fix(chromium): drag and drop works in chromium (#6207)

Waiting for #6203 to percolate to the cdn. But this all works locally.

Fixes #1094
This commit is contained in:
Joel Einbinder 2021-06-07 10:27:34 -07:00 committed by GitHub
parent 42a9e4a0d3
commit 8960584b78
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 281 additions and 28 deletions

View File

@ -0,0 +1,137 @@
/**
* Copyright (c) Microsoft Corporation.
*
* 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 { CRPage } from './crPage';
import * as types from '../types';
import { assert } from '../../utils/utils';
import { Protocol } from './protocol';
import { toModifiersMask } from './crProtocolHelper';
declare global {
interface Window {
__cleanupDrag?: () => Promise<boolean>;
}
}
export class DragManager {
private _crPage: CRPage;
private _dragState: Protocol.Input.DragData | null = null;
private _lastPosition = {x: 0, y: 0};
constructor(page: CRPage) {
this._crPage = page;
}
async cancelDrag() {
if (!this._dragState)
return false;
await this._crPage._mainFrameSession._client.send('Input.dispatchDragEvent', {
type: 'dragCancel',
x: this._lastPosition.x,
y: this._lastPosition.y,
data: {
items: [],
dragOperationsMask: 0xFFFF,
}
});
this._dragState = null;
return true;
}
async interceptDragCausedByMove(x: number, y: number, button: types.MouseButton | 'none', buttons: Set<types.MouseButton>, modifiers: Set<types.KeyboardModifier>, moveCallback: () => Promise<void>): Promise<void> {
this._lastPosition = {x, y};
if (this._dragState) {
await this._crPage._mainFrameSession._client.send('Input.dispatchDragEvent', {
type: 'dragOver',
x,
y,
data: this._dragState,
modifiers: toModifiersMask(modifiers),
});
return;
}
if (button !== 'left')
return moveCallback();
const client = this._crPage._mainFrameSession._client;
let onDragIntercepted: (payload: Protocol.Input.dragInterceptedPayload) => void;
const dragInterceptedPromise = new Promise<Protocol.Input.dragInterceptedPayload>(x => onDragIntercepted = x);
await Promise.all(this._crPage._page.frames().map(async frame => {
await frame.nonStallingEvaluateInExistingContext((function() {
let didStartDrag = Promise.resolve(false);
let dragEvent: Event|null = null;
const dragListener = (event: Event) => dragEvent = event;
const mouseListener = () => {
didStartDrag = new Promise<boolean>(callback => {
window.addEventListener('dragstart', dragListener, {once: true, capture: true});
setTimeout(() => callback(dragEvent ? !dragEvent.defaultPrevented : false), 0);
});
};
window.addEventListener('mousemove', mouseListener, {once: true, capture: true});
window.__cleanupDrag = async () => {
const val = await didStartDrag;
window.removeEventListener('mousemove', mouseListener, {capture: true});
window.removeEventListener('dragstart', dragListener, {capture: true});
return val;
};
}).toString(), true, 'utility').catch(() => {});
}));
client.on('Input.dragIntercepted', onDragIntercepted!);
try {
await client.send('Input.setInterceptDrags', {enabled: true});
} catch {
// If Input.setInterceptDrags is not supported, just do a regular move.
// This can be removed once we stop supporting old Electron.
client.off('Input.dragIntercepted', onDragIntercepted!);
return moveCallback();
}
await moveCallback();
const expectingDrag = (await Promise.all(this._crPage._page.frames().map(async frame => {
return frame.nonStallingEvaluateInExistingContext('window.__cleanupDrag && window.__cleanupDrag()', false, 'utility').catch(() => false);
}))).some(x => x);
this._dragState = expectingDrag ? (await dragInterceptedPromise).data : null;
client.off('Input.dragIntercepted', onDragIntercepted!);
await client.send('Input.setInterceptDrags', {enabled: false});
if (this._dragState) {
await this._crPage._mainFrameSession._client.send('Input.dispatchDragEvent', {
type: 'dragEnter',
x,
y,
data: this._dragState,
modifiers: toModifiersMask(modifiers),
});
}
}
isDragging() {
return !!this._dragState;
}
async drop(x: number, y: number, modifiers: Set<types.KeyboardModifier>) {
assert(this._dragState, 'missing drag state');
await this._crPage._mainFrameSession._client.send('Input.dispatchDragEvent', {
type: 'drop',
x,
y,
data: this._dragState,
modifiers: toModifiersMask(modifiers),
});
this._dragState = null;
}
}

View File

@ -20,24 +20,15 @@ import * as types from '../types';
import { CRSession } from './crConnection';
import { macEditingCommands } from '../macEditingCommands';
import { isString } from '../../utils/utils';
function toModifiersMask(modifiers: Set<types.KeyboardModifier>): number {
let mask = 0;
if (modifiers.has('Alt'))
mask |= 1;
if (modifiers.has('Control'))
mask |= 2;
if (modifiers.has('Meta'))
mask |= 4;
if (modifiers.has('Shift'))
mask |= 8;
return mask;
}
import { DragManager } from './crDragDrop';
import { CRPage } from './crPage';
import { toModifiersMask } from './crProtocolHelper';
export class RawKeyboardImpl implements input.RawKeyboard {
constructor(
private _client: CRSession,
private _isMac: boolean,
private _dragManger: DragManager,
) { }
_commandsForCode(code: string, modifiers: Set<types.KeyboardModifier>) {
@ -60,6 +51,8 @@ export class RawKeyboardImpl implements input.RawKeyboard {
}
async keydown(modifiers: Set<types.KeyboardModifier>, code: string, keyCode: number, keyCodeWithoutLocation: number, key: string, location: number, autoRepeat: boolean, text: string | undefined): Promise<void> {
if (code === 'Escape' && await this._dragManger.cancelDrag())
return;
const commands = this._commandsForCode(code, modifiers);
await this._client.send('Input.dispatchKeyEvent', {
type: text ? 'keyDown' : 'rawKeyDown',
@ -94,22 +87,30 @@ export class RawKeyboardImpl implements input.RawKeyboard {
export class RawMouseImpl implements input.RawMouse {
private _client: CRSession;
private _page: CRPage;
private _dragManager: DragManager;
constructor(client: CRSession) {
constructor(page: CRPage, client: CRSession, dragManager: DragManager) {
this._page = page;
this._client = client;
this._dragManager = dragManager;
}
async move(x: number, y: number, button: types.MouseButton | 'none', buttons: Set<types.MouseButton>, modifiers: Set<types.KeyboardModifier>): Promise<void> {
await this._client.send('Input.dispatchMouseEvent', {
type: 'mouseMoved',
button,
x,
y,
modifiers: toModifiersMask(modifiers)
await this._dragManager.interceptDragCausedByMove(x, y, button, buttons, modifiers, async () => {
await this._client.send('Input.dispatchMouseEvent', {
type: 'mouseMoved',
button,
x,
y,
modifiers: toModifiersMask(modifiers)
});
});
}
async down(x: number, y: number, button: types.MouseButton, buttons: Set<types.MouseButton>, modifiers: Set<types.KeyboardModifier>, clickCount: number): Promise<void> {
if (this._dragManager.isDragging())
return;
await this._client.send('Input.dispatchMouseEvent', {
type: 'mousePressed',
button,
@ -121,6 +122,10 @@ export class RawMouseImpl implements input.RawMouse {
}
async up(x: number, y: number, button: types.MouseButton, buttons: Set<types.MouseButton>, modifiers: Set<types.KeyboardModifier>, clickCount: number): Promise<void> {
if (this._dragManager.isDragging()) {
await this._dragManager.drop(x, y, modifiers);
return;
}
await this._client.send('Input.dispatchMouseEvent', {
type: 'mouseReleased',
button,

View File

@ -39,6 +39,7 @@ import { rewriteErrorMessage } from '../../utils/stackTrace';
import { assert, headersArrayToObject, createGuid, canAccessFile } from '../../utils/utils';
import { VideoRecorder } from './videoRecorder';
import { Progress } from '../progress';
import { DragManager } from './crDragDrop';
const UTILITY_WORLD_NAME = '__playwright_utility_world__';
@ -76,8 +77,9 @@ export class CRPage implements PageDelegate {
this._targetId = targetId;
this._opener = opener;
this._isBackgroundPage = isBackgroundPage;
this.rawKeyboard = new RawKeyboardImpl(client, browserContext._browser._isMac);
this.rawMouse = new RawMouseImpl(client);
const dragManager = new DragManager(this);
this.rawKeyboard = new RawKeyboardImpl(client, browserContext._browser._isMac, dragManager);
this.rawMouse = new RawMouseImpl(this, client, dragManager);
this.rawTouchscreen = new RawTouchscreenImpl(client);
this._pdf = new CRPDF(client);
this._coverage = new CRCoverage(client);

View File

@ -89,3 +89,16 @@ export function exceptionToError(exceptionDetails: Protocol.Runtime.ExceptionDet
err.name = name;
return err;
}
export function toModifiersMask(modifiers: Set<types.KeyboardModifier>): number {
let mask = 0;
if (modifiers.has('Alt'))
mask |= 1;
if (modifiers.has('Control'))
mask |= 2;
if (modifiers.has('Meta'))
mask |= 4;
if (modifiers.has('Shift'))
mask |= 8;
return mask;
}

View File

@ -495,6 +495,26 @@ export class Frame extends SdkObject {
}
}
async nonStallingEvaluateInExistingContext(expression: string, isFunction: boolean|undefined, world: types.World): Promise<any> {
if (this._pendingDocument)
throw new Error('Frame is currently attempting a navigation');
const context = this._contextData.get(world)?.context;
if (!context)
throw new Error('Frame does not yet have the execution context');
let callback = () => {};
const frameInvalidated = new Promise<void>((f, r) => callback = r);
this._nonStallingEvaluations.add(callback);
try {
return await Promise.race([
context.evaluateExpression(expression, isFunction),
frameInvalidated
]);
} finally {
this._nonStallingEvaluations.delete(callback);
}
}
private _recalculateLifecycle() {
const events = new Set<types.LifecycleEvent>(this._firedLifecycleEvents);
for (const child of this._childFrames) {

View File

@ -14,13 +14,13 @@
* limitations under the License.
*/
import type { ElementHandle } from '../../index';
import type { ElementHandle, Route } from '../../index';
import { test as it, expect } from './pageTest';
import { attachFrame } from '../config/utils';
it.describe('Drag and drop', () => {
it.skip(({ isAndroid }) => isAndroid);
it.fixme(({ browserName }) => browserName === 'chromium');
it.skip(({isAndroid}) => isAndroid);
it.skip(({browserName, browserMajorVersion}) => browserName === 'chromium' && browserMajorVersion < 91);
it('should work', async ({page, server}) => {
await page.goto(server.PREFIX + '/drag-n-drop.html');
@ -65,10 +65,10 @@ it.describe('Drag and drop', () => {
browserName === 'firefox' ? 'dragstart' : 'mousemove',
browserName === 'firefox' ? 'mousemove' : 'dragstart',
'dragenter',
'dragover',
browserName !== 'chromium' ? 'dragover' : null,
'dragend',
'mouseup',
]);
].filter(Boolean));
});
it.describe('iframe', () => {
@ -122,7 +122,6 @@ it.describe('Drag and drop', () => {
});
it('should respect the drop effect', async ({page, browserName, platform}) => {
it.fixme(browserName === 'chromium', 'Chromium doesn\'t let users set dropEffect on our fake data transfer');
it.fixme(browserName === 'webkit' && platform !== 'linux', 'WebKit doesn\'t handle the drop effect correctly outside of linux.');
it.fixme(browserName === 'firefox');
@ -174,6 +173,60 @@ it.describe('Drag and drop', () => {
return await page.evaluate('dropped');
}
});
it('should work if the drag is canceled', async ({page, server}) => {
await page.goto(server.PREFIX + '/drag-n-drop.html');
await page.evaluate(() => {
document.body.addEventListener('dragstart', event => {
event.preventDefault();
}, false);
});
await page.hover('#source');
await page.mouse.down();
await page.hover('#target');
await page.mouse.up();
expect(await page.$eval('#target', target => target.contains(document.querySelector('#source')))).toBe(false);
});
it('should work if the drag event is captured but not canceled', async ({page, server}) => {
await page.goto(server.PREFIX + '/drag-n-drop.html');
await page.evaluate(() => {
document.body.addEventListener('dragstart', event => {
event.stopImmediatePropagation();
}, false);
});
await page.hover('#source');
await page.mouse.down();
await page.hover('#target');
await page.mouse.up();
expect(await page.$eval('#target', target => target.contains(document.querySelector('#source')))).toBe(true);
});
it('should be able to drag the mouse in a frame', async ({page, server}) => {
await page.goto(server.PREFIX + '/frames/one-frame.html');
const eventsHandle = await trackEvents(await page.frames()[1].$('html'));
await page.mouse.move(30, 30);
await page.mouse.down();
await page.mouse.move(60, 60);
await page.mouse.up();
expect(await eventsHandle.jsonValue()).toEqual(['mousemove', 'mousedown', 'mousemove', 'mouseup']);
});
it('should work if a frame is stalled', async ({page, server, toImpl}) => {
await page.goto(server.PREFIX + '/drag-n-drop.html');
let madeRequest;
const routePromise = new Promise<Route>(x => madeRequest = x);
await page.route('**/empty.html', async (route, request) => {
madeRequest(route);
});
attachFrame(page, 'frame', server.EMPTY_PAGE).catch(() => {});
const route = await routePromise;
await page.hover('#source');
await page.mouse.down();
await page.hover('#target');
await page.mouse.up();
route.abort();
expect(await page.$eval('#target', target => target.contains(document.querySelector('#source')))).toBe(true); // could not find source in target
})
async function trackEvents(target: ElementHandle) {
const eventsHandle = await target.evaluateHandle(target => {
@ -189,3 +242,26 @@ it.describe('Drag and drop', () => {
return eventsHandle;
}
});
it('should work if not doing a drag', async ({page}) => {
const eventsHandle = await trackEvents(await page.$('html'));
await page.mouse.move(50, 50);
await page.mouse.down();
await page.mouse.move(100, 100);
await page.mouse.up();
expect(await eventsHandle.jsonValue()).toEqual(['mousemove', 'mousedown', 'mousemove', 'mouseup']);
});
async function trackEvents(target: ElementHandle) {
const eventsHandle = await target.evaluateHandle(target => {
const events: string[] = [];
for (const event of [
'mousedown', 'mousemove', 'mouseup',
'dragstart', 'dragend', 'dragover', 'dragenter', 'dragleave', 'dragexit',
'drop'
])
target.addEventListener(event, () => events.push(event), false);
return events;
});
return eventsHandle;
}