feat(richtext-lexical): support copy & pasting and drag & dopping files/images into the editor (#13868)

This PR adds support for inserting images into the rich text editor via
both **copy & paste** and **drag & drop**, whether from local files or
image DOM nodes.

It leverages the bulk uploads UI to provide a smooth workflow for:
- Selecting the target collection
- Filling in any required fields defined on the uploads collection
- Uploading multiple images at once

This significantly improves the UX for adding images to rich text, and
also works seamlessly when pasting images from external editors like
Google Docs or Microsoft Word.

Test pre-release: `3.57.0-internal.801ab5a`

## Showcase - drag & drop images from computer


https://github.com/user-attachments/assets/c558c034-d2e4-40d8-9035-c0681389fb7b

## Showcase - copy & paste images from computer


https://github.com/user-attachments/assets/f36faf94-5274-4151-b141-00aff2b0efa4

## Showcase - copy & paste image DOM nodes


https://github.com/user-attachments/assets/2839ed0f-3f28-4e8d-8b47-01d0cb947edc

---
- To see the specific tasks where the Asana app for GitHub is being
used, see below:
  - https://app.asana.com/0/0/1211217132290841
This commit is contained in:
Alessio Gravili
2025-09-24 08:04:46 -07:00
committed by GitHub
parent 062c1d7e89
commit 59414bd8f1
23 changed files with 1061 additions and 160 deletions

View File

@@ -287,6 +287,10 @@ jobs:
- folders - folders
- hooks - hooks
- lexical__collections___LexicalFullyFeatured - lexical__collections___LexicalFullyFeatured
- lexical__collections___LexicalFullyFeatured__db
- lexical__collections__LexicalHeadingFeature
- lexical__collections__LexicalJSXConverter
- lexical__collections__LexicalLinkFeature
- lexical__collections__OnDemandForm - lexical__collections__OnDemandForm
- lexical__collections__Lexical__e2e__main - lexical__collections__Lexical__e2e__main
- lexical__collections__Lexical__e2e__blocks - lexical__collections__Lexical__e2e__blocks
@@ -427,6 +431,10 @@ jobs:
- folders - folders
- hooks - hooks
- lexical__collections___LexicalFullyFeatured - lexical__collections___LexicalFullyFeatured
- lexical__collections___LexicalFullyFeatured__db
- lexical__collections__LexicalHeadingFeature
- lexical__collections__LexicalJSXConverter
- lexical__collections__LexicalLinkFeature
- lexical__collections__OnDemandForm - lexical__collections__OnDemandForm
- lexical__collections__Lexical__e2e__main - lexical__collections__Lexical__e2e__main
- lexical__collections__Lexical__e2e__blocks - lexical__collections__Lexical__e2e__blocks

View File

@@ -0,0 +1,13 @@
'use client'
import { ShimmerEffect } from '@payloadcms/ui'
import '../index.scss'
export const PendingUploadComponent = (): React.ReactNode => {
return (
<div className={'lexical-upload'}>
<ShimmerEffect height={'95px'} width={'203px'} />
</div>
)
}

View File

@@ -1,53 +1,25 @@
'use client' 'use client'
import type { SerializedDecoratorBlockNode } from '@lexical/react/LexicalDecoratorBlockNode.js' import type { DOMConversionMap, LexicalNode } from 'lexical'
import type { DOMConversionMap, DOMConversionOutput, LexicalNode, Spread } from 'lexical'
import type { JSX } from 'react' import type { JSX } from 'react'
import ObjectID from 'bson-objectid' import ObjectID from 'bson-objectid'
import { $applyNodeReplacement } from 'lexical' import { $applyNodeReplacement } from 'lexical'
import * as React from 'react' import * as React from 'react'
import type { UploadData } from '../../server/nodes/UploadNode.js' import type {
Internal_UploadData,
SerializedUploadNode,
UploadData,
} from '../../server/nodes/UploadNode.js'
import { isGoogleDocCheckboxImg, UploadServerNode } from '../../server/nodes/UploadNode.js' import { $convertUploadElement } from '../../server/nodes/conversions.js'
import { UploadServerNode } from '../../server/nodes/UploadNode.js'
import { PendingUploadComponent } from '../component/pending/index.js'
const RawUploadComponent = React.lazy(() => const RawUploadComponent = React.lazy(() =>
import('../../client/component/index.js').then((module) => ({ default: module.UploadComponent })), import('../../client/component/index.js').then((module) => ({ default: module.UploadComponent })),
) )
function $convertUploadElement(domNode: HTMLImageElement): DOMConversionOutput | null {
if (
domNode.hasAttribute('data-lexical-upload-relation-to') &&
domNode.hasAttribute('data-lexical-upload-id')
) {
const id = domNode.getAttribute('data-lexical-upload-id')
const relationTo = domNode.getAttribute('data-lexical-upload-relation-to')
if (id != null && relationTo != null) {
const node = $createUploadNode({
data: {
fields: {},
relationTo,
value: id,
},
})
return { node }
}
}
const img = domNode
if (img.src.startsWith('file:///') || isGoogleDocCheckboxImg(img)) {
return null
}
// TODO: Auto-upload functionality here!
//}
return null
}
export type SerializedUploadNode = {
children?: never // required so that our typed editor state doesn't automatically add children
type: 'upload'
} & Spread<UploadData, SerializedDecoratorBlockNode>
export class UploadNode extends UploadServerNode { export class UploadNode extends UploadServerNode {
static override clone(node: UploadServerNode): UploadServerNode { static override clone(node: UploadServerNode): UploadServerNode {
return super.clone(node) return super.clone(node)
@@ -60,7 +32,7 @@ export class UploadNode extends UploadServerNode {
static override importDOM(): DOMConversionMap<HTMLImageElement> { static override importDOM(): DOMConversionMap<HTMLImageElement> {
return { return {
img: (node) => ({ img: (node) => ({
conversion: $convertUploadElement, conversion: (domNode) => $convertUploadElement(domNode, $createUploadNode),
priority: 0, priority: 0,
}), }),
} }
@@ -75,9 +47,10 @@ export class UploadNode extends UploadServerNode {
serializedNode.version = 3 serializedNode.version = 3
} }
const importedData: UploadData = { const importedData: Internal_UploadData = {
id: serializedNode.id, id: serializedNode.id,
fields: serializedNode.fields, fields: serializedNode.fields,
pending: (serializedNode as Internal_UploadData).pending,
relationTo: serializedNode.relationTo, relationTo: serializedNode.relationTo,
value: serializedNode.value, value: serializedNode.value,
} }
@@ -89,6 +62,9 @@ export class UploadNode extends UploadServerNode {
} }
override decorate(): JSX.Element { override decorate(): JSX.Element {
if ((this.__data as Internal_UploadData).pending) {
return <PendingUploadComponent />
}
return <RawUploadComponent data={this.__data} nodeKey={this.getKey()} /> return <RawUploadComponent data={this.__data} nodeKey={this.getKey()} />
} }
@@ -105,6 +81,7 @@ export function $createUploadNode({
if (!data?.id) { if (!data?.id) {
data.id = new ObjectID.default().toHexString() data.id = new ObjectID.default().toHexString()
} }
return $applyNodeReplacement(new UploadNode({ data: data as UploadData })) return $applyNodeReplacement(new UploadNode({ data: data as UploadData }))
} }

View File

@@ -2,42 +2,225 @@
import type { LexicalCommand } from 'lexical' import type { LexicalCommand } from 'lexical'
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext.js' import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext.js'
import { $insertNodeToNearestRoot, mergeRegister } from '@lexical/utils' import { $dfsIterator, $insertNodeToNearestRoot, mergeRegister } from '@lexical/utils'
import { useConfig } from '@payloadcms/ui' import { useBulkUpload, useConfig, useEffectEvent, useModal } from '@payloadcms/ui'
import ObjectID from 'bson-objectid'
import { import {
$createRangeSelection,
$getPreviousSelection, $getPreviousSelection,
$getSelection, $getSelection,
$isParagraphNode, $isParagraphNode,
$isRangeSelection, $isRangeSelection,
$setSelection,
COMMAND_PRIORITY_EDITOR, COMMAND_PRIORITY_EDITOR,
COMMAND_PRIORITY_LOW,
createCommand, createCommand,
DROP_COMMAND,
getDOMSelectionFromTarget,
isHTMLElement,
PASTE_COMMAND,
} from 'lexical' } from 'lexical'
import React, { useEffect } from 'react' import React, { useEffect } from 'react'
import type { PluginComponent } from '../../../typesClient.js' import type { PluginComponent } from '../../../typesClient.js'
import type { UploadData } from '../../server/nodes/UploadNode.js' import type { Internal_UploadData, UploadData } from '../../server/nodes/UploadNode.js'
import type { UploadFeaturePropsClient } from '../index.js' import type { UploadFeaturePropsClient } from '../index.js'
import { UploadDrawer } from '../drawer/index.js' import { UploadDrawer } from '../drawer/index.js'
import { $createUploadNode, UploadNode } from '../nodes/UploadNode.js' import { $createUploadNode, $isUploadNode, UploadNode } from '../nodes/UploadNode.js'
export type InsertUploadPayload = Readonly<Omit<UploadData, 'id'> & Partial<Pick<UploadData, 'id'>>> export type InsertUploadPayload = Readonly<Omit<UploadData, 'id'> & Partial<Pick<UploadData, 'id'>>>
declare global {
interface DragEvent {
rangeOffset?: number
rangeParent?: Node
}
}
function canDropImage(event: DragEvent): boolean {
const target = event.target
return !!(
isHTMLElement(target) &&
!target.closest('code, span.editor-image') &&
isHTMLElement(target.parentElement) &&
target.parentElement.closest('div.ContentEditable__root')
)
}
function getDragSelection(event: DragEvent): null | Range | undefined {
// Source: https://github.com/AlessioGr/lexical/blob/main/packages/lexical-playground/src/plugins/ImagesPlugin/index.tsx
let range
const domSelection = getDOMSelectionFromTarget(event.target)
if (document.caretRangeFromPoint) {
range = document.caretRangeFromPoint(event.clientX, event.clientY)
} else if (event.rangeParent && domSelection !== null) {
domSelection.collapse(event.rangeParent, event.rangeOffset || 0)
range = domSelection.getRangeAt(0)
} else {
throw Error(`Cannot get the selection when dragging`)
}
return range
}
export const INSERT_UPLOAD_COMMAND: LexicalCommand<InsertUploadPayload> = export const INSERT_UPLOAD_COMMAND: LexicalCommand<InsertUploadPayload> =
createCommand('INSERT_UPLOAD_COMMAND') createCommand('INSERT_UPLOAD_COMMAND')
export const UploadPlugin: PluginComponent<UploadFeaturePropsClient> = ({ clientProps }) => { type FileToUpload = {
alt?: string
file: File
/**
* Bulk Upload Form ID that should be created, which can then be matched
* against the node formID if the upload is successful
*/
formID: string
}
export const UploadPlugin: PluginComponent<UploadFeaturePropsClient> = () => {
const [editor] = useLexicalComposerContext() const [editor] = useLexicalComposerContext()
const { const {
config: { collections }, config: { collections },
} = useConfig() } = useConfig()
const {
drawerSlug: bulkUploadDrawerSlug,
setCollectionSlug,
setInitialForms,
setOnCancel,
setOnSuccess,
setSelectableCollections,
} = useBulkUpload()
const { isModalOpen, openModal } = useModal()
const openBulkUpload = useEffectEvent(({ files }: { files: FileToUpload[] }) => {
if (files?.length === 0) {
return
}
setInitialForms((initialForms) => [
...(initialForms ?? []),
...files.map((file) => ({
file: file.file,
formID: file.formID,
})),
])
if (!isModalOpen(bulkUploadDrawerSlug)) {
const uploadCollections = collections.filter(({ upload }) => !!upload).map(({ slug }) => slug)
if (!uploadCollections.length || !uploadCollections[0]) {
return
}
setCollectionSlug(uploadCollections[0])
setSelectableCollections(uploadCollections)
setOnCancel(() => {
// Remove all the pending upload nodes that were added but not uploaded
editor.update(() => {
for (const dfsNode of $dfsIterator()) {
const node = dfsNode.node
if ($isUploadNode(node)) {
const nodeData = node.getData()
if ((nodeData as Internal_UploadData)?.pending) {
node.remove()
}
}
}
})
})
setOnSuccess((newDocs) => {
const newDocsMap = new Map(newDocs.map((doc) => [doc.formID, doc]))
editor.update(() => {
for (const dfsNode of $dfsIterator()) {
const node = dfsNode.node
if ($isUploadNode(node)) {
const nodeData: Internal_UploadData = node.getData()
if (nodeData?.pending) {
const newDoc = newDocsMap.get(nodeData.pending?.formID)
if (newDoc) {
node.replace(
$createUploadNode({
data: {
id: new ObjectID.default().toHexString(),
fields: {},
relationTo: newDoc.collectionSlug,
value: newDoc.doc.id,
} as UploadData,
}),
)
}
}
}
}
})
})
openModal(bulkUploadDrawerSlug)
}
})
useEffect(() => { useEffect(() => {
if (!editor.hasNodes([UploadNode])) { if (!editor.hasNodes([UploadNode])) {
throw new Error('UploadPlugin: UploadNode not registered on editor') throw new Error('UploadPlugin: UploadNode not registered on editor')
} }
return mergeRegister( return mergeRegister(
/**
* Handle auto-uploading files if you copy & paste an image dom element from the clipboard
*/
editor.registerNodeTransform(UploadNode, (node) => {
const nodeData: Internal_UploadData = node.getData()
if (!nodeData?.pending) {
return
}
async function upload() {
let transformedImage: FileToUpload | null = null
const src = nodeData?.pending?.src
const formID = nodeData?.pending?.formID as string
if (src?.startsWith('data:')) {
// It's a base64-encoded image
const mimeMatch = src.match(/data:(image\/[a-zA-Z]+);base64,/)
const mimeType = mimeMatch ? mimeMatch[1] : 'image/png' // Default to PNG if MIME type not found
const base64Data = src.replace(/^data:image\/[a-zA-Z]+;base64,/, '')
const byteCharacters = atob(base64Data)
const byteNumbers = new Array(byteCharacters.length)
for (let i = 0; i < byteCharacters.length; i++) {
byteNumbers[i] = byteCharacters.charCodeAt(i)
}
const byteArray = new Uint8Array(byteNumbers)
const file = new File([byteArray], 'pasted-image.' + mimeType?.split('/')[1], {
type: mimeType,
})
transformedImage = { alt: undefined, file, formID }
} else if (src?.startsWith('http') || src?.startsWith('https')) {
// It's an image URL
const res = await fetch(src)
const blob = await res.blob()
const inferredFileName =
src.split('/').pop() || 'pasted-image' + blob.type.split('/')[1]
const file = new File([blob], inferredFileName, {
type: blob.type,
})
transformedImage = { alt: undefined, file, formID }
}
if (!transformedImage) {
return
}
openBulkUpload({ files: [transformedImage] })
}
void upload()
}),
editor.registerCommand<InsertUploadPayload>( editor.registerCommand<InsertUploadPayload>(
INSERT_UPLOAD_COMMAND, INSERT_UPLOAD_COMMAND,
(payload: InsertUploadPayload) => { (payload: InsertUploadPayload) => {
@@ -70,6 +253,145 @@ export const UploadPlugin: PluginComponent<UploadFeaturePropsClient> = ({ client
}, },
COMMAND_PRIORITY_EDITOR, COMMAND_PRIORITY_EDITOR,
), ),
editor.registerCommand(
PASTE_COMMAND,
(event) => {
// Pending UploadNodes are automatically created when importDOM is called. However, if you paste a file from your computer
// directly, importDOM won't be called, as it's not a HTML dom element. So we need to handle that case here.
if (!(event instanceof ClipboardEvent)) {
return false
}
const clipboardData = event.clipboardData
if (!clipboardData?.types?.length || clipboardData?.types?.includes('text/html')) {
// HTML is handled through importDOM => registerNodeTransform for pending UploadNode
return false
}
const files: FileToUpload[] = []
if (clipboardData?.files?.length) {
Array.from(clipboardData.files).forEach((file) => {
files.push({
alt: '',
file,
formID: new ObjectID.default().toHexString(),
})
})
}
if (files.length) {
// Insert a pending UploadNode for each image
editor.update(() => {
const selection = $getSelection() || $getPreviousSelection()
if ($isRangeSelection(selection)) {
for (const file of files) {
const pendingUploadNode = new UploadNode({
data: {
pending: {
formID: file.formID,
src: URL.createObjectURL(file.file),
},
} as Internal_UploadData,
})
// we need to get the focus node before inserting the upload node, as $insertNodeToNearestRoot can change the focus node
const { focus } = selection
const focusNode = focus.getNode()
// Insert upload node BEFORE potentially removing focusNode, as $insertNodeToNearestRoot errors if the focusNode doesn't exist
$insertNodeToNearestRoot(pendingUploadNode)
// Delete the node it it's an empty paragraph
if ($isParagraphNode(focusNode) && !focusNode.__first) {
focusNode.remove()
}
}
}
})
// Open the bulk drawer - the node transform will not open it for us, as it does not handle blob/file uploads
openBulkUpload({ files })
return true
}
return false
},
COMMAND_PRIORITY_LOW,
),
// Handle drag & drop of files from the desktop into the editor
editor.registerCommand(
DROP_COMMAND,
(event) => {
if (!(event instanceof DragEvent)) {
return false
}
const dt = event.dataTransfer
if (!dt?.types?.length) {
return false
}
const files: FileToUpload[] = []
if (dt?.files?.length) {
Array.from(dt.files).forEach((file) => {
files.push({
alt: '',
file,
formID: new ObjectID.default().toHexString(),
})
})
}
if (files.length) {
// Prevent the default browser drop handling, which would open the file in the browser
event.preventDefault()
event.stopPropagation()
// Insert a PendingUploadNode for each image
editor.update(() => {
if (canDropImage(event)) {
const range = getDragSelection(event)
const selection = $createRangeSelection()
if (range !== null && range !== undefined) {
selection.applyDOMRange(range)
}
$setSelection(selection)
for (const file of files) {
const pendingUploadNode = new UploadNode({
data: {
pending: {
formID: file.formID,
src: URL.createObjectURL(file.file),
},
} as Internal_UploadData,
})
// we need to get the focus node before inserting the upload node, as $insertNodeToNearestRoot can change the focus node
const { focus } = selection
const focusNode = focus.getNode()
// Insert upload node BEFORE potentially removing focusNode, as $insertNodeToNearestRoot errors if the focusNode doesn't exist
$insertNodeToNearestRoot(pendingUploadNode)
// Delete the node it it's an empty paragraph
if ($isParagraphNode(focusNode) && !focusNode.__first) {
focusNode.remove()
}
}
}
})
// Open the bulk drawer - the node transform will not open it for us, as it does not handle blob/file uploads
openBulkUpload({ files })
return true
}
return false
},
COMMAND_PRIORITY_LOW,
),
) )
}, [editor]) }, [editor])

View File

@@ -1,7 +1,6 @@
import type { SerializedDecoratorBlockNode } from '@lexical/react/LexicalDecoratorBlockNode.js' import type { SerializedDecoratorBlockNode } from '@lexical/react/LexicalDecoratorBlockNode.js'
import type { import type {
DOMConversionMap, DOMConversionMap,
DOMConversionOutput,
DOMExportOutput, DOMExportOutput,
ElementFormatType, ElementFormatType,
LexicalNode, LexicalNode,
@@ -20,7 +19,8 @@ import type { JSX } from 'react'
import { DecoratorBlockNode } from '@lexical/react/LexicalDecoratorBlockNode.js' import { DecoratorBlockNode } from '@lexical/react/LexicalDecoratorBlockNode.js'
import ObjectID from 'bson-objectid' import ObjectID from 'bson-objectid'
import { $applyNodeReplacement } from 'lexical' import { $applyNodeReplacement } from 'lexical'
import * as React from 'react'
import { $convertUploadElement } from './conversions.js'
export type UploadData<TUploadExtraFieldsData extends JsonObject = JsonObject> = { export type UploadData<TUploadExtraFieldsData extends JsonObject = JsonObject> = {
[TCollectionSlug in CollectionSlug]: { [TCollectionSlug in CollectionSlug]: {
@@ -37,6 +37,23 @@ export type UploadData<TUploadExtraFieldsData extends JsonObject = JsonObject> =
} }
}[CollectionSlug] }[CollectionSlug]
/**
* Internal use only - UploadData type that can contain a pending state
* @internal
*/
export type Internal_UploadData<TUploadExtraFieldsData extends JsonObject = JsonObject> = {
pending?: {
/**
* ID that corresponds to the bulk upload form ID
*/
formID: string
/**
* src value of the image dom element
*/
src: string
}
} & UploadData<TUploadExtraFieldsData>
/** /**
* UploadDataImproved is a more precise type, and will replace UploadData in Payload v4. * UploadDataImproved is a more precise type, and will replace UploadData in Payload v4.
* This type is for internal use only as it will be deprecated in the future. * This type is for internal use only as it will be deprecated in the future.
@@ -59,43 +76,6 @@ export type UploadDataImproved<TUploadExtraFieldsData extends JsonObject = JsonO
} }
}[UploadCollectionSlug] }[UploadCollectionSlug]
export function isGoogleDocCheckboxImg(img: HTMLImageElement): boolean {
return (
img.parentElement != null &&
img.parentElement.tagName === 'LI' &&
img.previousSibling === null &&
img.getAttribute('aria-roledescription') === 'checkbox'
)
}
function $convertUploadServerElement(domNode: HTMLImageElement): DOMConversionOutput | null {
if (
domNode.hasAttribute('data-lexical-upload-relation-to') &&
domNode.hasAttribute('data-lexical-upload-id')
) {
const id = domNode.getAttribute('data-lexical-upload-id')
const relationTo = domNode.getAttribute('data-lexical-upload-relation-to')
if (id != null && relationTo != null) {
const node = $createUploadServerNode({
data: {
fields: {},
relationTo,
value: id,
},
})
return { node }
}
}
const img = domNode
if (img.src.startsWith('file:///') || isGoogleDocCheckboxImg(img)) {
return null
}
// TODO: Auto-upload functionality here!
//}
return null
}
export type SerializedUploadNode = { export type SerializedUploadNode = {
children?: never // required so that our typed editor state doesn't automatically add children children?: never // required so that our typed editor state doesn't automatically add children
type: 'upload' type: 'upload'
@@ -132,7 +112,7 @@ export class UploadServerNode extends DecoratorBlockNode {
static override importDOM(): DOMConversionMap<HTMLImageElement> { static override importDOM(): DOMConversionMap<HTMLImageElement> {
return { return {
img: (node) => ({ img: (node) => ({
conversion: $convertUploadServerElement, conversion: (domNode) => $convertUploadElement(domNode, $createUploadServerNode),
priority: 0, priority: 0,
}), }),
} }
@@ -147,9 +127,10 @@ export class UploadServerNode extends DecoratorBlockNode {
serializedNode.version = 3 serializedNode.version = 3
} }
const importedData: UploadData = { const importedData: Internal_UploadData = {
id: serializedNode.id, id: serializedNode.id,
fields: serializedNode.fields, fields: serializedNode.fields,
pending: (serializedNode as Internal_UploadData).pending,
relationTo: serializedNode.relationTo, relationTo: serializedNode.relationTo,
value: serializedNode.value, value: serializedNode.value,
} }
@@ -165,14 +146,19 @@ export class UploadServerNode extends DecoratorBlockNode {
} }
override decorate(): JSX.Element { override decorate(): JSX.Element {
// @ts-expect-error return null as unknown as JSX.Element
return <RawUploadComponent data={this.__data} format={this.__format} nodeKey={this.getKey()} />
} }
override exportDOM(): DOMExportOutput { override exportDOM(): DOMExportOutput {
const element = document.createElement('img') const element = document.createElement('img')
element.setAttribute('data-lexical-upload-id', String(this.__data?.value)) const data = this.__data as Internal_UploadData
element.setAttribute('data-lexical-upload-relation-to', this.__data?.relationTo) if (data.pending) {
element.setAttribute('data-lexical-pending-upload-form-id', String(data?.pending?.formID))
element.setAttribute('src', data?.pending?.src || '')
} else {
element.setAttribute('data-lexical-upload-id', String(data?.value))
element.setAttribute('data-lexical-upload-relation-to', data?.relationTo)
}
return { element } return { element }
} }

View File

@@ -0,0 +1,68 @@
// This file contains functions used to convert dom elements to upload or pending upload lexical nodes. It requires the actual node
// creation functions to be passed in to stay compatible with both client and server code.
import type { DOMConversionOutput } from 'lexical'
import ObjectID from 'bson-objectid'
import type { $createUploadNode } from '../../client/nodes/UploadNode.js'
import type { $createUploadServerNode, Internal_UploadData } from './UploadNode.js'
export function isGoogleDocCheckboxImg(img: HTMLImageElement): boolean {
return (
img.parentElement != null &&
img.parentElement.tagName === 'LI' &&
img.previousSibling === null &&
img.getAttribute('aria-roledescription') === 'checkbox'
)
}
export function $convertUploadElement(
domNode: HTMLImageElement,
$createNode: typeof $createUploadNode | typeof $createUploadServerNode,
): DOMConversionOutput | null {
if (domNode.hasAttribute('data-lexical-pending-upload-form-id')) {
const formID = domNode.getAttribute('data-lexical-pending-upload-form-id')
if (formID != null) {
const node = $createNode({
data: {
pending: {
formID,
src: domNode.getAttribute('src') || '',
},
} as Internal_UploadData,
})
return { node }
}
}
if (
domNode.hasAttribute('data-lexical-upload-relation-to') &&
domNode.hasAttribute('data-lexical-upload-id')
) {
const id = domNode.getAttribute('data-lexical-upload-id')
const relationTo = domNode.getAttribute('data-lexical-upload-relation-to')
if (id != null && relationTo != null) {
const node = $createNode({
data: {
fields: {},
relationTo,
value: id,
},
})
return { node }
}
}
// Create pending UploadNode. Auto-Upload functionality will then be handled by the node transform
const node = $createNode({
data: {
pending: {
formID: new ObjectID.default().toHexString(),
src: domNode.getAttribute('src') || '',
},
} as Internal_UploadData,
})
return { node }
}

View File

@@ -8,7 +8,7 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
width: 300px; width: 300px;
overflow: auto; overflow: visible;
max-height: 100%; max-height: 100%;
&__header { &__header {
@@ -28,6 +28,19 @@
} }
} }
&__collectionSelect {
width: 100%;
.react-select {
width: 100%;
}
.field-type__wrap {
width: 100%;
padding-block: var(--base);
padding-inline: var(--file-gutter-h);
}
}
&__headerTopRow { &__headerTopRow {
display: flex; display: flex;
align-items: center; align-items: center;

View File

@@ -5,8 +5,10 @@ import { useWindowInfo } from '@faceless-ui/window-info'
import { isImage } from 'payload/shared' import { isImage } from 'payload/shared'
import React from 'react' import React from 'react'
import { SelectInput } from '../../../fields/Select/Input.js'
import { ChevronIcon } from '../../../icons/Chevron/index.js' import { ChevronIcon } from '../../../icons/Chevron/index.js'
import { XIcon } from '../../../icons/X/index.js' import { XIcon } from '../../../icons/X/index.js'
import { useConfig } from '../../../providers/Config/index.js'
import { useTranslation } from '../../../providers/Translation/index.js' import { useTranslation } from '../../../providers/Translation/index.js'
import { AnimateHeight } from '../../AnimateHeight/index.js' import { AnimateHeight } from '../../AnimateHeight/index.js'
import { Button } from '../../Button/index.js' import { Button } from '../../Button/index.js'
@@ -18,9 +20,9 @@ import { createThumbnail } from '../../Thumbnail/createThumbnail.js'
import { Thumbnail } from '../../Thumbnail/index.js' import { Thumbnail } from '../../Thumbnail/index.js'
import { Actions } from '../ActionsBar/index.js' import { Actions } from '../ActionsBar/index.js'
import { AddFilesView } from '../AddFilesView/index.js' import { AddFilesView } from '../AddFilesView/index.js'
import './index.scss'
import { useFormsManager } from '../FormsManager/index.js' import { useFormsManager } from '../FormsManager/index.js'
import { useBulkUpload } from '../index.js' import { useBulkUpload } from '../index.js'
import './index.scss'
const addMoreFilesDrawerSlug = 'bulk-upload-drawer--add-more-files' const addMoreFilesDrawerSlug = 'bulk-upload-drawer--add-more-files'
@@ -36,7 +38,7 @@ export function FileSidebar() {
setActiveIndex, setActiveIndex,
totalErrorCount, totalErrorCount,
} = useFormsManager() } = useFormsManager()
const { initialFiles, maxFiles } = useBulkUpload() const { initialFiles, initialForms, maxFiles } = useBulkUpload()
const { i18n, t } = useTranslation() const { i18n, t } = useTranslation()
const { closeModal, openModal } = useModal() const { closeModal, openModal } = useModal()
const [showFiles, setShowFiles] = React.useState(false) const [showFiles, setShowFiles] = React.useState(false)
@@ -66,7 +68,17 @@ export function FileSidebar() {
return formattedSize return formattedSize
}, []) }, [])
const totalFileCount = isInitializing ? initialFiles.length : forms.length const totalFileCount = isInitializing
? (initialFiles?.length ?? initialForms?.length)
: forms.length
const {
collectionSlug: bulkUploadCollectionSlug,
selectableCollections,
setCollectionSlug,
} = useBulkUpload()
const { getEntityConfig } = useConfig()
return ( return (
<div <div
@@ -74,6 +86,29 @@ export function FileSidebar() {
> >
{breakpoints.m && showFiles ? <div className={`${baseClass}__mobileBlur`} /> : null} {breakpoints.m && showFiles ? <div className={`${baseClass}__mobileBlur`} /> : null}
<div className={`${baseClass}__header`}> <div className={`${baseClass}__header`}>
{selectableCollections?.length > 1 && (
<SelectInput
className={`${baseClass}__collectionSelect`}
isClearable={false}
name="groupBy"
onChange={(e) => {
const val: string =
typeof e === 'object' && 'value' in e
? (e?.value as string)
: (e as unknown as string)
setCollectionSlug(val)
}}
options={
selectableCollections?.map((coll) => {
const config = getEntityConfig({ collectionSlug: coll })
return { label: config.labels.singular, value: config.slug }
}) || []
}
path="groupBy"
required
value={bulkUploadCollectionSlug}
/>
)}
<div className={`${baseClass}__headerTopRow`}> <div className={`${baseClass}__headerTopRow`}>
<div className={`${baseClass}__header__text`}> <div className={`${baseClass}__header__text`}>
<ErrorPill count={totalErrorCount} i18n={i18n} withMessage /> <ErrorPill count={totalErrorCount} i18n={i18n} withMessage />
@@ -130,8 +165,10 @@ export function FileSidebar() {
<div className={`${baseClass}__animateWrapper`}> <div className={`${baseClass}__animateWrapper`}>
<AnimateHeight height={!breakpoints.m || showFiles ? 'auto' : 0}> <AnimateHeight height={!breakpoints.m || showFiles ? 'auto' : 0}>
<div className={`${baseClass}__filesContainer`}> <div className={`${baseClass}__filesContainer`}>
{isInitializing && forms.length === 0 && initialFiles.length > 0 {isInitializing &&
? Array.from(initialFiles).map((file, index) => ( forms.length === 0 &&
(initialFiles?.length > 0 || initialForms?.length > 0)
? (initialFiles ? Array.from(initialFiles) : initialForms).map((file, index) => (
<ShimmerEffect <ShimmerEffect
animationDelay={`calc(${index} * ${60}ms)`} animationDelay={`calc(${index} * ${60}ms)`}
height="35px" height="35px"

View File

@@ -1,9 +1,11 @@
'use client' 'use client'
import type { import type {
CollectionSlug,
Data, Data,
DocumentSlots, DocumentSlots,
FormState, FormState,
JsonObject,
SanitizedDocumentPermissions, SanitizedDocumentPermissions,
UploadEdits, UploadEdits,
} from 'payload' } from 'payload'
@@ -16,6 +18,7 @@ import { toast } from 'sonner'
import type { State } from './reducer.js' import type { State } from './reducer.js'
import { fieldReducer } from '../../../forms/Form/fieldReducer.js' import { fieldReducer } from '../../../forms/Form/fieldReducer.js'
import { useEffectEvent } from '../../../hooks/useEffectEvent.js'
import { useConfig } from '../../../providers/Config/index.js' import { useConfig } from '../../../providers/Config/index.js'
import { useLocale } from '../../../providers/Locale/index.js' import { useLocale } from '../../../providers/Locale/index.js'
import { useServerFunctions } from '../../../providers/ServerFunctions/index.js' import { useServerFunctions } from '../../../providers/ServerFunctions/index.js'
@@ -86,6 +89,12 @@ const initialState: State = {
totalErrorCount: 0, totalErrorCount: 0,
} }
export type InitialForms = Array<{
file: File
formID?: string
initialState?: FormState | null
}>
type FormsManagerProps = { type FormsManagerProps = {
readonly children: React.ReactNode readonly children: React.ReactNode
} }
@@ -118,7 +127,16 @@ export function FormsManagerProvider({ children }: FormsManagerProps) {
const { toggleLoadingOverlay } = useLoadingOverlay() const { toggleLoadingOverlay } = useLoadingOverlay()
const { closeModal } = useModal() const { closeModal } = useModal()
const { collectionSlug, drawerSlug, initialFiles, onSuccess, setInitialFiles } = useBulkUpload() const {
collectionSlug,
drawerSlug,
initialFiles,
initialForms,
onSuccess,
setInitialFiles,
setInitialForms,
setSuccessfullyUploaded,
} = useBulkUpload()
const [isUploading, setIsUploading] = React.useState(false) const [isUploading, setIsUploading] = React.useState(false)
const [loadingText, setLoadingText] = React.useState('') const [loadingText, setLoadingText] = React.useState('')
@@ -244,12 +262,38 @@ export function FormsManagerProvider({ children }: FormsManagerProps) {
if (!hasInitializedState) { if (!hasInitializedState) {
await initializeSharedFormState() await initializeSharedFormState()
} }
dispatch({ type: 'ADD_FORMS', files, initialState: initialStateRef.current }) dispatch({
type: 'ADD_FORMS',
forms: Array.from(files).map((file) => ({
file,
initialState: initialStateRef.current,
})),
})
toggleLoadingOverlay({ isLoading: false, key: 'addingDocs' }) toggleLoadingOverlay({ isLoading: false, key: 'addingDocs' })
}, },
[initializeSharedFormState, hasInitializedState, toggleLoadingOverlay, activeIndex, forms], [initializeSharedFormState, hasInitializedState, toggleLoadingOverlay, activeIndex, forms],
) )
const addFilesEffectEvent = useEffectEvent(addFiles)
const addInitialForms = useEffectEvent(async (initialForms: InitialForms) => {
toggleLoadingOverlay({ isLoading: true, key: 'addingDocs' })
if (!hasInitializedState) {
await initializeSharedFormState()
}
dispatch({
type: 'ADD_FORMS',
forms: initialForms.map((form) => ({
...form,
initialState: form?.initialState || initialStateRef.current,
})),
})
toggleLoadingOverlay({ isLoading: false, key: 'addingDocs' })
})
const removeFile: FormsManagerContext['removeFile'] = React.useCallback((index) => { const removeFile: FormsManagerContext['removeFile'] = React.useCallback((index) => {
dispatch({ type: 'REMOVE_FORM', index }) dispatch({ type: 'REMOVE_FORM', index })
}, []) }, [])
@@ -275,7 +319,14 @@ export function FormsManagerProvider({ children }: FormsManagerProps) {
formState: currentFormsData, formState: currentFormsData,
uploadEdits: currentForms[activeIndex].uploadEdits, uploadEdits: currentForms[activeIndex].uploadEdits,
} }
const newDocs = [] const newDocs: Array<{
collectionSlug: CollectionSlug
doc: JsonObject
/**
* ID of the form that created this document
*/
formID: string
}> = []
setIsUploading(true) setIsUploading(true)
@@ -309,7 +360,11 @@ export function FormsManagerProvider({ children }: FormsManagerProps) {
const json = await req.json() const json = await req.json()
if (req.status === 201 && json?.doc) { if (req.status === 201 && json?.doc) {
newDocs.push(json.doc) newDocs.push({
collectionSlug,
doc: json.doc,
formID: form.formID,
})
} }
// should expose some sort of helper for this // should expose some sort of helper for this
@@ -380,6 +435,7 @@ export function FormsManagerProvider({ children }: FormsManagerProps) {
if (successCount) { if (successCount) {
toast.success(`Successfully saved ${successCount} files`) toast.success(`Successfully saved ${successCount} files`)
setSuccessfullyUploaded(true)
if (typeof onSuccess === 'function') { if (typeof onSuccess === 'function') {
onSuccess(newDocs, errorCount) onSuccess(newDocs, errorCount)
@@ -403,20 +459,23 @@ export function FormsManagerProvider({ children }: FormsManagerProps) {
if (remainingForms.length === 0) { if (remainingForms.length === 0) {
setInitialFiles(undefined) setInitialFiles(undefined)
setInitialForms(undefined)
} }
}, },
[ [
setInitialFiles,
actionURL,
collectionSlug,
getUploadHandler,
t,
forms, forms,
activeIndex, activeIndex,
closeModal, t,
drawerSlug, actionURL,
onSuccess,
code, code,
collectionSlug,
getUploadHandler,
onSuccess,
closeModal,
setSuccessfullyUploaded,
drawerSlug,
setInitialFiles,
setInitialForms,
], ],
) )
@@ -504,7 +563,7 @@ export function FormsManagerProvider({ children }: FormsManagerProps) {
void initializeSharedDocPermissions() void initializeSharedDocPermissions()
} }
if (initialFiles) { if (initialFiles || initialForms) {
if (!hasInitializedState || !hasInitializedDocPermissions) { if (!hasInitializedState || !hasInitializedDocPermissions) {
setIsInitializing(true) setIsInitializing(true)
} else { } else {
@@ -512,19 +571,28 @@ export function FormsManagerProvider({ children }: FormsManagerProps) {
} }
} }
if (hasInitializedState && initialFiles && !hasInitializedWithFiles.current) { if (
void addFiles(initialFiles) hasInitializedState &&
(initialForms?.length || initialFiles?.length) &&
!hasInitializedWithFiles.current
) {
if (initialForms?.length) {
void addInitialForms(initialForms)
}
if (initialFiles?.length) {
void addFilesEffectEvent(initialFiles)
}
hasInitializedWithFiles.current = true hasInitializedWithFiles.current = true
} }
return return
}, [ }, [
addFiles,
initialFiles, initialFiles,
initializeSharedFormState, initializeSharedFormState,
initializeSharedDocPermissions, initializeSharedDocPermissions,
collectionSlug, collectionSlug,
hasInitializedState, hasInitializedState,
hasInitializedDocPermissions, hasInitializedDocPermissions,
initialForms,
]) ])
return ( return (

View File

@@ -2,6 +2,8 @@ import type { FormState, UploadEdits } from 'payload'
import { v4 as uuidv4 } from 'uuid' import { v4 as uuidv4 } from 'uuid'
import type { InitialForms } from './index.js'
export type State = { export type State = {
activeIndex: number activeIndex: number
forms: { forms: {
@@ -28,8 +30,7 @@ type Action =
uploadEdits?: UploadEdits uploadEdits?: UploadEdits
} }
| { | {
files: FileList forms: InitialForms
initialState: FormState | null
type: 'ADD_FORMS' type: 'ADD_FORMS'
} }
| { | {
@@ -49,16 +50,16 @@ export function formsManagementReducer(state: State, action: Action): State {
switch (action.type) { switch (action.type) {
case 'ADD_FORMS': { case 'ADD_FORMS': {
const newForms: State['forms'] = [] const newForms: State['forms'] = []
for (let i = 0; i < action.files.length; i++) { for (let i = 0; i < action.forms.length; i++) {
newForms[i] = { newForms[i] = {
errorCount: 0, errorCount: 0,
formID: crypto.randomUUID ? crypto.randomUUID() : uuidv4(), formID: action.forms[i].formID ?? (crypto.randomUUID ? crypto.randomUUID() : uuidv4()),
formState: { formState: {
...(action.initialState || {}), ...(action.forms[i].initialState || {}),
file: { file: {
initialValue: action.files[i], initialValue: action.forms[i].file,
valid: true, valid: true,
value: action.files[i], value: action.forms[i].file,
}, },
}, },
uploadEdits: {}, uploadEdits: {},

View File

@@ -1,12 +1,13 @@
'use client' 'use client'
import type { JsonObject } from 'payload' import type { CollectionSlug, JsonObject } from 'payload'
import { useModal } from '@faceless-ui/modal' import { useModal } from '@faceless-ui/modal'
import { validateMimeType } from 'payload/shared' import { validateMimeType } from 'payload/shared'
import React from 'react' import React, { useEffect } from 'react'
import { toast } from 'sonner' import { toast } from 'sonner'
import { useEffectEvent } from '../../hooks/useEffectEvent.js'
import { useConfig } from '../../providers/Config/index.js' import { useConfig } from '../../providers/Config/index.js'
import { EditDepthProvider } from '../../providers/EditDepth/index.js' import { EditDepthProvider } from '../../providers/EditDepth/index.js'
import { useTranslation } from '../../providers/Translation/index.js' import { useTranslation } from '../../providers/Translation/index.js'
@@ -14,7 +15,7 @@ import { UploadControlsProvider } from '../../providers/UploadControls/index.js'
import { Drawer, useDrawerDepth } from '../Drawer/index.js' import { Drawer, useDrawerDepth } from '../Drawer/index.js'
import { AddFilesView } from './AddFilesView/index.js' import { AddFilesView } from './AddFilesView/index.js'
import { AddingFilesView } from './AddingFilesView/index.js' import { AddingFilesView } from './AddingFilesView/index.js'
import { FormsManagerProvider, useFormsManager } from './FormsManager/index.js' import { FormsManagerProvider, type InitialForms, useFormsManager } from './FormsManager/index.js'
const drawerSlug = 'bulk-upload-drawer-slug' const drawerSlug = 'bulk-upload-drawer-slug'
@@ -72,7 +73,56 @@ export type BulkUploadProps = {
} }
export function BulkUploadDrawer() { export function BulkUploadDrawer() {
const { drawerSlug } = useBulkUpload() const {
drawerSlug,
onCancel,
setInitialFiles,
setInitialForms,
setOnCancel,
setOnSuccess,
setSelectableCollections,
setSuccessfullyUploaded,
successfullyUploaded,
} = useBulkUpload()
const { modalState } = useModal()
const previousModalStateRef = React.useRef(modalState)
/**
* This is used to trigger onCancel when the drawer is closed (=> forms reset, as FormsManager is unmounted)
*/
const onModalStateChanged = useEffectEvent((modalState) => {
const previousModalState = previousModalStateRef.current[drawerSlug]
const currentModalState = modalState[drawerSlug]
if (typeof currentModalState === 'undefined' && typeof previousModalState === 'undefined') {
return
}
if (previousModalState?.isOpen !== currentModalState?.isOpen) {
if (!currentModalState?.isOpen) {
if (!successfullyUploaded) {
// It's only cancelled if successfullyUploaded is not set. Otherwise, this would simply be a modal close after success
// => do not call cancel, just reset everything
if (typeof onCancel === 'function') {
onCancel()
}
}
// Reset everything to defaults
setInitialFiles(undefined)
setInitialForms(undefined)
setOnCancel(() => () => null)
setOnSuccess(() => () => null)
setSelectableCollections(null)
setSuccessfullyUploaded(false)
}
}
previousModalStateRef.current = modalState
})
useEffect(() => {
onModalStateChanged(modalState)
}, [modalState])
return ( return (
<Drawer gutter={false} Header={null} slug={drawerSlug}> <Drawer gutter={false} Header={null} slug={drawerSlug}>
@@ -87,32 +137,68 @@ export function BulkUploadDrawer() {
) )
} }
type BulkUploadContext = { export type BulkUploadContext = {
collectionSlug: string collectionSlug: CollectionSlug
drawerSlug: string drawerSlug: string
initialFiles: FileList initialFiles: FileList
/**
* Like initialFiles, but allows manually providing initial form state or the form ID for each file
*/
initialForms: InitialForms
maxFiles: number maxFiles: number
onCancel: () => void onCancel: () => void
onSuccess: (newDocs: JsonObject[], errorCount: number) => void onSuccess: (
uploadedForms: Array<{
collectionSlug: CollectionSlug
doc: JsonObject
/**
* ID of the form that created this document
*/
formID: string
}>,
errorCount: number,
) => void
/**
* An array of collection slugs that can be selected in the collection dropdown (if applicable)
* @default null - collection cannot be selected
*/
selectableCollections?: null | string[]
setCollectionSlug: (slug: string) => void setCollectionSlug: (slug: string) => void
setInitialFiles: (files: FileList) => void setInitialFiles: (files: FileList) => void
setInitialForms: (
forms: ((forms: InitialForms | undefined) => InitialForms | undefined) | InitialForms,
) => void
setMaxFiles: (maxFiles: number) => void setMaxFiles: (maxFiles: number) => void
setOnCancel: (onCancel: BulkUploadContext['onCancel']) => void setOnCancel: (onCancel: BulkUploadContext['onCancel']) => void
setOnSuccess: (onSuccess: BulkUploadContext['onSuccess']) => void setOnSuccess: (onSuccess: BulkUploadContext['onSuccess']) => void
/**
* Set the collections that can be selected in the collection dropdown (if applicable)
*
* @default null - collection cannot be selected
*/
setSelectableCollections: (collections: null | string[]) => void
setSuccessfullyUploaded: (successfullyUploaded: boolean) => void
successfullyUploaded: boolean
} }
const Context = React.createContext<BulkUploadContext>({ const Context = React.createContext<BulkUploadContext>({
collectionSlug: '', collectionSlug: '',
drawerSlug: '', drawerSlug: '',
initialFiles: undefined, initialFiles: undefined,
initialForms: [],
maxFiles: undefined, maxFiles: undefined,
onCancel: () => null, onCancel: () => null,
onSuccess: () => null, onSuccess: () => null,
selectableCollections: null,
setCollectionSlug: () => null, setCollectionSlug: () => null,
setInitialFiles: () => null, setInitialFiles: () => null,
setInitialForms: () => null,
setMaxFiles: () => null, setMaxFiles: () => null,
setOnCancel: () => null, setOnCancel: () => null,
setOnSuccess: () => null, setOnSuccess: () => null,
setSelectableCollections: () => null,
setSuccessfullyUploaded: () => false,
successfullyUploaded: false,
}) })
export function BulkUploadProvider({ export function BulkUploadProvider({
children, children,
@@ -121,20 +207,23 @@ export function BulkUploadProvider({
readonly children: React.ReactNode readonly children: React.ReactNode
readonly drawerSlugPrefix?: string readonly drawerSlugPrefix?: string
}) { }) {
const [selectableCollections, setSelectableCollections] = React.useState<null | string[]>(null)
const [collection, setCollection] = React.useState<string>() const [collection, setCollection] = React.useState<string>()
const [onSuccessFunction, setOnSuccessFunction] = React.useState<BulkUploadContext['onSuccess']>() const [onSuccessFunction, setOnSuccessFunction] = React.useState<BulkUploadContext['onSuccess']>()
const [onCancelFunction, setOnCancelFunction] = React.useState<BulkUploadContext['onCancel']>() const [onCancelFunction, setOnCancelFunction] = React.useState<BulkUploadContext['onCancel']>()
const [initialFiles, setInitialFiles] = React.useState<FileList>(undefined) const [initialFiles, setInitialFiles] = React.useState<FileList>(undefined)
const [initialForms, setInitialForms] = React.useState<InitialForms>(undefined)
const [maxFiles, setMaxFiles] = React.useState<number>(undefined) const [maxFiles, setMaxFiles] = React.useState<number>(undefined)
const drawerSlug = `${drawerSlugPrefix ? `${drawerSlugPrefix}-` : ''}${useBulkUploadDrawerSlug()}` const [successfullyUploaded, setSuccessfullyUploaded] = React.useState<boolean>(false)
const setCollectionSlug: BulkUploadContext['setCollectionSlug'] = (slug) => { const drawerSlug = `${drawerSlugPrefix ? `${drawerSlugPrefix}-` : ''}${useBulkUploadDrawerSlug()}`
setCollection(slug)
}
const setOnSuccess: BulkUploadContext['setOnSuccess'] = (onSuccess) => { const setOnSuccess: BulkUploadContext['setOnSuccess'] = (onSuccess) => {
setOnSuccessFunction(() => onSuccess) setOnSuccessFunction(() => onSuccess)
} }
const setOnCancel: BulkUploadContext['setOnCancel'] = (onCancel) => {
setOnCancelFunction(() => onCancel)
}
return ( return (
<Context <Context
@@ -142,22 +231,28 @@ export function BulkUploadProvider({
collectionSlug: collection, collectionSlug: collection,
drawerSlug, drawerSlug,
initialFiles, initialFiles,
initialForms,
maxFiles, maxFiles,
onCancel: () => { onCancel: () => {
if (typeof onCancelFunction === 'function') { if (typeof onCancelFunction === 'function') {
onCancelFunction() onCancelFunction()
} }
}, },
onSuccess: (docIDs, errorCount) => { onSuccess: (newDocs, errorCount) => {
if (typeof onSuccessFunction === 'function') { if (typeof onSuccessFunction === 'function') {
onSuccessFunction(docIDs, errorCount) onSuccessFunction(newDocs, errorCount)
} }
}, },
setCollectionSlug, selectableCollections,
setCollectionSlug: setCollection,
setInitialFiles, setInitialFiles,
setInitialForms,
setMaxFiles, setMaxFiles,
setOnCancel: setOnCancelFunction, setOnCancel,
setOnSuccess, setOnSuccess,
setSelectableCollections,
setSuccessfullyUploaded,
successfullyUploaded,
}} }}
> >
<React.Fragment> <React.Fragment>

View File

@@ -20,7 +20,7 @@ import React, { useCallback, useEffect, useMemo } from 'react'
import type { ListDrawerProps } from '../../elements/ListDrawer/types.js' import type { ListDrawerProps } from '../../elements/ListDrawer/types.js'
import type { PopulateDocs, ReloadDoc } from './types.js' import type { PopulateDocs, ReloadDoc } from './types.js'
import { useBulkUpload } from '../../elements/BulkUpload/index.js' import { type BulkUploadContext, useBulkUpload } from '../../elements/BulkUpload/index.js'
import { Button } from '../../elements/Button/index.js' import { Button } from '../../elements/Button/index.js'
import { useDocumentDrawer } from '../../elements/DocumentDrawer/index.js' import { useDocumentDrawer } from '../../elements/DocumentDrawer/index.js'
import { Dropzone } from '../../elements/Dropzone/index.js' import { Dropzone } from '../../elements/Dropzone/index.js'
@@ -244,33 +244,33 @@ export function UploadInput(props: UploadInputProps) {
[code, serverURL, api, i18n.language, t, hasMany], [code, serverURL, api, i18n.language, t, hasMany],
) )
const onUploadSuccess = useCallback( const onUploadSuccess: BulkUploadContext['onSuccess'] = useCallback(
(newDocs: JsonObject[]) => { (uploadedForms) => {
if (hasMany) { if (hasMany) {
const mergedValue = [ const mergedValue = [
...(Array.isArray(value) ? value : []), ...(Array.isArray(value) ? value : []),
...newDocs.map((doc) => doc.id), ...uploadedForms.map((form) => form.doc.id),
] ]
onChange(mergedValue) onChange(mergedValue)
setPopulatedDocs((currentDocs) => [ setPopulatedDocs((currentDocs) => [
...(currentDocs || []), ...(currentDocs || []),
...newDocs.map((doc) => ({ ...uploadedForms.map((form) => ({
relationTo: activeRelationTo, relationTo: form.collectionSlug,
value: doc, value: form.doc,
})), })),
]) ])
} else { } else {
const firstDoc = newDocs[0] const firstDoc = uploadedForms[0].doc
onChange(firstDoc.id) onChange(firstDoc.id)
setPopulatedDocs([ setPopulatedDocs([
{ {
relationTo: activeRelationTo, relationTo: firstDoc.collectionSlug,
value: firstDoc, value: firstDoc,
}, },
]) ])
} }
}, },
[value, onChange, activeRelationTo, hasMany], [value, onChange, hasMany],
) )
const onLocalFileSelection = React.useCallback( const onLocalFileSelection = React.useCallback(

View File

@@ -22,7 +22,7 @@ import { OnDemandForm } from './collections/OnDemandForm/index.js'
import { OnDemandOutsideForm } from './collections/OnDemandOutsideForm/index.js' import { OnDemandOutsideForm } from './collections/OnDemandOutsideForm/index.js'
import RichTextFields from './collections/RichText/index.js' import RichTextFields from './collections/RichText/index.js'
import TextFields from './collections/Text/index.js' import TextFields from './collections/Text/index.js'
import Uploads from './collections/Upload/index.js' import { Uploads, Uploads2 } from './collections/Upload/index.js'
import TabsWithRichText from './globals/TabsWithRichText.js' import TabsWithRichText from './globals/TabsWithRichText.js'
import { seed } from './seed.js' import { seed } from './seed.js'
@@ -49,6 +49,7 @@ export const baseConfig: Partial<Config> = {
RichTextFields, RichTextFields,
TextFields, TextFields,
Uploads, Uploads,
Uploads2,
ArrayFields, ArrayFields,
OnDemandForm, OnDemandForm,
OnDemandOutsideForm, OnDemandOutsideForm,
@@ -60,9 +61,15 @@ export const baseConfig: Partial<Config> = {
baseDir: path.resolve(dirname), baseDir: path.resolve(dirname),
}, },
components: { components: {
views: {
custom: {
Component: './components/Image.js#Image',
path: '/custom-image',
},
},
beforeDashboard: [ beforeDashboard: [
{ {
path: './components/CollectionsExplained.tsx#CollectionsExplained', path: './components/CollectionsExplained.js#CollectionsExplained',
}, },
], ],
}, },

View File

@@ -1,6 +1,5 @@
import { expect, test } from '@playwright/test' import { expect, test } from '@playwright/test'
import { AdminUrlUtil } from 'helpers/adminUrlUtil.js' import { AdminUrlUtil } from 'helpers/adminUrlUtil.js'
import { reInitializeDB } from 'helpers/reInitializeDB.js'
import { lexicalHeadingFeatureSlug } from 'lexical/slugs.js' import { lexicalHeadingFeatureSlug } from 'lexical/slugs.js'
import path from 'path' import path from 'path'
import { fileURLToPath } from 'url' import { fileURLToPath } from 'url'

View File

@@ -3,11 +3,11 @@ import type { CollectionConfig } from 'payload'
import path from 'path' import path from 'path'
import { fileURLToPath } from 'url' import { fileURLToPath } from 'url'
import { uploadsSlug } from '../../slugs.js' import { uploads2Slug, uploadsSlug } from '../../slugs.js'
const filename = fileURLToPath(import.meta.url) const filename = fileURLToPath(import.meta.url)
const dirname = path.dirname(filename) const dirname = path.dirname(filename)
const Uploads: CollectionConfig = { export const Uploads: CollectionConfig = {
slug: uploadsSlug, slug: uploadsSlug,
fields: [ fields: [
{ {
@@ -34,4 +34,14 @@ const Uploads: CollectionConfig = {
}, },
} }
export default Uploads export const Uploads2: CollectionConfig = {
...Uploads,
slug: uploads2Slug,
fields: [
...Uploads.fields,
{
name: 'altText',
type: 'text',
},
],
}

View File

@@ -0,0 +1,127 @@
import { expect, type Page, test } from '@playwright/test'
import { lexicalFullyFeaturedSlug } from 'lexical/slugs.js'
import path from 'path'
import { fileURLToPath } from 'url'
import type { PayloadTestSDK } from '../../../../helpers/sdk/index.js'
import type { Config } from '../../../payload-types.js'
import { ensureCompilationIsDone, saveDocAndAssert } from '../../../../helpers.js'
import { AdminUrlUtil } from '../../../../helpers/adminUrlUtil.js'
import { initPayloadE2ENoConfig } from '../../../../helpers/initPayloadE2ENoConfig.js'
import { reInitializeDB } from '../../../../helpers/reInitializeDB.js'
import { TEST_TIMEOUT_LONG } from '../../../../playwright.config.js'
import { LexicalHelpers, type PasteMode } from '../../utils.js'
const filename = fileURLToPath(import.meta.url)
const currentFolder = path.dirname(filename)
const dirname = path.resolve(currentFolder, '../../../')
let payload: PayloadTestSDK<Config>
let serverURL: string
const { beforeAll, beforeEach, describe } = test
// This test suite resets the database before each test to ensure a clean state and cannot be run in parallel.
// Use this for tests that modify the database.
describe('Lexical Fully Featured - database', () => {
let lexical: LexicalHelpers
let url: AdminUrlUtil
beforeAll(async ({ browser }, testInfo) => {
testInfo.setTimeout(TEST_TIMEOUT_LONG)
process.env.SEED_IN_CONFIG_ONINIT = 'false' // Makes it so the payload config onInit seed is not run. Otherwise, the seed would be run unnecessarily twice for the initial test run - once for beforeEach and once for onInit
;({ payload, serverURL } = await initPayloadE2ENoConfig<Config>({ dirname }))
const page = await browser.newPage()
await ensureCompilationIsDone({ page, serverURL })
await page.close()
})
beforeEach(async ({ page }) => {
await reInitializeDB({
serverURL,
snapshotKey: 'lexicalTest',
uploadsDir: [path.resolve(dirname, './collections/Upload/uploads')],
})
url = new AdminUrlUtil(serverURL, lexicalFullyFeaturedSlug)
lexical = new LexicalHelpers(page)
await page.goto(url.create)
await lexical.editor.first().focus()
})
describe('auto upload', () => {
const filePath = path.resolve(dirname, './collections/Upload/payload.jpg')
async function uploadsTest(page: Page, mode: 'cmd+v' | PasteMode, expectedFileName?: string) {
if (mode === 'cmd+v') {
await page.keyboard.press('Meta+V')
await page.keyboard.press('Control+V')
} else {
await lexical.pasteFile({ filePath, mode })
}
await expect(lexical.drawer).toBeVisible()
await lexical.drawer.locator('.bulk-upload--actions-bar').getByText('Save').click()
await expect(lexical.drawer).toBeHidden()
await expect(lexical.editor.locator('.lexical-upload')).toHaveCount(1)
await expect(lexical.editor.locator('.lexical-upload__doc-drawer-toggler')).toHaveText(
expectedFileName || 'payload-1.jpg',
)
const uploadedImage = await payload.find({
collection: 'uploads',
where: { filename: { equals: expectedFileName || 'payload-1.jpg' } },
})
expect(uploadedImage.totalDocs).toBe(1)
}
// eslint-disable-next-line playwright/expect-expect
test('ensure auto upload by copy & pasting image works when pasting a blob', async ({
page,
}) => {
await uploadsTest(page, 'blob')
})
// eslint-disable-next-line playwright/expect-expect
test('ensure auto upload by copy & pasting image works when pasting as html', async ({
page,
}) => {
// blob will be put in src of img tag => cannot infer file name
await uploadsTest(page, 'html', 'pasted-image.jpeg')
})
test('ensure auto upload by copy & pasting image works when pasting from website', async ({
page,
}) => {
await page.goto(url.admin + '/custom-image')
await page.keyboard.press('Meta+A')
await page.keyboard.press('Control+A')
await page.keyboard.press('Meta+C')
await page.keyboard.press('Control+C')
await page.goto(url.create)
await lexical.editor.first().focus()
await expect(lexical.editor).toBeFocused()
await uploadsTest(page, 'cmd+v')
// Save page
await saveDocAndAssert(page)
const lexicalFullyFeatured = await payload.find({
collection: lexicalFullyFeaturedSlug,
limit: 1,
})
const richText = lexicalFullyFeatured?.docs?.[0]?.richText
const headingNode = richText?.root?.children[0]
expect(headingNode).toBeDefined()
expect(headingNode?.children?.[1]?.text).toBe('This is an image:')
const uploadNode = richText?.root?.children?.[1]?.children?.[0]
// @ts-expect-error unsafe access is fine in tests
expect(uploadNode.value?.filename).toBe('payload-1.jpg')
})
})
})

View File

@@ -1,33 +1,37 @@
import { expect, test } from '@playwright/test' import { expect, test } from '@playwright/test'
import { AdminUrlUtil } from 'helpers/adminUrlUtil.js'
import { reInitializeDB } from 'helpers/reInitializeDB.js'
import { lexicalFullyFeaturedSlug } from 'lexical/slugs.js'
import path from 'path' import path from 'path'
import { fileURLToPath } from 'url' import { fileURLToPath } from 'url'
import type { PayloadTestSDK } from '../../../helpers/sdk/index.js'
import type { Config } from '../../payload-types.js'
import { ensureCompilationIsDone } from '../../../helpers.js' import { ensureCompilationIsDone } from '../../../helpers.js'
import { AdminUrlUtil } from '../../../helpers/adminUrlUtil.js'
import { initPayloadE2ENoConfig } from '../../../helpers/initPayloadE2ENoConfig.js' import { initPayloadE2ENoConfig } from '../../../helpers/initPayloadE2ENoConfig.js'
import { lexicalFullyFeaturedSlug } from '../../../lexical/slugs.js'
import { TEST_TIMEOUT_LONG } from '../../../playwright.config.js' import { TEST_TIMEOUT_LONG } from '../../../playwright.config.js'
import { LexicalHelpers } from '../utils.js' import { LexicalHelpers } from '../utils.js'
const filename = fileURLToPath(import.meta.url) const filename = fileURLToPath(import.meta.url)
const currentFolder = path.dirname(filename) const currentFolder = path.dirname(filename)
const dirname = path.resolve(currentFolder, '../../') const dirname = path.resolve(currentFolder, '../../')
let payload: PayloadTestSDK<Config>
let serverURL: string
const { beforeAll, beforeEach, describe } = test const { beforeAll, beforeEach, describe } = test
// Unlike the other suites, this one runs in parallel, as they run on the `lexical-fully-featured/create` URL and are "pure" tests // Unlike the other suites, this one runs in parallel, as they run on the `lexical-fully-featured/create` URL and are "pure" tests
// PLEASE do not reset the database or perform any operations that modify it in this file. // PLEASE do not reset the database or perform any operations that modify it in this file.
test.describe.configure({ mode: 'parallel' }) test.describe.configure({ mode: 'parallel' })
const { serverURL } = await initPayloadE2ENoConfig({
dirname,
})
describe('Lexical Fully Featured', () => { describe('Lexical Fully Featured', () => {
let lexical: LexicalHelpers let lexical: LexicalHelpers
beforeAll(async ({ browser }, testInfo) => { beforeAll(async ({ browser }, testInfo) => {
testInfo.setTimeout(TEST_TIMEOUT_LONG) testInfo.setTimeout(TEST_TIMEOUT_LONG)
process.env.SEED_IN_CONFIG_ONINIT = 'false' // Makes it so the payload config onInit seed is not run. Otherwise, the seed would be run unnecessarily twice for the initial test run - once for beforeEach and once for onInit process.env.SEED_IN_CONFIG_ONINIT = 'false' // Makes it so the payload config onInit seed is not run. Otherwise, the seed would be run unnecessarily twice for the initial test run - once for beforeEach and once for onInit
;({ payload, serverURL } = await initPayloadE2ENoConfig<Config>({ dirname }))
const page = await browser.newPage() const page = await browser.newPage()
await ensureCompilationIsDone({ page, serverURL }) await ensureCompilationIsDone({ page, serverURL })
await page.close() await page.close()

View File

@@ -1,8 +1,35 @@
import type { Locator, Page } from 'playwright' import type { Locator, Page } from 'playwright'
import { expect } from '@playwright/test' import { expect } from '@playwright/test'
import fs from 'fs'
import path from 'path'
import { wait } from 'payload/shared' import { wait } from 'payload/shared'
export type PasteMode = 'blob' | 'html'
function inferMimeFromExt(ext: string): string {
switch (ext.toLowerCase()) {
case '.gif':
return 'image/gif'
case '.jpeg':
case '.jpg':
return 'image/jpeg'
case '.png':
return 'image/png'
case '.svg':
return 'image/svg+xml'
case '.webp':
return 'image/webp'
default:
return 'application/octet-stream'
}
}
async function readAsBase64(filePath: string): Promise<string> {
const buf = await fs.promises.readFile(filePath)
return Buffer.from(buf).toString('base64')
}
export class LexicalHelpers { export class LexicalHelpers {
page: Page page: Page
constructor(page: Page) { constructor(page: Page) {
@@ -89,6 +116,8 @@ export class LexicalHelpers {
} }
async paste(type: 'html' | 'markdown', text: string) { async paste(type: 'html' | 'markdown', text: string) {
await this.page.context().grantPermissions(['clipboard-read', 'clipboard-write'])
await this.page.evaluate( await this.page.evaluate(
async ([text, type]) => { async ([text, type]) => {
const blob = new Blob([text!], { type: type === 'html' ? 'text/html' : 'text/markdown' }) const blob = new Blob([text!], { type: type === 'html' ? 'text/html' : 'text/markdown' })
@@ -100,6 +129,54 @@ export class LexicalHelpers {
await this.page.keyboard.press(`ControlOrMeta+v`) await this.page.keyboard.press(`ControlOrMeta+v`)
} }
async pasteFile({ filePath, mode: modeFromArgs }: { filePath: string; mode?: PasteMode }) {
const mode: PasteMode = modeFromArgs ?? 'blob'
const name = path.basename(filePath)
const mime = inferMimeFromExt(path.extname(name))
// Build payloads per mode
let payload:
| { bytes: number[]; kind: 'blob'; mime: string; name: string }
| { html: string; kind: 'html' } = { html: '', kind: 'html' }
if (mode === 'blob') {
const buf = await fs.promises.readFile(filePath)
payload = { kind: 'blob', bytes: Array.from(buf), name, mime }
} else if (mode === 'html') {
const b64 = await readAsBase64(filePath)
const src = `data:${mime};base64,${b64}`
const html = `<img src="${src}" alt="${name}">`
payload = { kind: 'html', html }
}
await this.page.evaluate((p) => {
const target =
(document.activeElement as HTMLElement | null) ||
document.querySelector('[contenteditable="true"]') ||
document.body
const dt = new DataTransfer()
if (p.kind === 'blob') {
const file = new File([new Uint8Array(p.bytes)], p.name, { type: p.mime })
dt.items.add(file)
} else if (p.kind === 'html') {
dt.setData('text/html', p.html)
}
try {
const evt = new ClipboardEvent('paste', {
clipboardData: dt,
bubbles: true,
cancelable: true,
})
target.dispatchEvent(evt)
} catch {
/* ignore */
}
}, payload)
}
async save(container: 'document' | 'drawer') { async save(container: 'document' | 'drawer') {
if (container === 'drawer') { if (container === 'drawer') {
await this.drawer.getByText('Save').click() await this.drawer.getByText('Save').click()

View File

@@ -0,0 +1,22 @@
import type { AdminViewServerProps } from 'payload'
import React from 'react'
export const Image: React.FC<AdminViewServerProps> = async ({ payload }) => {
const images = await payload.find({
collection: 'uploads',
limit: 1,
})
if (!images?.docs?.length) {
return null
}
return (
<div>
<h2>This is an image:</h2>
{/* eslint-disable-next-line jsx-a11y/alt-text */}
<img src={images?.docs?.[0]?.url as string} />
</div>
)
}

View File

@@ -97,6 +97,7 @@ export interface Config {
'rich-text-fields': RichTextField; 'rich-text-fields': RichTextField;
'text-fields': TextField; 'text-fields': TextField;
uploads: Upload; uploads: Upload;
uploads2: Uploads2;
'array-fields': ArrayField; 'array-fields': ArrayField;
OnDemandForm: OnDemandForm; OnDemandForm: OnDemandForm;
OnDemandOutsideForm: OnDemandOutsideForm; OnDemandOutsideForm: OnDemandOutsideForm;
@@ -121,6 +122,7 @@ export interface Config {
'rich-text-fields': RichTextFieldsSelect<false> | RichTextFieldsSelect<true>; 'rich-text-fields': RichTextFieldsSelect<false> | RichTextFieldsSelect<true>;
'text-fields': TextFieldsSelect<false> | TextFieldsSelect<true>; 'text-fields': TextFieldsSelect<false> | TextFieldsSelect<true>;
uploads: UploadsSelect<false> | UploadsSelect<true>; uploads: UploadsSelect<false> | UploadsSelect<true>;
uploads2: Uploads2Select<false> | Uploads2Select<true>;
'array-fields': ArrayFieldsSelect<false> | ArrayFieldsSelect<true>; 'array-fields': ArrayFieldsSelect<false> | ArrayFieldsSelect<true>;
OnDemandForm: OnDemandFormSelect<false> | OnDemandFormSelect<true>; OnDemandForm: OnDemandFormSelect<false> | OnDemandFormSelect<true>;
OnDemandOutsideForm: OnDemandOutsideFormSelect<false> | OnDemandOutsideFormSelect<true>; OnDemandOutsideForm: OnDemandOutsideFormSelect<false> | OnDemandOutsideFormSelect<true>;
@@ -760,6 +762,27 @@ export interface Upload {
focalX?: number | null; focalX?: number | null;
focalY?: number | null; focalY?: number | null;
} }
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "uploads2".
*/
export interface Uploads2 {
id: string;
text?: string | null;
media?: (string | null) | Upload;
altText?: string | null;
updatedAt: string;
createdAt: string;
url?: string | null;
thumbnailURL?: string | null;
filename?: string | null;
mimeType?: string | null;
filesize?: number | null;
width?: number | null;
height?: number | null;
focalX?: number | null;
focalY?: number | null;
}
/** /**
* This interface was referenced by `Config`'s JSON-Schema * This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "array-fields". * via the `definition` "array-fields".
@@ -996,6 +1019,10 @@ export interface PayloadLockedDocument {
relationTo: 'uploads'; relationTo: 'uploads';
value: number | Upload; value: number | Upload;
} | null) } | null)
| ({
relationTo: 'uploads2';
value: string | Uploads2;
} | null)
| ({ | ({
relationTo: 'array-fields'; relationTo: 'array-fields';
value: number | ArrayField; value: number | ArrayField;
@@ -1288,6 +1315,26 @@ export interface UploadsSelect<T extends boolean = true> {
focalX?: T; focalX?: T;
focalY?: T; focalY?: T;
} }
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "uploads2_select".
*/
export interface Uploads2Select<T extends boolean = true> {
text?: T;
media?: T;
altText?: T;
updatedAt?: T;
createdAt?: T;
url?: T;
thumbnailURL?: T;
filename?: T;
mimeType?: T;
filesize?: T;
width?: T;
height?: T;
focalX?: T;
focalY?: T;
}
/** /**
* This interface was referenced by `Config`'s JSON-Schema * This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "array-fields_select". * via the `definition` "array-fields_select".

View File

@@ -16,6 +16,7 @@ import {
lexicalRelationshipFieldsSlug, lexicalRelationshipFieldsSlug,
richTextFieldsSlug, richTextFieldsSlug,
textFieldsSlug, textFieldsSlug,
uploads2Slug,
uploadsSlug, uploadsSlug,
usersSlug, usersSlug,
} from './slugs.js' } from './slugs.js'
@@ -125,6 +126,14 @@ export const seed = async (_payload: Payload) => {
overrideAccess: true, overrideAccess: true,
}) })
const createdPNGDoc2 = await _payload.create({
collection: uploads2Slug,
data: {},
file: pngFile,
depth: 0,
overrideAccess: true,
})
const createdJPGDoc = await _payload.create({ const createdJPGDoc = await _payload.create({
collection: uploadsSlug, collection: uploadsSlug,
data: { data: {

View File

@@ -15,6 +15,8 @@ export const richTextFieldsSlug = 'rich-text-fields'
// Auxiliary slugs // Auxiliary slugs
export const textFieldsSlug = 'text-fields' export const textFieldsSlug = 'text-fields'
export const uploadsSlug = 'uploads' export const uploadsSlug = 'uploads'
export const uploads2Slug = 'uploads2'
export const arrayFieldsSlug = 'array-fields' export const arrayFieldsSlug = 'array-fields'
export const collectionSlugs = [ export const collectionSlugs = [

View File

@@ -82,17 +82,26 @@ if (!suiteName) {
// Run specific suite // Run specific suite
clearWebpackCache() clearWebpackCache()
const suitePath: string | undefined = path const suiteFolderPath: string | undefined = path
.resolve(dirname, inputSuitePath, 'e2e.spec.ts') .resolve(dirname, inputSuitePath)
.replaceAll('__', '/') .replaceAll('__', '/')
const allSuitesInFolder = await globby(`${suiteFolderPath.replace(/\\/g, '/')}/*e2e.spec.ts`)
const baseTestFolder = inputSuitePath.split('__')[0] const baseTestFolder = inputSuitePath.split('__')[0]
if (!suitePath || !baseTestFolder) { if (!baseTestFolder || !allSuitesInFolder?.length) {
throw new Error(`No test suite found for ${suiteName}`) throw new Error(`No test suite found for ${suiteName}`)
} }
executePlaywright(suitePath, baseTestFolder, false, suiteConfigPath) console.log(`\n\nExecuting all ${allSuitesInFolder.length} E2E tests...\n\n`)
console.log(`${allSuitesInFolder.join('\n')}\n`)
for (const file of allSuitesInFolder) {
clearWebpackCache()
executePlaywright(file, baseTestFolder, false, suiteConfigPath)
}
} }
console.log('\nRESULTS:') console.log('\nRESULTS:')