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
231 lines
6.2 KiB
TypeScript
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')
|
|
}
|
|
}
|