feat: add drag-and-drop support (#7150)

This commit adds drag-and-drop support, leveraging new additions to the CDP Input domain (Input.setInterceptDrags, Input.dispatchDragEvent, and Input.dragIntercepted).
This commit is contained in:
Dan Park 2021-06-04 03:25:36 -07:00 committed by Mathias Bynens
parent 0295b1c773
commit a91b8aca37
7 changed files with 591 additions and 4 deletions

View File

@ -155,6 +155,7 @@
* [page.goto(url[, options])](#pagegotourl-options)
* [page.hover(selector)](#pagehoverselector)
* [page.isClosed()](#pageisclosed)
* [page.isDragInterceptionEnabled](#pageisdraginterceptionenabled)
* [page.isJavaScriptEnabled()](#pageisjavascriptenabled)
* [page.keyboard](#pagekeyboard)
* [page.mainFrame()](#pagemainframe)
@ -171,6 +172,7 @@
* [page.setCookie(...cookies)](#pagesetcookiecookies)
* [page.setDefaultNavigationTimeout(timeout)](#pagesetdefaultnavigationtimeouttimeout)
* [page.setDefaultTimeout(timeout)](#pagesetdefaulttimeouttimeout)
* [page.setDragInterception(enabled)](#pagesetdraginterceptionenabled)
* [page.setExtraHTTPHeaders(headers)](#pagesetextrahttpheadersheaders)
* [page.setGeolocation(options)](#pagesetgeolocationoptions)
* [page.setJavaScriptEnabled(enabled)](#pagesetjavascriptenabledenabled)
@ -214,6 +216,11 @@
- [class: Mouse](#class-mouse)
* [mouse.click(x, y[, options])](#mouseclickx-y-options)
* [mouse.down([options])](#mousedownoptions)
* [mouse.drag(start, target)](#mousedragstart-target)
* [mouse.dragAndDrop(start, target[, options])](#mousedraganddropstart-target-options)
* [mouse.dragEnter(target, data)](#mousedragentertarget-data)
* [mouse.dragOver(target, data)](#mousedragovertarget-data)
* [mouse.drop(target, data)](#mousedroptarget-data)
* [mouse.move(x, y[, options])](#mousemovex-y-options)
* [mouse.up([options])](#mouseupoptions)
* [mouse.wheel([options])](#mousewheeloptions)
@ -294,8 +301,14 @@
* [elementHandle.boundingBox()](#elementhandleboundingbox)
* [elementHandle.boxModel()](#elementhandleboxmodel)
* [elementHandle.click([options])](#elementhandleclickoptions)
* [elementHandle.clickablePoint()](#elementhandleclickablepoint)
* [elementHandle.contentFrame()](#elementhandlecontentframe)
* [elementHandle.dispose()](#elementhandledispose)
* [elementHandle.drag(target)](#elementhandledragtarget)
* [elementHandle.dragAndDrop(target[, options])](#elementhandledraganddroptarget-options)
* [elementHandle.dragEnter([data])](#elementhandledragenterdata)
* [elementHandle.dragOver([data])](#elementhandledragoverdata)
* [elementHandle.drop([data])](#elementhandledropdata)
* [elementHandle.evaluate(pageFunction[, ...args])](#elementhandleevaluatepagefunction-args)
* [elementHandle.evaluateHandle(pageFunction[, ...args])](#elementhandleevaluatehandlepagefunction-args)
* [elementHandle.executionContext()](#elementhandleexecutioncontext)
@ -1919,6 +1932,12 @@ Shortcut for [page.mainFrame().hover(selector)](#framehoverselector).
Indicates that the page has been closed.
#### page.isDragInterceptionEnabled
- returns: <[boolean]>
Indicates that drag events are being intercepted.
#### page.isJavaScriptEnabled()
- returns: <[boolean]>
@ -2183,6 +2202,13 @@ This setting will change the default maximum time for the following methods and
> **NOTE** [`page.setDefaultNavigationTimeout`](#pagesetdefaultnavigationtimeouttimeout) takes priority over [`page.setDefaultTimeout`](#pagesetdefaulttimeouttimeout)
#### page.setDragInterception(enabled)
- `enabled` <[boolean]>
- returns: <[Promise]>
Enables the Input.drag methods. This provides the capability to cpature drag events emitted on the page, which can then be used to simulate drag-and-drop.
#### page.setExtraHTTPHeaders(headers)
- `headers` <[Object]> An object containing additional HTTP headers to be sent with every request. All header values must be strings.
@ -2958,6 +2984,62 @@ Shortcut for [`mouse.move`](#mousemovex-y-options), [`mouse.down`](#mousedownopt
Dispatches a `mousedown` event.
#### mouse.drag(start, target)
- `start` <[Object]> the position to start dragging from
- `x` <[number]> x coordinate
- `y` <[number]> y coordinate
- `target` <[Object]> the position to drag to
- `x` <[number]> x coordinate
- `y` <[number]> y coordinate
- returns: <[Promise<[DragData]>]>
This method creates and captures a dragevent from a given point.
#### mouse.dragAndDrop(start, target[, options])
- `start` <[Object]>
- `x` <[number]> x coordinate
- `y` <[number]> y coordinate
- `target` <[Object]>
- `x` <[number]> x coordinate
- `y` <[number]> y coordinate
- `options` <[Object]>
- `delay` <[number]> how long to delay before dropping onto the target point
- returns: <[Promise<[DragData]>]>
This method drags from a given start point and drops onto a target point.
#### mouse.dragEnter(target, data)
- `target` <[Object]>
- `x` <[number]> x coordinate
- `y` <[number]> y coordinate
- `data` <[Object]>
- returns: <[Promise]]>
This method triggers a dragenter event from the target point.
#### mouse.dragOver(target, data)
- `target` <[Object]>
- `x` <[number]> x coordinate
- `y` <[number]> y coordinate
- `data` <[Object]>
- returns: <[Promise]]>
This method triggers a dragover event from the target point.
#### mouse.drop(target, data)
- `target` <[Object]>
- `x` <[number]> x coordinate
- `y` <[number]> y coordinate
- `data` <[Object]>
- returns: <[Promise]]>
This method triggers a drop event from the target point.
#### mouse.move(x, y[, options])
- `x` <[number]>
@ -4033,6 +4115,10 @@ This method returns boxes of the element, or `null` if the element is not visibl
This method scrolls element into view if needed, and then uses [page.mouse](#pagemouse) to click in the center of the element.
If the element is detached from DOM, the method throws an error.
#### elementHandle.clickablePoint()
- returns: <[Promise<[Point]>]> Resolves to the x, y point that describes the element's position.
#### elementHandle.contentFrame()
- returns: <[Promise]<?[Frame]>> Resolves to the content frame for element handles referencing iframe nodes, or null otherwise
@ -4043,6 +4129,45 @@ If the element is detached from DOM, the method throws an error.
The `elementHandle.dispose` method stops referencing the element handle.
#### elementHandle.drag(target)
- `target` <[Object]>
- `x` <[number]> x coordinate
- `y` <[number]> y coordinate
- returns: <[Promise<[DragData]>]>
This method creates and captures a drag event from the element.
#### elementHandle.dragAndDrop(target[, options])
- `target` <[ElementHandle]>
- `options` <[Object]>
- `delay` <[number]> how long to delay before dropping onto the target element
- returns: <[Promise]>
This method will drag a given element and drop it onto a target element.
#### elementHandle.dragEnter([data])
- `data` <[Object]> drag data created from `element.drag`
- returns: <[Promise]>
This method will trigger a dragenter event from the given element.
#### elementHandle.dragOver([data])
- `data` <[Object]> drag data created from `element.drag`
- returns: <[Promise]>
This method will trigger a dragover event from the given element.
#### elementHandle.drop([data])
- `data` <[Object]> drag data created from `element.drag`
- returns: <[Promise]>
This method will trigger a drop event from the given element.
#### elementHandle.evaluate(pageFunction[, ...args])
- `pageFunction` <[function]\([Object]\)> Function to be evaluated in browser context

View File

@ -17,6 +17,8 @@
import { assert } from './assert.js';
import { CDPSession } from './Connection.js';
import { keyDefinitions, KeyDefinition, KeyInput } from './USKeyboardLayout.js';
import { Protocol } from 'devtools-protocol';
import { Point } from './JSHandle.js';
type KeyDescription = Required<
Pick<KeyDefinition, 'keyCode' | 'key' | 'text' | 'code' | 'location'>
@ -485,6 +487,98 @@ export class Mouse {
pointerType: 'mouse',
});
}
/**
* Dispatches a `drag` event.
* @param start - starting point for drag
* @param target - point to drag to
* ```
*/
async drag(start: Point, target: Point): Promise<Protocol.Input.DragData> {
const promise = new Promise<Protocol.Input.DragData>((resolve) => {
this._client.once('Input.dragIntercepted', (event) =>
resolve(event.data)
);
});
await this.move(start.x, start.y);
await this.down();
await this.move(target.x, target.y);
return promise;
}
/**
* Dispatches a `dragenter` event.
* @param target - point for emitting `dragenter` event
* ```
*/
async dragEnter(target: Point, data: Protocol.Input.DragData): Promise<void> {
await this._client.send('Input.dispatchDragEvent', {
type: 'dragEnter',
x: target.x,
y: target.y,
modifiers: this._keyboard._modifiers,
data,
});
}
/**
* Dispatches a `dragover` event.
* @param target - point for emitting `dragover` event
* ```
*/
async dragOver(target: Point, data: Protocol.Input.DragData): Promise<void> {
await this._client.send('Input.dispatchDragEvent', {
type: 'dragOver',
x: target.x,
y: target.y,
modifiers: this._keyboard._modifiers,
data,
});
}
/**
* Performs a dragenter, dragover, and drop in sequence.
* @param target - point to drop on
* @param data - drag data containing items and operations mask
* @param options - An object of options. Accepts delay which,
* if specified, is the time to wait between `dragover` and `drop` in milliseconds.
* Defaults to 0.
* ```
*/
async drop(target: Point, data: Protocol.Input.DragData): Promise<void> {
await this._client.send('Input.dispatchDragEvent', {
type: 'drop',
x: target.x,
y: target.y,
modifiers: this._keyboard._modifiers,
data,
});
}
/**
* Performs a drag, dragenter, dragover, and drop in sequence.
* @param target - point to drag from
* @param target - point to drop on
* @param options - An object of options. Accepts delay which,
* if specified, is the time to wait between `dragover` and `drop` in milliseconds.
* Defaults to 0.
* ```
*/
async dragAndDrop(
start: Point,
target: Point,
options: { delay?: number } = {}
): Promise<void> {
const { delay = null } = options;
const data = await this.drag(start, target);
await this.dragEnter(target, data);
await this.dragOver(target, data);
if (delay) {
await new Promise((resolve) => setTimeout(resolve, delay));
}
await this.drop(target, data);
await this.up();
}
}
/**

View File

@ -411,7 +411,7 @@ export class ElementHandle<
if (error) throw new Error(error);
}
private async _clickablePoint(): Promise<{ x: number; y: number }> {
async clickablePoint(): Promise<Point> {
const [result, layoutMetrics] = await Promise.all([
this._client
.send('DOM.getContentQuads', {
@ -482,7 +482,7 @@ export class ElementHandle<
*/
async hover(): Promise<void> {
await this._scrollIntoViewIfNeeded();
const { x, y } = await this._clickablePoint();
const { x, y } = await this.clickablePoint();
await this._page.mouse.move(x, y);
}
@ -493,10 +493,69 @@ export class ElementHandle<
*/
async click(options: ClickOptions = {}): Promise<void> {
await this._scrollIntoViewIfNeeded();
const { x, y } = await this._clickablePoint();
const { x, y } = await this.clickablePoint();
await this._page.mouse.click(x, y, options);
}
/**
* This method creates and captures a dragevent from the element.
*/
async drag(target: Point): Promise<Protocol.Input.DragData> {
assert(
this._page.isDragInterceptionEnabled,
'Drag Interception is not enabled!'
);
await this._scrollIntoViewIfNeeded();
const start = await this.clickablePoint();
return await this._page.mouse.drag(start, target);
}
/**
* This method creates a `dragenter` event on the element.
*/
async dragEnter(
data: Protocol.Input.DragData = { items: [], dragOperationsMask: 1 }
): Promise<void> {
await this._scrollIntoViewIfNeeded();
const target = await this.clickablePoint();
await this._page.mouse.dragEnter(target, data);
}
/**
* This method creates a `dragover` event on the element.
*/
async dragOver(
data: Protocol.Input.DragData = { items: [], dragOperationsMask: 1 }
): Promise<void> {
await this._scrollIntoViewIfNeeded();
const target = await this.clickablePoint();
await this._page.mouse.dragOver(target, data);
}
/**
* This method triggers a drop on the element.
*/
async drop(
data: Protocol.Input.DragData = { items: [], dragOperationsMask: 1 }
): Promise<void> {
await this._scrollIntoViewIfNeeded();
const destination = await this.clickablePoint();
await this._page.mouse.drop(destination, data);
}
/**
* This method triggers a dragenter, dragover, and drop on the element.
*/
async dragAndDrop(
target: ElementHandle,
options?: { delay: number }
): Promise<void> {
await this._scrollIntoViewIfNeeded();
const startPoint = await this.clickablePoint();
const targetPoint = await target.clickablePoint();
await this._page.mouse.dragAndDrop(startPoint, targetPoint, options);
}
/**
* Triggers a `change` and `input` event once all the provided options have been
* selected. If there's no `<select>` element matching `selector`, the method
@ -621,7 +680,7 @@ export class ElementHandle<
*/
async tap(): Promise<void> {
await this._scrollIntoViewIfNeeded();
const { x, y } = await this._clickablePoint();
const { x, y } = await this.clickablePoint();
await this._page.touchscreen.tap(x, y);
}
@ -982,6 +1041,14 @@ export interface PressOptions {
text?: string;
}
/**
* @public
*/
export interface Point {
x: number;
y: number;
}
function computeQuadArea(quad: Array<{ x: number; y: number }>): number {
/* Compute sum of all directed areas of adjacent triangles
https://en.wikipedia.org/wiki/Polygon#Simple_polygons

View File

@ -463,6 +463,7 @@ export class Page extends EventEmitter {
private _fileChooserInterceptors = new Set<Function>();
private _disconnectPromise?: Promise<Error>;
private _userDragInterceptionEnabled = false;
/**
* @internal
@ -748,6 +749,10 @@ export class Page extends EventEmitter {
return this._accessibility;
}
get isDragInterceptionEnabled(): boolean {
return this._userDragInterceptionEnabled;
}
/**
* @returns An array of all frames attached to the page.
*/
@ -799,6 +804,19 @@ export class Page extends EventEmitter {
return this._frameManager.networkManager().setRequestInterception(value);
}
/**
* @param enabled - Whether to enable drag interception.
*
* @remarks
* Activating drag interception enables the {@link Input.drag},
* methods This provides the capability to capture drag events emitted
* on the page, which can then be used to simulate drag-and-drop.
*/
async setDragInterception(enabled: boolean): Promise<void> {
this._userDragInterceptionEnabled = enabled;
return this._client.send('Input.setInterceptDrags', { enabled });
}
/**
* @param enabled - When `true`, enables offline mode for the page.
*/

View File

@ -0,0 +1,46 @@
<!DOCTYPE html>
<html>
<head>
<title>Drag-and-drop test</title>
<style>
#drop {
width: 5em;
height: 5em;
border: 1px solid black;
}
</style>
</head>
<body>
<div id="drag" draggable="true">drag me</div>
<div id="drop"></div>
<script>
window.didDragStart = false;
window.didDragEnter = false;
window.didDragOver = false;
window.didDrop = false;
var drag = document.getElementById('drag');
var drop = document.getElementById('drop');
drag.addEventListener('dragstart', function(e) {
e.dataTransfer.setData('id', e.target.id);
window.didDragStart = true;
});
drop.addEventListener('dragenter', function(e) {
e.preventDefault();
window.didDragEnter = true;
});
drop.addEventListener('dragover', function(e) {
e.preventDefault();
window.didDragOver = true;
});
drop.addEventListener('drop', function(e) {
e.preventDefault();
var id = e.dataTransfer.getData('id');
var el = document.getElementById(id);
if (el) {
e.target.appendChild(el);
window.didDrop = true;
}
});
</script>
</body>
</html>

125
test/drag-and-drop.spec.ts Normal file
View File

@ -0,0 +1,125 @@
/**
* Copyright 2021 Google Inc. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import expect from 'expect';
import {
getTestState,
setupTestPageAndContextHooks,
setupTestBrowserHooks,
describeChromeOnly,
} from './mocha-utils'; // eslint-disable-line import/extensions
describeChromeOnly('Input.drag', function () {
setupTestBrowserHooks();
setupTestPageAndContextHooks();
it('should throw an exception if not enabled before usage', async () => {
const { page, server } = getTestState();
await page.goto(server.PREFIX + '/input/drag-and-drop.html');
const draggable = await page.$('#drag');
try {
await draggable.drag({ x: 1, y: 1 });
} catch (error) {
expect(error.message).toContain('Drag Interception is not enabled!');
}
});
it('should emit a dragIntercepted event when dragged', async () => {
const { page, server } = getTestState();
await page.goto(server.PREFIX + '/input/drag-and-drop.html');
await page.setDragInterception(true);
const draggable = await page.$('#drag');
const data = await draggable.drag({ x: 1, y: 1 });
expect(data.items.length).toBe(1);
expect(await page.evaluate(() => globalThis.didDragStart)).toBe(true);
});
it('should emit a dragEnter', async () => {
const { page, server } = getTestState();
await page.goto(server.PREFIX + '/input/drag-and-drop.html');
await page.setDragInterception(true);
const draggable = await page.$('#drag');
const data = await draggable.drag({ x: 1, y: 1 });
const dropzone = await page.$('#drop');
await dropzone.dragEnter(data);
expect(await page.evaluate(() => globalThis.didDragStart)).toBe(true);
expect(await page.evaluate(() => globalThis.didDragEnter)).toBe(true);
});
it('should emit a dragOver event', async () => {
const { page, server } = getTestState();
await page.goto(server.PREFIX + '/input/drag-and-drop.html');
await page.setDragInterception(true);
const draggable = await page.$('#drag');
const data = await draggable.drag({ x: 1, y: 1 });
const dropzone = await page.$('#drop');
await dropzone.dragEnter(data);
await dropzone.dragOver(data);
expect(await page.evaluate(() => globalThis.didDragStart)).toBe(true);
expect(await page.evaluate(() => globalThis.didDragEnter)).toBe(true);
expect(await page.evaluate(() => globalThis.didDragOver)).toBe(true);
});
it('can be dropped', async () => {
const { page, server } = getTestState();
await page.goto(server.PREFIX + '/input/drag-and-drop.html');
await page.setDragInterception(true);
const draggable = await page.$('#drag');
const dropzone = await page.$('#drop');
const data = await draggable.drag({ x: 1, y: 1 });
await dropzone.dragEnter(data);
await dropzone.dragOver(data);
await dropzone.drop(data);
expect(await page.evaluate(() => globalThis.didDragStart)).toBe(true);
expect(await page.evaluate(() => globalThis.didDragEnter)).toBe(true);
expect(await page.evaluate(() => globalThis.didDragOver)).toBe(true);
expect(await page.evaluate(() => globalThis.didDrop)).toBe(true);
});
it('can be dragged and dropped with a single function', async () => {
const { page, server } = getTestState();
await page.goto(server.PREFIX + '/input/drag-and-drop.html');
await page.setDragInterception(true);
const draggable = await page.$('#drag');
const dropzone = await page.$('#drop');
await draggable.dragAndDrop(dropzone);
expect(await page.evaluate(() => globalThis.didDragStart)).toBe(true);
expect(await page.evaluate(() => globalThis.didDragEnter)).toBe(true);
expect(await page.evaluate(() => globalThis.didDragOver)).toBe(true);
expect(await page.evaluate(() => globalThis.didDrop)).toBe(true);
});
it('can be disabled', async () => {
const { page, server } = getTestState();
await page.goto(server.PREFIX + '/input/drag-and-drop.html');
await page.setDragInterception(true);
const draggable = await page.$('#drag');
await draggable.drag({ x: 1, y: 1 });
await page.setDragInterception(false);
try {
await draggable.drag({ x: 1, y: 1 });
} catch (error) {
expect(error.message).toContain('Drag Interception is not enabled!');
}
});
});

View File

@ -385,6 +385,41 @@ function compareDocumentations(actual, expected) {
expectedName: 'KeyInput',
},
],
[
'Method ElementHandle.drag() target',
{
actualName: 'Object',
expectedName: 'Point',
},
],
[
'Method ElementHandle.dragAndDrop() target',
{
actualName: 'ElementHandle',
expectedName: 'ElementHandle<Element>',
},
],
[
'Method ElementHandle.dragEnter() data',
{
actualName: 'Object',
expectedName: 'DragData',
},
],
[
'Method ElementHandle.dragOver() data',
{
actualName: 'Object',
expectedName: 'DragData',
},
],
[
'Method ElementHandle.drop() data',
{
actualName: 'Object',
expectedName: 'DragData',
},
],
[
'Method Keyboard.down() key',
{
@ -413,6 +448,83 @@ function compareDocumentations(actual, expected) {
expectedName: 'MouseOptions',
},
],
[
'Method Mouse.drag() start',
{
actualName: 'Object',
expectedName: 'Point',
},
],
[
'Method Mouse.drag() target',
{
actualName: 'Object',
expectedName: 'Point',
},
],
[
'Method Mouse.dragAndDrop() start',
{
actualName: 'Object',
expectedName: 'Point',
},
],
[
'Method Mouse.dragAndDrop() target',
{
actualName: 'Object',
expectedName: 'Point',
},
],
[
'Method Mouse.dragAndDrop() target',
{
actualName: 'Object',
expectedName: 'Point',
},
],
[
'Method Mouse.dragEnter() target',
{
actualName: 'Object',
expectedName: 'Point',
},
],
[
'Method Mouse.dragEnter() data',
{
actualName: 'Object',
expectedName: 'DragData',
},
],
[
'Method Mouse.dragOver() target',
{
actualName: 'Object',
expectedName: 'Point',
},
],
[
'Method Mouse.dragOver() data',
{
actualName: 'Object',
expectedName: 'DragData',
},
],
[
'Method Mouse.drop() target',
{
actualName: 'Object',
expectedName: 'Point',
},
],
[
'Method Mouse.drop() data',
{
actualName: 'Object',
expectedName: 'DragData',
},
],
[
'Method Mouse.up() options',
{