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:
parent
bec050c4c4
commit
0b04c7d504
|
|
@ -28,6 +28,7 @@ import { SelectorInfo } from './selectors';
|
||||||
import * as types from './types';
|
import * as types from './types';
|
||||||
|
|
||||||
type SetInputFilesFiles = channels.ElementHandleSetInputFilesParams['files'];
|
type SetInputFilesFiles = channels.ElementHandleSetInputFilesParams['files'];
|
||||||
|
type ActionName = 'click' | 'hover' | 'dblclick' | 'tap' | 'move and up' | 'move and down';
|
||||||
|
|
||||||
export class NonRecoverableDOMError extends Error {
|
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'> {
|
options: types.PointerActionOptions & types.PointerActionWaitOptions & types.NavigatingActionWaitOptions): Promise<'error:notconnected' | 'done'> {
|
||||||
let retry = 0;
|
let retry = 0;
|
||||||
// We progressively wait longer between retries, up to 500ms.
|
// 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';
|
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;
|
const { force = false, position } = options;
|
||||||
if ((options as any).__testHookBeforeStable)
|
if ((options as any).__testHookBeforeStable)
|
||||||
await (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);
|
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.force) {
|
||||||
if ((options as any).__testHookBeforeHitTarget)
|
if ((options as any).__testHookBeforeHitTarget)
|
||||||
await (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';
|
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);
|
await progress.beforeInputAction(this);
|
||||||
|
|
||||||
let hitTargetInterceptionHandle: js.JSHandle<HitTargetInterceptionResult> | undefined;
|
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)
|
if ((options as any).__testHookBeforeHitTarget)
|
||||||
await (options as any).__testHookBeforeHitTarget();
|
await (options as any).__testHookBeforeHitTarget();
|
||||||
|
|
||||||
const actionType = (actionName === 'hover' || actionName === 'tap') ? actionName : 'mouse';
|
if (actionName === 'move and up') {
|
||||||
const handle = await this.evaluateHandleInUtility(([injected, node, { actionType, trial }]) => injected.setupHitTargetInterceptor(node, actionType, trial), { actionType, trial: !!options.trial } as const);
|
// When dropping, the "element that is being dragged" often stays under the cursor,
|
||||||
if (handle === 'error:notconnected')
|
// so hit target check at the moment we receive mousedown does not work -
|
||||||
return handle;
|
// it finds the "element that is being dragged" instead of the
|
||||||
if (!handle._objectId)
|
// "element that we drop onto".
|
||||||
return handle.rawValue() as 'error:notconnected';
|
progress.log(` checking that element receives pointer events at (${point.x},${point.y})`);
|
||||||
hitTargetInterceptionHandle = handle as any;
|
const hitTargetResult = await this._checkHitTargetAt(point);
|
||||||
progress.cleanupWhenAborted(() => {
|
if (hitTargetResult !== 'done')
|
||||||
// Do not await here, just in case the renderer is stuck (e.g. on alert)
|
return hitTargetResult;
|
||||||
// and we won't be able to cleanup.
|
progress.log(` element does receive pointer events`);
|
||||||
hitTargetInterceptionHandle!.evaluate(h => h.stop()).catch(e => {});
|
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 () => {
|
const actionResult = await this._page._frameManager.waitForSignalsCreatedBy(progress, options.noWaitAfter, async () => {
|
||||||
|
|
|
||||||
44
tests/assets/input/drag-n-drop-manual.html
Normal file
44
tests/assets/input/drag-n-drop-manual.html
Normal file
|
|
@ -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 page.click('div.MuiFormControl-root:has-text("Age")');
|
||||||
await expect(page.locator('text=Thirty')).toBeVisible();
|
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');
|
||||||
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue