fix(drag&drop): relax layout shift logic when dropping (#11760)

When element that is being dragged stays under the mouse,
it prevents the hit target check on drop from working,
because drop target is overlayed by the dragged element.

To workaround this, we perform a one-time hit target check
before moving for the drop, as we used to.
This commit is contained in:
Dmitry Gozman 2022-01-31 16:21:35 -08:00 коммит произвёл GitHub
Родитель bec050c4c4
Коммит 0b04c7d504
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: 4AEE18F83AFDEB23
3 изменённых файлов: 83 добавлений и 16 удалений

Просмотреть файл

@ -28,6 +28,7 @@ import { SelectorInfo } from './selectors';
import * as types from './types';
type SetInputFilesFiles = channels.ElementHandleSetInputFilesParams['files'];
type ActionName = 'click' | 'hover' | 'dblclick' | 'tap' | 'move and up' | 'move and down';
export class NonRecoverableDOMError extends Error {
}
@ -328,7 +329,7 @@ export class ElementHandle<T extends Node = Node> extends js.JSHandle<T> {
};
}
async _retryPointerAction(progress: Progress, actionName: string, waitForEnabled: boolean, action: (point: types.Point) => Promise<void>,
async _retryPointerAction(progress: Progress, actionName: ActionName, waitForEnabled: boolean, action: (point: types.Point) => Promise<void>,
options: types.PointerActionOptions & types.PointerActionWaitOptions & types.NavigatingActionWaitOptions): Promise<'error:notconnected' | 'done'> {
let retry = 0;
// We progressively wait longer between retries, up to 500ms.
@ -382,7 +383,7 @@ export class ElementHandle<T extends Node = Node> extends js.JSHandle<T> {
return 'done';
}
async _performPointerAction(progress: Progress, actionName: string, waitForEnabled: boolean, action: (point: types.Point) => Promise<void>, forceScrollOptions: ScrollIntoViewOptions | undefined, options: types.PointerActionOptions & types.PointerActionWaitOptions & types.NavigatingActionWaitOptions): Promise<'error:notvisible' | 'error:notconnected' | 'error:notinviewport' | { hitTargetDescription: string } | 'done'> {
async _performPointerAction(progress: Progress, actionName: ActionName, waitForEnabled: boolean, action: (point: types.Point) => Promise<void>, forceScrollOptions: ScrollIntoViewOptions | undefined, options: types.PointerActionOptions & types.PointerActionWaitOptions & types.NavigatingActionWaitOptions): Promise<'error:notvisible' | 'error:notconnected' | 'error:notinviewport' | { hitTargetDescription: string } | 'done'> {
const { force = false, position } = options;
if ((options as any).__testHookBeforeStable)
await (options as any).__testHookBeforeStable();
@ -420,7 +421,7 @@ export class ElementHandle<T extends Node = Node> extends js.JSHandle<T> {
return this._finishPointerActionDetectLayoutShift(progress, actionName, point, options, action);
}
private async _finishPointerAction(progress: Progress, actionName: string, point: types.Point, options: types.PointerActionOptions & types.PointerActionWaitOptions & types.NavigatingActionWaitOptions, action: (point: types.Point) => Promise<void>): Promise<'error:notconnected' | { hitTargetDescription: string } | 'done'> {
private async _finishPointerAction(progress: Progress, actionName: ActionName, point: types.Point, options: types.PointerActionOptions & types.PointerActionWaitOptions & types.NavigatingActionWaitOptions, action: (point: types.Point) => Promise<void>): Promise<'error:notconnected' | { hitTargetDescription: string } | 'done'> {
if (!options.force) {
if ((options as any).__testHookBeforeHitTarget)
await (options as any).__testHookBeforeHitTarget();
@ -458,7 +459,7 @@ export class ElementHandle<T extends Node = Node> extends js.JSHandle<T> {
return 'done';
}
private async _finishPointerActionDetectLayoutShift(progress: Progress, actionName: string, point: types.Point, options: types.PointerActionOptions & types.PointerActionWaitOptions & types.NavigatingActionWaitOptions, action: (point: types.Point) => Promise<void>): Promise<'error:notconnected' | { hitTargetDescription: string } | 'done'> {
private async _finishPointerActionDetectLayoutShift(progress: Progress, actionName: ActionName, point: types.Point, options: types.PointerActionOptions & types.PointerActionWaitOptions & types.NavigatingActionWaitOptions, action: (point: types.Point) => Promise<void>): Promise<'error:notconnected' | { hitTargetDescription: string } | 'done'> {
await progress.beforeInputAction(this);
let hitTargetInterceptionHandle: js.JSHandle<HitTargetInterceptionResult> | undefined;
@ -466,18 +467,34 @@ export class ElementHandle<T extends Node = Node> extends js.JSHandle<T> {
if ((options as any).__testHookBeforeHitTarget)
await (options as any).__testHookBeforeHitTarget();
const actionType = (actionName === 'hover' || actionName === 'tap') ? actionName : 'mouse';
const handle = await this.evaluateHandleInUtility(([injected, node, { actionType, trial }]) => injected.setupHitTargetInterceptor(node, actionType, trial), { actionType, trial: !!options.trial } as const);
if (handle === 'error:notconnected')
return handle;
if (!handle._objectId)
return handle.rawValue() as 'error:notconnected';
hitTargetInterceptionHandle = handle as any;
progress.cleanupWhenAborted(() => {
// Do not await here, just in case the renderer is stuck (e.g. on alert)
// and we won't be able to cleanup.
hitTargetInterceptionHandle!.evaluate(h => h.stop()).catch(e => {});
});
if (actionName === 'move and up') {
// When dropping, the "element that is being dragged" often stays under the cursor,
// so hit target check at the moment we receive mousedown does not work -
// it finds the "element that is being dragged" instead of the
// "element that we drop onto".
progress.log(` checking that element receives pointer events at (${point.x},${point.y})`);
const hitTargetResult = await this._checkHitTargetAt(point);
if (hitTargetResult !== 'done')
return hitTargetResult;
progress.log(` element does receive pointer events`);
if (options.trial) {
progress.log(` trial ${actionName} has finished`);
return 'done';
}
} else {
const actionType = (actionName === 'hover' || actionName === 'tap') ? actionName : 'mouse';
const handle = await this.evaluateHandleInUtility(([injected, node, { actionType, trial }]) => injected.setupHitTargetInterceptor(node, actionType, trial), { actionType, trial: !!options.trial } as const);
if (handle === 'error:notconnected')
return handle;
if (!handle._objectId)
return handle.rawValue() as 'error:notconnected';
hitTargetInterceptionHandle = handle as any;
progress.cleanupWhenAborted(() => {
// Do not await here, just in case the renderer is stuck (e.g. on alert)
// and we won't be able to cleanup.
hitTargetInterceptionHandle!.evaluate(h => h.stop()).catch(e => {});
});
}
}
const actionResult = await this._page._frameManager.waitForSignalsCreatedBy(progress, options.noWaitAfter, async () => {

Просмотреть файл

@ -0,0 +1,44 @@
<style>
div {
position: relative;
user-select: none;
}
#from {
cursor: pointer;
}
</style>
<body>
<div id="container">
<div id="to">
Drop here
</div>
<div id="from">
Drag me
</div>
</div>
</body>
<script>
const from = document.querySelector('#from');
const to = document.querySelector('#to');
let start = null;
from.addEventListener('mousedown', e => {
start = { x: e.clientX, y: e.clientY };
});
document.body.addEventListener('mousemove', e => {
if (start) {
from.style.top = (e.clientY - start.y) + 'px';
from.style.left = (e.clientX - start.x) + 'px';
}
});
document.body.addEventListener('mouseup', e => {
const box = to.getBoundingClientRect();
if (start && box.left < e.clientX && box.right > e.clientX && box.top < e.clientY && box.bottom > e.clientY)
to.textContent = 'Dropped';
start = null;
from.style.top = '0';
from.style.left = '0';
});
</script>

Просмотреть файл

@ -178,3 +178,9 @@ it('should work with mui select', async ({ page, server }) => {
await page.click('div.MuiFormControl-root:has-text("Age")');
await expect(page.locator('text=Thirty')).toBeVisible();
});
it('should work with drag and drop that moves the element under cursor', async ({ page, server }) => {
await page.goto(server.PREFIX + '/input/drag-n-drop-manual.html');
await page.dragAndDrop('#from', '#to');
await expect(page.locator('#to')).toHaveText('Dropped');
});