Files
payloadcms/test/lexical/collections/utils.ts
Alessio Gravili 59414bd8f1 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
2025-09-24 15:04:46 +00:00

231 lines
6.2 KiB
TypeScript

import type { Locator, Page } from 'playwright'
import { expect } from '@playwright/test'
import fs from 'fs'
import path from 'path'
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 {
page: Page
constructor(page: Page) {
this.page = page
}
async addLine(
type: 'check' | 'h1' | 'h2' | 'ordered' | 'paragraph' | 'unordered',
text: string,
indent: number,
startWithEnter = true,
) {
if (startWithEnter) {
await this.page.keyboard.press('Enter')
}
await this.slashCommand(type)
// Outdent 10 times to be sure we are at the beginning of the line
for (let i = 0; i < 10; i++) {
await this.page.keyboard.press('Shift+Tab')
}
const adjustedIndent = ['check', 'ordered', 'unordered'].includes(type) ? indent - 1 : indent
for (let i = 0; i < adjustedIndent; i++) {
await this.page.keyboard.press('Tab')
}
await this.page.keyboard.type(text)
}
async clickFixedToolbarButton({
buttonKey,
dropdownKey,
}: {
buttonKey?: string
dropdownKey?: string
}): Promise<{
dropdownItems?: Locator
}> {
if (dropdownKey) {
await this.fixedToolbar.locator(`[data-dropdown-key="${dropdownKey}"]`).click()
const dropdownItems = this.page.locator(`.toolbar-popup__dropdown-items`)
await expect(dropdownItems).toBeVisible()
if (buttonKey) {
await dropdownItems.locator(`[data-item-key="${buttonKey}"]`).click()
}
return {
dropdownItems,
}
}
if (buttonKey) {
await this.fixedToolbar.locator(`[data-item-key="${buttonKey}"]`).click()
}
return {}
}
async clickInlineToolbarButton({
buttonKey,
dropdownKey,
}: {
buttonKey?: string
dropdownKey?: string
}): Promise<{
dropdownItems?: Locator
}> {
if (dropdownKey) {
await this.inlineToolbar.locator(`[data-dropdown-key="${dropdownKey}"]`).click()
const dropdownItems = this.page.locator(`.toolbar-popup__dropdown-items`)
await expect(dropdownItems).toBeVisible()
if (buttonKey) {
await dropdownItems.locator(`[data-item-key="${buttonKey}"]`).click()
}
return {
dropdownItems,
}
}
if (buttonKey) {
await this.inlineToolbar.locator(`[data-item-key="${buttonKey}"]`).click()
}
return {}
}
async paste(type: 'html' | 'markdown', text: string) {
await this.page.context().grantPermissions(['clipboard-read', 'clipboard-write'])
await this.page.evaluate(
async ([text, type]) => {
const blob = new Blob([text!], { type: type === 'html' ? 'text/html' : 'text/markdown' })
const clipboardItem = new ClipboardItem({ 'text/html': blob })
await navigator.clipboard.write([clipboardItem])
},
[text, type],
)
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') {
if (container === 'drawer') {
await this.drawer.getByText('Save').click()
} else {
throw new Error('Not implemented')
}
await this.page.waitForTimeout(1000)
}
async slashCommand(
// prettier-ignore
command: ('block' | 'check' | 'code' | 'h1' | 'h2' | 'h3' | 'h4' | 'h5' |'h6' | 'inline'
| 'link' | 'ordered' | 'paragraph' | 'quote' | 'relationship' | 'table' | 'unordered'|'upload') | ({} & string),
expectMenuToClose = true,
) {
await this.page.keyboard.press(`/`)
const slashMenuPopover = this.page.locator('#slash-menu .slash-menu-popup')
await expect(slashMenuPopover).toBeVisible()
await this.page.keyboard.type(command)
await wait(200)
await this.page.keyboard.press(`Enter`)
if (expectMenuToClose) {
await expect(slashMenuPopover).toBeHidden()
}
}
get decorator() {
return this.editor.locator('[data-lexical-decorator="true"]')
}
get drawer() {
return this.page.locator('.drawer__content')
}
get editor() {
return this.page.locator('[data-lexical-editor="true"]')
}
get fixedToolbar() {
return this.page.locator('.fixed-toolbar')
}
get inlineToolbar() {
return this.page.locator('.inline-toolbar-popup')
}
get paragraph() {
return this.editor.locator('p')
}
}