-
Notifications
You must be signed in to change notification settings - Fork 5.5k
feat(locator): add drop API for files and clipboard-like data #40283
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -922,6 +922,54 @@ Locator of the element to drag to. | |||||
| ### option: Locator.dragTo.steps = %%-input-drag-steps-%% | ||||||
| * since: v1.57 | ||||||
|
|
||||||
| ## async method: Locator.drop | ||||||
| * since: v1.60 | ||||||
|
|
||||||
| Simulate an external drag-and-drop of files or clipboard-like data onto this locator. | ||||||
|
|
||||||
| **Details** | ||||||
|
|
||||||
| Dispatches the native `dragenter`, `dragover`, and `drop` events at the center of the | ||||||
| target element with a synthetic [DataTransfer] carrying the provided files and/or data | ||||||
| entries. Works cross-browser by constructing the [DataTransfer] in the page context. | ||||||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||
|
|
||||||
| If the target element's `dragover` listener does not call `preventDefault()`, the target | ||||||
| is considered to have rejected the drop: Playwright dispatches `dragleave` and this | ||||||
| method throws. | ||||||
|
|
||||||
| **Usage** | ||||||
|
|
||||||
| Drop a file buffer onto an upload area: | ||||||
|
|
||||||
| ```js | ||||||
| await page.locator('#dropzone').drop({ | ||||||
| files: { name: 'note.txt', mimeType: 'text/plain', buffer: Buffer.from('hello') }, | ||||||
| }); | ||||||
| ``` | ||||||
|
|
||||||
| Drop plain text and a URL together: | ||||||
|
|
||||||
| ```js | ||||||
| await page.locator('#dropzone').drop({ | ||||||
| data: { | ||||||
| 'text/plain': 'hello world', | ||||||
| 'text/uri-list': 'https://example.com', | ||||||
| }, | ||||||
| }); | ||||||
| ``` | ||||||
|
|
||||||
| ### param: Locator.drop.payload = %%-drop-payload-%% | ||||||
| * since: v1.60 | ||||||
|
|
||||||
| ### option: Locator.drop.position = %%-input-position-%% | ||||||
| * since: v1.60 | ||||||
|
|
||||||
| ### option: Locator.drop.timeout = %%-input-timeout-%% | ||||||
| * since: v1.60 | ||||||
|
|
||||||
| ### option: Locator.drop.timeout = %%-input-timeout-js-%% | ||||||
| * since: v1.60 | ||||||
|
|
||||||
| ## async method: Locator.elementHandle | ||||||
| * since: v1.14 | ||||||
| * discouraged: Always prefer using [Locator]s and web assertions over [ElementHandle]s because latter are inherently racy. | ||||||
|
|
||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -134,6 +134,18 @@ Defaults to `left`. | |
| - `mimeType` <[string]> File type | ||
| - `buffer` <[Buffer]> File content | ||
|
|
||
| ## drop-payload | ||
| - `payload` <[Object]> | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. add a name alias |
||
| - `files` ?<[path]|[Array]<[path]>|[Object]|[Array]<[Object]>> | ||
| - `name` <[string]> File name | ||
| - `mimeType` <[string]> File type | ||
| - `buffer` <[Buffer]> File content | ||
| - `data` ?<[Object]<[string], [string]>> | ||
|
|
||
| Data to drop onto the target. Provide `files` (file paths or in-memory buffers), `data` | ||
| (a mime-type → string map for clipboard-like content such as `text/plain`, `text/html`, | ||
| `text/uri-list`), or both. | ||
|
|
||
| ## input-down-up-delay | ||
| - `delay` <[float]> | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -15,6 +15,9 @@ | |
| */ | ||
|
|
||
| import fs from 'fs'; | ||
| import path from 'path'; | ||
|
|
||
| import mime from 'mime'; | ||
|
|
||
| import { isUnderTest } from '@utils/debug'; | ||
| import * as js from './javascript'; | ||
|
|
@@ -35,7 +38,7 @@ export type InputFilesItems = { | |
| localDirectory?: string | ||
| }; | ||
|
|
||
| type ActionName = 'click' | 'hover' | 'dblclick' | 'tap' | 'move and up' | 'move and down'; | ||
| type ActionName = 'click' | 'hover' | 'dblclick' | 'tap' | 'move and up' | 'move and down' | 'drop'; | ||
| type PerformActionResult = 'error:notvisible' | 'error:notconnected' | 'error:notinviewport' | 'error:optionsnotfound' | 'error:optionnotenabled' | { missingState: ElementState } | { hitTargetDescription: string } | 'done'; | ||
|
|
||
| export class NonRecoverableDOMError extends Error { | ||
|
|
@@ -657,6 +660,73 @@ export class ElementHandle<T extends Node = Node> extends js.JSHandle<T> { | |
| return assertDone(throwRetargetableDOMError(result)); | ||
| } | ||
|
|
||
| async _drop(progress: Progress, inputFileItems: InputFilesItems, data: { mimeType: string, value: string }[], options: types.PointerActionWaitOptions): Promise<'error:notconnected' | 'done'> { | ||
| const { filePayloads, localPaths } = inputFileItems; | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Why don't we reuse |
||
| let payloads: { name: string, mimeType: string, buffer: string, lastModifiedMs?: number }[]; | ||
| if (localPaths && !filePayloads) { | ||
| // Co-located server/browser: read files into buffers so File objects can be | ||
| // constructed in page context. | ||
| payloads = await Promise.all(localPaths.map(async p => ({ | ||
| name: path.basename(p), | ||
| mimeType: mime.getType(p) || 'application/octet-stream', | ||
| buffer: (await fs.promises.readFile(p)).toString('base64'), | ||
| lastModifiedMs: (await fs.promises.stat(p)).mtimeMs, | ||
| }))); | ||
| } else { | ||
| payloads = (filePayloads ?? []).map(p => ({ | ||
| name: p.name, | ||
| mimeType: p.mimeType || 'application/octet-stream', | ||
| buffer: p.buffer, | ||
| lastModifiedMs: p.lastModifiedMs, | ||
| })); | ||
| } | ||
|
Comment on lines
+666
to
+682
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Can we instead force |
||
| return this._retryPointerAction(progress, 'drop', false /* waitForEnabled */, async (progress, point) => { | ||
| // Firefox strips files from DataTransfer objects that cross the isolated-world | ||
| // boundary into the page's main world. Adopt the element to main context and | ||
| // construct the DataTransfer + dispatch events there. | ||
| const mainContext = await progress.race(this._frame.mainContext()); | ||
| const handle = this._context === mainContext ? this : await progress.race(this._page.delegate.adoptElementHandle(this, mainContext)); | ||
| const disposeHandle = handle !== this; | ||
| try { | ||
| const result = await progress.race(handle.evaluate((node: Node, { payloads, data, point }) => { | ||
| if (!node.isConnected || node.nodeType !== 1 /* ELEMENT_NODE */) | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Can we please extract this large evaluated function into |
||
| return 'error:notconnected' as const; | ||
| const element = node as Element; | ||
| const dt = new DataTransfer(); | ||
| for (const p of payloads) { | ||
| const bytes = Uint8Array.from(atob(p.buffer), c => c.charCodeAt(0)); | ||
| const file = new File([bytes], p.name, { type: p.mimeType, lastModified: p.lastModifiedMs }); | ||
| dt.items.add(file); | ||
| } | ||
| for (const entry of data) | ||
| dt.setData(entry.mimeType, entry.value); | ||
| const makeEvent = (type: string) => new DragEvent(type, { | ||
| bubbles: true, | ||
| cancelable: true, | ||
| composed: true, | ||
| clientX: point.x, | ||
| clientY: point.y, | ||
| dataTransfer: dt, | ||
| }); | ||
| element.dispatchEvent(makeEvent('dragenter')); | ||
| const over = makeEvent('dragover'); | ||
| element.dispatchEvent(over); | ||
| if (!over.defaultPrevented) { | ||
| element.dispatchEvent(makeEvent('dragleave')); | ||
| return 'not-accepted' as const; | ||
| } | ||
| element.dispatchEvent(makeEvent('drop')); | ||
| return 'accepted' as const; | ||
| }, { payloads, data, point })); | ||
| if (result === 'not-accepted') | ||
| throw new NonRecoverableDOMError('Drop target did not accept the drop — its dragover handler did not call preventDefault()'); | ||
| } finally { | ||
| if (disposeHandle) | ||
| handle.dispose(); | ||
| } | ||
| }, { ...options, waitAfter: 'disabled' }); | ||
| } | ||
|
|
||
| async _setInputFiles(progress: Progress, items: InputFilesItems): Promise<'error:notconnected' | 'done'> { | ||
| const { filePayloads, localPaths, localDirectory } = items; | ||
| const multiple = filePayloads && filePayloads.length > 1 || localPaths && localPaths.length > 1; | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
at the centeris wrong whenpositionis passed. I'd recommendinside the target elementinstead.