mirror of
https://github.com/lexmount/moli.git
synced 2026-11-13 16:10:01 +00:00
feat(cdp): intercept native drags with complete input lifecycle
This commit is contained in:
@@ -151,7 +151,7 @@ or identical scenario counts:
|
||||
| Fetch body streams | Buffered seeking and explicit rejection of continuation after body take | Sequential reads; EOF may require a separate empty read |
|
||||
| WebSocket events and URL blocking | Extra generic request events, payload lengths, and WebSocket URL blocking | Dedicated WebSocket events and payload data; URL blocking excludes WebSockets |
|
||||
| Hosted file URLs | Capability error before navigation/interception | File navigation and interception; missing file returns `ERR_FILE_NOT_FOUND` |
|
||||
| Other capability boundaries | Stack timing privacy, unsupported drag/CPU throttling, PI rename extension | Observed stack timing, successful drag/throttling, rejected PI rename |
|
||||
| Other capability boundaries | Stack timing privacy, unsupported CPU throttling, PI rename extension | Observed stack timing, successful throttling, rejected PI rename |
|
||||
| Response/manifest/idle details | Plaintext TextTrack bodies, document-relative data manifest URLs, same-site idle persistence | Base64 TextTrack bodies, data manifest URL fallbacks, cross-document idle reset |
|
||||
|
||||
The `dom-input` text-editing checks were calibrated on 2026-09-12 against
|
||||
@@ -190,6 +190,17 @@ observed, but it does not replace the executable probe when one can be run. If
|
||||
Chromium cannot be tested, document that limitation explicitly instead of
|
||||
presenting an inferred expectation as verified behavior.
|
||||
|
||||
The native drag scenarios were calibrated on 2026-10-28 against Playwright's
|
||||
Chrome for Testing 145.0.7632.6. The `dom` group checks drag eligibility and
|
||||
movement thresholds, native `effectAllowed: "uninitialized"`, frontend-session
|
||||
isolation, cancellation, protected dragenter/dragover data, client-edited replay
|
||||
payloads, and accepted or rejected drops. Chromium reports all operations as
|
||||
`-1`, while Moli reports the supported mask `19`; the shared assertion compares
|
||||
the copy/link/move bits. Default text drops into writable input, textarea, and
|
||||
contenteditable targets need no author-canceled dragover. They use copy for a
|
||||
draggable element source: `copyMove` allows the drop, while `linkMove` rejects it.
|
||||
The same scenarios run against Moli's current build.
|
||||
|
||||
The `Fetch.getResponseBody` text encoding was calibrated on 2026-10-19 against
|
||||
Debian `/usr/bin/chromium` 145.0.7632.116. A fresh page enabled `Network` and
|
||||
`Fetch` with `requestStage: "Response"`, navigated to a local UTF-8 HTML fixture,
|
||||
|
||||
@@ -15,6 +15,7 @@ from ..config import is_moli_browser
|
||||
from .drop_input import run_drop_input_workflow
|
||||
from ..assertions import SmokeError, assert_equal, wait_until
|
||||
from ..progress import await_with_progress
|
||||
from .drag_interception import run_drag_interception_workflows
|
||||
|
||||
|
||||
async def run_dom_input_group(state: SmokeState) -> None:
|
||||
@@ -237,6 +238,7 @@ async def run_dom_input_group(state: SmokeState) -> None:
|
||||
state.record("auth_style_hydrated_click_redirect")
|
||||
|
||||
await run_locator_input_workflows(state)
|
||||
await run_drag_interception_workflows(state)
|
||||
await run_user_facing_locator_workflows(state)
|
||||
await run_role_selector_state_workflows(state)
|
||||
await run_playwright_expect_matcher_workflows(state)
|
||||
@@ -358,6 +360,7 @@ async def run_locator_input_workflows(state: SmokeState) -> None:
|
||||
<div id="drop">drop</div>
|
||||
`;
|
||||
window.__keys = [];
|
||||
window.__dragEvents = [];
|
||||
const textInput = document.getElementById('text');
|
||||
const hoverTarget = document.getElementById('hover');
|
||||
const hoverChild = document.getElementById('hover-child');
|
||||
@@ -366,10 +369,15 @@ async def run_locator_input_workflows(state: SmokeState) -> None:
|
||||
textInput.addEventListener('keydown', event => window.__keys.push(`${event.key}:${event.shiftKey}:${event.ctrlKey || event.metaKey}`));
|
||||
hoverTarget.addEventListener('mouseenter', () => window.__hovered = true);
|
||||
hoverChild.addEventListener('click', () => window.__hoverChildClicked = true);
|
||||
dragSource.addEventListener('dragstart', event => event.dataTransfer.setData('text/plain', 'dragged-value'));
|
||||
dragSource.addEventListener('dragstart', event => {
|
||||
window.__dragEvents.push('start');
|
||||
event.dataTransfer.setData('text/plain', 'dragged-value');
|
||||
});
|
||||
dragSource.addEventListener('dragend', () => window.__dragEvents.push('end'));
|
||||
dropTarget.addEventListener('dragover', event => event.preventDefault());
|
||||
dropTarget.addEventListener('drop', event => {
|
||||
event.preventDefault();
|
||||
window.__dragEvents.push('drop');
|
||||
dropTarget.textContent = event.dataTransfer.getData('text/plain');
|
||||
});
|
||||
}"""
|
||||
@@ -437,13 +445,13 @@ async def run_locator_input_workflows(state: SmokeState) -> None:
|
||||
)
|
||||
assert_equal(form_state, {"checked": True, "radio": "b", "select": "two"}, "checkbox/radio/select state")
|
||||
|
||||
drag = page.locator("#drag").drag_to(page.locator("#drop"), timeout=1_000)
|
||||
if is_moli_browser():
|
||||
await _expect_drag_interception_boundary(drag, "Moli drag interception boundary")
|
||||
assert_equal(await page.text_content("#drop"), "drop", "unsupported drag/drop does not dispatch")
|
||||
else:
|
||||
await drag
|
||||
assert_equal(await page.text_content("#drop"), "dragged-value", "Chromium drag/drop dispatches")
|
||||
await page.locator("#drag").drag_to(page.locator("#drop"), timeout=1_000)
|
||||
assert_equal(await page.text_content("#drop"), "dragged-value", "drag/drop transfers the native payload")
|
||||
assert_equal(
|
||||
await page.evaluate("() => window.__dragEvents"),
|
||||
["start", "drop", "end"],
|
||||
"drag/drop dispatches dragend on the source after drop",
|
||||
)
|
||||
state.record("locator_input_workflows")
|
||||
|
||||
|
||||
@@ -1809,17 +1817,6 @@ async def _expect_playwright_error(awaitable: object) -> str:
|
||||
raise SmokeError("expected Playwright command to fail")
|
||||
|
||||
|
||||
async def _expect_drag_interception_boundary(awaitable: object, label: str) -> str:
|
||||
error = await _expect_playwright_error(awaitable)
|
||||
explicit_unsupported = "Input.setInterceptDrags" in error and "not supported" in error
|
||||
bounded_drag_interception = (
|
||||
"Timeout 1000ms exceeded" in error and "performing move and up action" in error
|
||||
)
|
||||
if not explicit_unsupported and not bounded_drag_interception:
|
||||
raise SmokeError(f"{label}: expected drag interception boundary, got: {error}")
|
||||
return error
|
||||
|
||||
|
||||
async def run_selector_eval_and_handle_conversion_workflows(state: SmokeState) -> None:
|
||||
# Reduced from Playwright eval-on-selector*.spec.ts,
|
||||
# elementhandle-content-frame.spec.ts, and jshandle-as-element.spec.ts.
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from contextlib import AsyncExitStack
|
||||
|
||||
from ..assertions import assert_equal
|
||||
from ..diagnostics import new_cdp_session
|
||||
from ..helpers import capture_layout
|
||||
from . import SmokeState
|
||||
from .drag_protocol_contracts import run_drag_protocol_contracts
|
||||
|
||||
|
||||
async def run_drag_interception_workflows(state: SmokeState) -> None:
|
||||
page = state.page
|
||||
await page.set_content(
|
||||
"""
|
||||
<style>
|
||||
html,body { margin:0; height:320px }
|
||||
#source,#target,#plain { position:absolute; width:100px; height:80px }
|
||||
#source { left:20px; top:20px; background:#ddd }
|
||||
#target { left:220px; top:20px; background:#eee }
|
||||
#plain { left:20px; top:140px; background:#ccc }
|
||||
</style>
|
||||
<div id=source draggable=true>source</div><div id=target>target</div><div id=plain></div>
|
||||
<script>
|
||||
window.__dragLog = [];
|
||||
window.__acceptDrop = true;
|
||||
for (const type of ['dragstart','dragenter','dragover','dragleave','drop','dragend']) {
|
||||
document.addEventListener(type, event => {
|
||||
if (type === 'dragstart') event.dataTransfer.setData('text/plain','native-payload');
|
||||
if (event.target.id === 'target' && type === 'dragover' && window.__acceptDrop)
|
||||
event.preventDefault();
|
||||
if (type === 'drop') event.preventDefault();
|
||||
window.__dragLog.push({type, target:event.target.id,
|
||||
allowed:event.dataTransfer.effectAllowed, effect:event.dataTransfer.dropEffect,
|
||||
types:Array.from(event.dataTransfer.types), data:event.dataTransfer.getData('text/plain')});
|
||||
});
|
||||
}
|
||||
</script>
|
||||
"""
|
||||
)
|
||||
await capture_layout(page)
|
||||
|
||||
await page.locator("#source").drag_to(page.locator("#target"), timeout=5_000)
|
||||
events = await page.evaluate("() => window.__dragLog")
|
||||
assert_equal([event["type"] for event in events if event["type"] in ("dragstart", "drop", "dragend")],
|
||||
["dragstart", "drop", "dragend"], "Playwright dragTo completes the native source lifecycle")
|
||||
assert_equal(next(event for event in events if event["type"] == "drop")["data"],
|
||||
"native-payload", "Playwright dragTo transfers the native payload")
|
||||
await page.evaluate("() => window.__dragLog = []")
|
||||
state.record("locator_native_drag_to")
|
||||
|
||||
async with AsyncExitStack() as cleanup:
|
||||
first = await new_cdp_session(page.context, page)
|
||||
cleanup.push_async_callback(first.detach)
|
||||
second = await new_cdp_session(page.context, page)
|
||||
cleanup.push_async_callback(second.detach)
|
||||
intercepted: dict[str, list[dict]] = {"first": [], "second": []}
|
||||
ready = {"first": asyncio.Event(), "second": asyncio.Event()}
|
||||
|
||||
def capture(name: str, event: dict) -> None:
|
||||
intercepted[name].append(event)
|
||||
ready[name].set()
|
||||
|
||||
first.on("Input.dragIntercepted", lambda event: capture("first", event))
|
||||
second.on("Input.dragIntercepted", lambda event: capture("second", event))
|
||||
await first.send("Input.setInterceptDrags", {"enabled": True})
|
||||
await second.send("Input.setInterceptDrags", {"enabled": False})
|
||||
|
||||
async def mouse(session, kind: str, x: float, y: float, buttons: int) -> None:
|
||||
await session.send("Input.dispatchMouseEvent", {
|
||||
"type": kind, "x": x, "y": y, "buttons": buttons,
|
||||
"button": "none" if kind == "mouseMoved" and not buttons else "left",
|
||||
})
|
||||
|
||||
async def log() -> list[dict]:
|
||||
return await page.evaluate("() => window.__dragLog")
|
||||
|
||||
async def start(session, name: str) -> dict:
|
||||
ready[name].clear()
|
||||
await page.evaluate("() => window.__dragLog = []")
|
||||
await mouse(session, "mousePressed", 40, 40, 1)
|
||||
await mouse(session, "mouseMoved", 50, 50, 1)
|
||||
await asyncio.wait_for(ready[name].wait(), timeout=5)
|
||||
return intercepted[name][-1]["data"]
|
||||
|
||||
async def replay(session, kind: str, data: dict) -> None:
|
||||
await session.send("Input.dispatchDragEvent", {
|
||||
"type": kind, "x": 250, "y": 40, "data": data,
|
||||
})
|
||||
|
||||
# A plain element cannot start a native drag merely by moving the mouse.
|
||||
await mouse(first, "mousePressed", 40, 160, 1)
|
||||
await mouse(first, "mouseMoved", 90, 160, 1)
|
||||
await mouse(first, "mouseReleased", 90, 160, 0)
|
||||
assert_equal(await log(), [], "ordinary held mouse movement does not start a drag")
|
||||
assert_equal(intercepted["first"], [], "ordinary movement is not intercepted")
|
||||
|
||||
# Movement inside the threshold preserves the candidate for a later move.
|
||||
await mouse(first, "mousePressed", 40, 40, 1)
|
||||
await mouse(first, "mouseMoved", 41, 41, 1)
|
||||
assert_equal(await log(), [], "sub-threshold movement does not start a drag")
|
||||
assert_equal(intercepted["first"], [], "sub-threshold movement is not intercepted")
|
||||
await mouse(first, "mouseMoved", 50, 50, 1)
|
||||
await asyncio.wait_for(ready["first"].wait(), timeout=5)
|
||||
data = intercepted["first"][-1]["data"]
|
||||
# Chromium encodes all operations as -1; Moli uses the supported bits 19.
|
||||
assert_equal(data["dragOperationsMask"] & 19, 19, "native default allows copy/link/move")
|
||||
starts = [event for event in await log() if event["type"] == "dragstart"]
|
||||
assert_equal(len(starts), 1, "threshold crossing starts one native drag")
|
||||
assert_equal(starts[0]["allowed"], "uninitialized", "native effectAllowed starts uninitialized")
|
||||
assert_equal(starts[0]["data"], "native-payload", "dragstart has read/write data access")
|
||||
assert_equal(intercepted["second"], [], "interception event stays with the enabled session")
|
||||
await replay(first, "dragCancel", data)
|
||||
await mouse(first, "mouseReleased", 50, 50, 0)
|
||||
assert_equal(
|
||||
[event["type"] for event in await log() if event["type"] in ("dragstart", "dragend")],
|
||||
["dragstart", "dragend"], "cancel ends the source once",
|
||||
)
|
||||
|
||||
data = await start(first, "first")
|
||||
for kind in ("dragEnter", "dragOver", "drop"):
|
||||
await replay(first, kind, data)
|
||||
events = await log()
|
||||
assert_equal(
|
||||
[event["type"] for event in events if event["type"] in ("dragstart", "drop", "dragend")],
|
||||
["dragstart", "drop", "dragend"], "accepted replay completes the source lifecycle",
|
||||
)
|
||||
for event in events:
|
||||
if event["type"] in ("dragenter", "dragover"):
|
||||
assert_equal(event["data"], "", f"{event['type']} protects the payload")
|
||||
assert_equal(event["types"], ["text/plain"], f"{event['type']} exposes types")
|
||||
assert_equal(next(event for event in events if event["type"] == "drop")["data"],
|
||||
"native-payload", "drop can read the original payload")
|
||||
await mouse(first, "mouseReleased", 250, 40, 0)
|
||||
|
||||
data = await start(first, "first")
|
||||
data = {**data, "items": [{"mimeType": "text/plain", "data": "client-payload"}]}
|
||||
for kind in ("dragEnter", "drop"):
|
||||
await replay(first, kind, data)
|
||||
events = await log()
|
||||
assert_equal(next(event for event in events if event["type"] == "drop")["data"],
|
||||
"client-payload", "replay consumes the client's explicit payload")
|
||||
assert_equal([event["type"] for event in events if event["type"] == "dragend"],
|
||||
["dragend"], "replacing replay data still ends the source once")
|
||||
await mouse(first, "mouseReleased", 250, 40, 0)
|
||||
|
||||
# Drop performs a fresh dragover; a target that refuses it must see no drop.
|
||||
await page.evaluate("() => window.__acceptDrop = false")
|
||||
data = await start(first, "first")
|
||||
await replay(first, "dragEnter", data)
|
||||
await replay(first, "drop", data)
|
||||
events = await log()
|
||||
assert_equal([event for event in events if event["type"] == "drop"], [],
|
||||
"a refusing target receives no drop")
|
||||
assert_equal([event["effect"] for event in events if event["type"] == "dragend"],
|
||||
["none"], "a rejected drop ends with no operation")
|
||||
await mouse(first, "mouseReleased", 250, 40, 0)
|
||||
|
||||
# Disable one session without overriding the other session's configuration.
|
||||
await second.send("Input.setInterceptDrags", {"enabled": True})
|
||||
await first.send("Input.setInterceptDrags", {"enabled": False})
|
||||
first_count = len(intercepted["first"])
|
||||
data = await start(second, "second")
|
||||
assert_equal(len(intercepted["first"]), first_count, "other session receives no interception event")
|
||||
await replay(second, "dragCancel", data)
|
||||
await mouse(second, "mouseReleased", 50, 50, 0)
|
||||
await second.send("Input.setInterceptDrags", {"enabled": False})
|
||||
state.record("drag_interception_lifecycle_and_session_isolation")
|
||||
await run_native_text_drop_workflows(state)
|
||||
await run_drag_protocol_contracts(state)
|
||||
|
||||
|
||||
async def run_native_text_drop_workflows(state: SmokeState) -> None:
|
||||
page = state.page
|
||||
cases = [
|
||||
("<input id=target>", "payload", "uninitialized"),
|
||||
("<textarea id=target></textarea>", "payload", "uninitialized"),
|
||||
("<div id=target contenteditable=true></div>", "payload", "uninitialized"),
|
||||
("<input id=target readonly>", "", "uninitialized"),
|
||||
("<textarea id=target readonly></textarea>", "", "uninitialized"),
|
||||
("<input id=target disabled>", "", "uninitialized"),
|
||||
("<div id=target></div>", "", "uninitialized"),
|
||||
]
|
||||
for markup in ("<input id=target>", "<textarea id=target></textarea>",
|
||||
"<div id=target contenteditable=true></div>"):
|
||||
cases.extend([(markup, "payload", "copyMove"), (markup, "", "linkMove")])
|
||||
for markup, expected, allowed in cases:
|
||||
label = f"{markup}, effectAllowed={allowed}"
|
||||
await page.set_content(
|
||||
"""<style>
|
||||
#source,#target{position:absolute;width:100px;height:80px}
|
||||
#source{left:20px;top:20px}#target{left:220px;top:20px}
|
||||
</style><div id=source draggable=true>source</div>""" + markup + """<script>
|
||||
window.__defaultDropLog=[];
|
||||
document.getElementById('source').addEventListener('dragstart', event => {
|
||||
event.dataTransfer.setData('text/plain','payload');
|
||||
event.dataTransfer.effectAllowed='""" + allowed + """';
|
||||
});
|
||||
for(const type of ['dragover','drop','dragend']) document.addEventListener(type, event =>
|
||||
window.__defaultDropLog.push({type, canceled:event.defaultPrevented,
|
||||
effect:event.dataTransfer.dropEffect, data:event.dataTransfer.getData('text/plain')}));
|
||||
</script>"""
|
||||
)
|
||||
await capture_layout(page)
|
||||
await page.locator("#source").drag_to(page.locator("#target"), timeout=5_000)
|
||||
result = await page.evaluate("""() => {
|
||||
const target=document.getElementById('target');
|
||||
return {events:window.__defaultDropLog, value:target.value ?? target.textContent};
|
||||
}""")
|
||||
assert_equal(result["value"], expected, f"default drop into {label}")
|
||||
drops = [event for event in result["events"] if event["type"] == "drop"]
|
||||
assert_equal(len(drops), int(bool(expected)), f"default acceptance for {label}")
|
||||
assert_equal([event["effect"] for event in result["events"] if event["type"] == "dragend"],
|
||||
["copy" if expected else "none"], f"source finishes with the default operation for {label}")
|
||||
for event in result["events"]:
|
||||
if event["type"] == "dragover":
|
||||
assert_equal(event["canceled"], False, f"author does not accept dragover for {label}")
|
||||
assert_equal(event["data"], "", f"dragover protects default drop data for {label}")
|
||||
state.record("native_text_drop_default_acceptance")
|
||||
@@ -0,0 +1,335 @@
|
||||
"""Drag payload, event-view, and pointer contracts checked against Chromium."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from contextlib import AsyncExitStack, asynccontextmanager
|
||||
from pathlib import Path
|
||||
from tempfile import TemporaryDirectory
|
||||
|
||||
from ..assertions import assert_equal
|
||||
from ..diagnostics import new_cdp_session
|
||||
from ..helpers import capture_layout
|
||||
from . import SmokeState
|
||||
|
||||
|
||||
_BOARD = """<!doctype html><style>
|
||||
html,body{margin:0;height:320px}
|
||||
#source,#a,#b{position:absolute;width:100px;height:80px}
|
||||
#source{left:20px;top:20px}#a{left:220px;top:20px}#b{left:420px;top:20px}
|
||||
</style><div id=source draggable=true>source</div><div id=a>A</div><div id=b>B</div>"""
|
||||
|
||||
_INSTALL = """options => {
|
||||
const contract = window.__dragContract = {events:[], views:[], stringCallbacks:[]};
|
||||
const identities = new WeakMap();
|
||||
let nextIdentity = 1;
|
||||
const identity = value => {
|
||||
if (!identities.has(value)) identities.set(value, nextIdentity++);
|
||||
return identities.get(value);
|
||||
};
|
||||
const node = value => value ? (value.id || value.nodeName) : null;
|
||||
const oldView = view => ({
|
||||
type:view.type, data:view.dt.getData('text/plain'), types:Array.from(view.dt.types),
|
||||
itemCount:view.items.length, fileNames:Array.from(view.files,file=>file.name),
|
||||
savedTypes:Array.from(view.types), kind:view.item?.kind ?? null,
|
||||
fileKind:view.fileItem?.kind ?? null, file:view.fileItem?.getAsFile()?.name ?? null,
|
||||
});
|
||||
contract.sourceState = () => {
|
||||
const view=contract.sourceView;
|
||||
if(!view) return null;
|
||||
return {data:view.dt.getData('text/plain'),nativeData:view.dt.getData('application/x-native'),
|
||||
types:Array.from(view.dt.types),items:Array.from(view.items,item=>({kind:item.kind,type:item.type})),
|
||||
files:Array.from(view.dt.files,file=>file.name),savedTypes:Array.from(view.types),
|
||||
savedItem:{kind:view.item.kind,type:view.item.type},
|
||||
savedFile:view.fileItem ? {kind:view.fileItem.kind,type:view.fileItem.type,
|
||||
name:view.fileItem.getAsFile()?.name ?? null} : null};
|
||||
};
|
||||
for (const type of ['dragstart','drag','dragenter','dragover','dragleave','drop','dragend']) {
|
||||
document.addEventListener(type, event => {
|
||||
const dt=event.dataTransfer;
|
||||
if(type==='dragstart') {
|
||||
dt.setData('text/plain','native-secret');
|
||||
dt.setData('application/x-native','native-only');
|
||||
if(options.sourceFile) dt.items.add(new File(['native-file-payload'],'source-file.txt',{type:'text/plain'}));
|
||||
dt.effectAllowed='copyMove';
|
||||
}
|
||||
if(type==='dragover' && ['a','b'].includes(event.target.id)) event.preventDefault();
|
||||
if(type==='drop') event.preventDefault();
|
||||
const view={type,dt,items:dt.items,types:dt.types,files:dt.files,item:dt.items[0],
|
||||
fileItem:Array.from(dt.items).find(item=>item.kind==='file')};
|
||||
if(type==='dragstart') contract.sourceView=view;
|
||||
const record={type,target:node(event.target),relatedTarget:node(event.relatedTarget),
|
||||
cancelable:event.cancelable,dt:identity(dt),data:dt.getData('text/plain'),
|
||||
types:Array.from(dt.types),allowed:dt.effectAllowed,effect:dt.dropEffect};
|
||||
if(contract.sourceView) record.source=contract.sourceState();
|
||||
if(type==='drop') {
|
||||
record.oldViews=contract.views.filter(view=>['dragenter','dragover'].includes(view.type)).map(oldView);
|
||||
contract.dropFiles=Array.from(dt.files);
|
||||
contract.dropView=view;
|
||||
if(options.strings) {
|
||||
for(const previous of contract.views) if(previous.item) previous.item.getAsString(data =>
|
||||
contract.stringCallbacks.push({type:'old:'+previous.type,data}));
|
||||
contract.dropString = new Promise(resolve => {
|
||||
view.item.getAsString(data => resolve({data,kind:view.item.kind,type:view.item.type}));
|
||||
});
|
||||
}
|
||||
}
|
||||
if(options.strings && type!=='drop' && view.item) view.item.getAsString(data =>
|
||||
contract.stringCallbacks.push({type,data}));
|
||||
contract.views.push(view);
|
||||
contract.events.push(record);
|
||||
});
|
||||
}
|
||||
for (const type of ['pointerdown','pointermove','pointerup','pointercancel',
|
||||
'gotpointercapture','lostpointercapture','mousedown','mousemove','mouseup']) {
|
||||
document.addEventListener(type,event => {
|
||||
contract.events.push({type,target:node(event.target),buttons:event.buttons,cancelable:event.cancelable});
|
||||
if(options.capture && type==='pointerdown' && event.target.id==='source')
|
||||
event.target.setPointerCapture(event.pointerId);
|
||||
});
|
||||
}
|
||||
}"""
|
||||
|
||||
|
||||
def _data(value: str | None, mask: int = 1, mime: str = "text/plain") -> dict:
|
||||
return {
|
||||
"items": [] if value is None else [{"mimeType": mime, "data": value}],
|
||||
"dragOperationsMask": mask,
|
||||
}
|
||||
|
||||
|
||||
async def _mouse(session, kind: str, x: int, y: int, buttons: int) -> None:
|
||||
await session.send("Input.dispatchMouseEvent", {
|
||||
"type": kind, "x": x, "y": y, "buttons": buttons,
|
||||
"button": "none" if kind == "mouseMoved" and not buttons else "left",
|
||||
})
|
||||
|
||||
|
||||
async def _drag(session, kind: str, data: dict, x: int = 250) -> None:
|
||||
await session.send("Input.dispatchDragEvent", {"type": kind, "x": x, "y": 40, "data": data})
|
||||
|
||||
|
||||
async def _events(page) -> list[dict]:
|
||||
return await page.evaluate("() => window.__dragContract.events")
|
||||
|
||||
|
||||
async def _drag_step(page, session, kind: str, data: dict, x: int = 250) -> list[dict]:
|
||||
start = len(await _events(page))
|
||||
await _drag(session, kind, data, x)
|
||||
return [event for event in (await _events(page))[start:]
|
||||
if event["type"] in ("dragenter", "dragover", "dragleave", "drop")]
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def _board(state: SmokeState, *, native: bool = True, capture: bool = False,
|
||||
strings: bool = False, source_file: bool = False, page=None):
|
||||
page = state.page if page is None else page
|
||||
await page.set_content(_BOARD)
|
||||
await page.evaluate(_INSTALL, {"capture": capture, "strings": strings, "sourceFile": source_file})
|
||||
await capture_layout(page)
|
||||
session = await new_cdp_session(page.context, page)
|
||||
intercepted = []
|
||||
ready = asyncio.Event()
|
||||
|
||||
def on_intercepted(event: dict) -> None:
|
||||
intercepted.append(event)
|
||||
ready.set()
|
||||
|
||||
session.on("Input.dragIntercepted", on_intercepted)
|
||||
try:
|
||||
await session.send("Input.setInterceptDrags", {"enabled": native})
|
||||
if native:
|
||||
await _mouse(session, "mouseMoved", 40, 40, 0)
|
||||
await _mouse(session, "mousePressed", 40, 40, 1)
|
||||
await _mouse(session, "mouseMoved", 55, 50, 1)
|
||||
await asyncio.wait_for(ready.wait(), timeout=5)
|
||||
yield session, intercepted
|
||||
finally:
|
||||
# dragCancel really ends intercepted native drags in Chromium. Its
|
||||
# cancelDragging command can leave that particular source pending.
|
||||
await _drag(session, "dragCancel", _data(None))
|
||||
await _mouse(session, "mouseReleased", 250, 40, 0)
|
||||
await session.send("Input.setInterceptDrags", {"enabled": False})
|
||||
await session.detach()
|
||||
|
||||
|
||||
async def _payload_contracts(state: SmokeState) -> None:
|
||||
page = state.page
|
||||
async with _board(state) as (session, intercepted):
|
||||
assert_equal(intercepted[0]["data"]["items"][0]["data"], "native-secret", "source interception payload")
|
||||
enter = await _drag_step(page, session, "dragEnter", _data(None))
|
||||
assert_equal([(event["type"], event["types"]) for event in enter],
|
||||
[("dragenter", [])], "an explicitly empty enter replaces the native secret")
|
||||
drop = await _drag_step(page, session, "drop", _data("drop-replacement"))
|
||||
assert_equal([(event["type"], event["types"], event["data"]) for event in drop],
|
||||
[("dragover", [], ""), ("drop", ["text/plain"], "drop-replacement")],
|
||||
"drop pre-over keeps enter data; drop consumes its own payload")
|
||||
|
||||
async with _board(state) as (session, _):
|
||||
await _drag_step(page, session, "dragEnter", _data("enter-secret"))
|
||||
over = await _drag_step(page, session, "dragOver", _data("ignored-over", 16, "application/x-over"))
|
||||
assert_equal([(event["type"], event["types"], event["allowed"]) for event in over],
|
||||
[("dragover", ["text/plain"], "move")], "over updates mask without replacing enter items")
|
||||
drop = await _drag_step(page, session, "drop", _data("drop-replacement", 1))
|
||||
assert_equal([(event["type"], event["types"], event["allowed"], event["data"]) for event in drop],
|
||||
[("dragover", ["text/plain"], "copy", ""),
|
||||
("drop", ["text/plain"], "copy", "drop-replacement")],
|
||||
"drop updates the pre-over mask and replaces data only for drop")
|
||||
|
||||
async with _board(state) as (session, _):
|
||||
await _drag_step(page, session, "dragEnter", _data("enter-secret"))
|
||||
drop = await _drag_step(page, session, "drop", _data(None))
|
||||
assert_equal([(event["type"], event["types"], event["data"]) for event in drop],
|
||||
[("dragover", ["text/plain"], ""), ("drop", [], "")],
|
||||
"an explicitly empty drop cannot reuse enter payload")
|
||||
state.record("drag_command_payload_and_operation_replacement")
|
||||
|
||||
|
||||
async def _view_contracts(state: SmokeState) -> None:
|
||||
page = state.page
|
||||
source_types = ["text/plain", "application/x-native", "Files"]
|
||||
protected_source = {
|
||||
"data": "", "nativeData": "", "types": source_types,
|
||||
"items": [{"kind": "string", "type": "text/plain"},
|
||||
{"kind": "string", "type": "application/x-native"},
|
||||
{"kind": "file", "type": "text/plain"}],
|
||||
"files": [], "savedTypes": source_types,
|
||||
"savedItem": {"kind": "string", "type": "text/plain"},
|
||||
"savedFile": {"kind": "file", "type": "text/plain", "name": None},
|
||||
}
|
||||
async with _board(state, source_file=True) as (session, _):
|
||||
assert_equal(await page.evaluate("() => window.__dragContract.sourceState()"), protected_source,
|
||||
"the retained source view exposes metadata but protects data and files after dragstart")
|
||||
packet = _data("target-data", 17)
|
||||
for kind in ("dragEnter", "dragOver", "drop"):
|
||||
await _drag_step(page, session, kind, packet)
|
||||
events = await _events(page)
|
||||
start = next(event for event in events if event["type"] == "dragstart")
|
||||
end = next(event for event in events if event["type"] == "dragend")
|
||||
target = [event for event in events if event["type"] in ("dragenter", "dragover", "drop")]
|
||||
assert_equal(start["dt"], end["dt"], "source dragend retains the source DataTransfer identity")
|
||||
assert_equal(end["effect"], "move", "accepted copyMove without explicit dropEffect prefers move")
|
||||
assert_equal(start["source"], {**protected_source, "data": "native-secret", "nativeData": "native-only",
|
||||
"files": ["source-file.txt"],
|
||||
"savedFile": {"kind": "file", "type": "text/plain", "name": "source-file.txt"}},
|
||||
"the source is writable and its File is readable inside dragstart")
|
||||
assert_equal(len({event["dt"] for event in target}), len(target),
|
||||
"distinct target updates use distinct DataTransfer views")
|
||||
assert_equal(start["dt"] in {event["dt"] for event in target}, False,
|
||||
"target views do not borrow the source's writable object")
|
||||
for event in events:
|
||||
if "source" in event and event["type"] != "dragstart":
|
||||
assert_equal(event["source"], protected_source,
|
||||
f"retained source metadata survives {event['type']} without exposing native data")
|
||||
if event["type"] in ("dragstart", "drag", "dragend"):
|
||||
assert_equal(event["cancelable"], event["type"] != "dragend",
|
||||
f"source {event['type']} cancellation contract")
|
||||
assert_equal(await page.evaluate("() => window.__dragContract.sourceState()"),
|
||||
{"data": "", "nativeData": "", "types": [], "items": [], "files": [],
|
||||
"savedTypes": source_types, "savedItem": {"kind": "", "type": ""},
|
||||
"savedFile": {"kind": "", "type": "", "name": None}},
|
||||
"the source becomes disabled only after dragend dispatch finishes")
|
||||
|
||||
with TemporaryDirectory(prefix="moli-drag-views-") as directory:
|
||||
file = Path(directory) / "payload.txt"
|
||||
file.write_text("retained-file-payload", encoding="utf-8")
|
||||
async with _board(state, native=False, strings=True) as (session, _):
|
||||
packet = {**_data("enter-data"), "files": [str(file)]}
|
||||
for kind in ("dragEnter", "dragOver"):
|
||||
await _drag_step(page, session, kind, packet)
|
||||
await _drag_step(page, session, "drop", {**packet, "items": _data("drop-data")["items"]})
|
||||
callback = await page.evaluate("async () => await window.__dragContract.dropString")
|
||||
assert_equal(callback, {"data": "drop-data", "kind": "", "type": ""},
|
||||
"queued getAsString reads its captured value after the item becomes disabled")
|
||||
drop = next(event for event in await _events(page) if event["type"] == "drop")
|
||||
for view in drop["oldViews"]:
|
||||
assert_equal({key: view[key] for key in ("data", "types", "itemCount", "fileNames", "kind", "fileKind", "file")},
|
||||
{"data": "", "types": [], "itemCount": 0, "fileNames": [],
|
||||
"kind": "", "fileKind": "", "file": None},
|
||||
"saved protected target views cannot regain access during drop")
|
||||
assert_equal(view["savedTypes"], ["text/plain", "Files"], "saved types array is a snapshot")
|
||||
result = await page.evaluate("""async () => {
|
||||
const contract=window.__dragContract;
|
||||
return {protectedCallbacks:contract.stringCallbacks,
|
||||
capturedFiles:await Promise.all(contract.dropFiles.map(async file=>({name:file.name,data:await file.text()}))),
|
||||
data:contract.dropView.dt.getData('text/plain'),types:Array.from(contract.dropView.dt.types),
|
||||
items:contract.dropView.dt.items.length,files:contract.dropView.dt.files.length};
|
||||
}""")
|
||||
assert_equal(result, {"protectedCallbacks": [],
|
||||
"capturedFiles": [{"name": "payload.txt", "data": "retained-file-payload"}],
|
||||
"data": "", "types": [], "items": 0, "files": 0},
|
||||
"completed stores stay disabled while captured File objects remain readable")
|
||||
state.record("drag_data_transfer_view_access_and_async_callbacks")
|
||||
|
||||
|
||||
async def _target_contracts(state: SmokeState) -> None:
|
||||
page = state.page
|
||||
async with _board(state, native=False) as (session, _):
|
||||
packet = _data("target-data", 17)
|
||||
steps = []
|
||||
for kind, x in (("dragEnter", 250), ("dragOver", 250), ("dragOver", 450),
|
||||
("dragOver", 250), ("drop", 250)):
|
||||
steps.append(await _drag_step(page, session, kind, packet, x))
|
||||
expected = [
|
||||
[("dragenter", "a", None)], [("dragover", "a", None)],
|
||||
[("dragenter", "b", "a"), ("dragleave", "a", "b")],
|
||||
[("dragenter", "a", "b"), ("dragleave", "b", "a")],
|
||||
[("dragover", "a", None), ("drop", "a", None)],
|
||||
]
|
||||
assert_equal([[(event["type"], event["target"], event["relatedTarget"]) for event in step] for step in steps],
|
||||
expected, "target changes enter new before leaving old, without an extra over")
|
||||
for step in steps[2:4]:
|
||||
assert_equal(step[0]["dt"], step[1]["dt"], "one target transition shares one event view")
|
||||
events = [event for step in steps for event in step]
|
||||
for event in events:
|
||||
assert_equal(event["cancelable"], event["type"] != "dragleave", f"{event['type']} cancellation contract")
|
||||
assert_equal(event["effect"], "none", "plain target event views start with no explicit dropEffect")
|
||||
state.record("drag_target_transition_order_related_target_and_views")
|
||||
|
||||
|
||||
async def _pointer_contracts(state: SmokeState) -> None:
|
||||
async with AsyncExitStack() as cleanup:
|
||||
# Give pointer capture its own Page, independent of the earlier external
|
||||
# drag cases and the pointer streams those commands created.
|
||||
page = await state.page.context.new_page()
|
||||
cleanup.push_async_callback(page.close)
|
||||
async with _board(state, capture=True, page=page) as (session, intercepted):
|
||||
await page.wait_for_function(
|
||||
"() => window.__dragContract.events.some(event => event.type === 'lostpointercapture')",
|
||||
timeout=5_000,
|
||||
)
|
||||
initial = await _events(page)
|
||||
start = next(index for index, event in enumerate(initial) if event["type"] == "dragstart")
|
||||
assert_equal([event["type"] for event in initial[start:start + 3]],
|
||||
["dragstart", "pointercancel", "lostpointercapture"],
|
||||
"native drag cancels the pointer before releasing capture")
|
||||
assert_equal([(event["buttons"], event["cancelable"]) for event in initial[start + 1:start + 3]],
|
||||
[(0, False), (0, False)], "pointer cancellation and capture loss have no pressed buttons")
|
||||
assert_equal(any(event["type"] == "gotpointercapture" for event in initial[:start]), True,
|
||||
"the source actually acquired capture before dragging")
|
||||
before = len(initial)
|
||||
await _mouse(session, "mouseMoved", 60, 50, 1)
|
||||
await _mouse(session, "mouseMoved", 250, 40, 1)
|
||||
ordinary = [event["type"] for event in (await _events(page))[before:]
|
||||
if event["type"].startswith("pointer") or event["type"] in ("mousemove", "mouseup")]
|
||||
assert_equal(ordinary, [], "an active native drag suppresses the ordinary pointer/mouse stream")
|
||||
await _drag(session, "dragCancel", intercepted[0]["data"])
|
||||
end = [event for event in await _events(page) if event["type"] == "dragend"]
|
||||
assert_equal([(event["effect"], event["cancelable"]) for event in end], [("none", False)],
|
||||
"effective cancellation ends the source exactly once")
|
||||
before = len(await _events(page))
|
||||
await _mouse(session, "mouseMoved", 60, 50, 1)
|
||||
await _mouse(session, "mouseReleased", 60, 50, 0)
|
||||
resumed = [event["type"] for event in (await _events(page))[before:]]
|
||||
assert_equal(resumed, ["pointermove", "mousemove", "pointerup", "mouseup"],
|
||||
"a completed dragCancel restores ordinary input even before the held button is released")
|
||||
assert_equal(len(intercepted), 1, "held movement after cancellation cannot start a second drag")
|
||||
|
||||
state.record("native_drag_pointer_cancel_capture_and_stream_recovery")
|
||||
|
||||
|
||||
async def run_drag_protocol_contracts(state: SmokeState) -> None:
|
||||
await _payload_contracts(state)
|
||||
await _view_contracts(state)
|
||||
await _target_contracts(state)
|
||||
await _pointer_contracts(state)
|
||||
@@ -95,6 +95,7 @@ pub trait PageInputExt {
|
||||
delta_y: f64,
|
||||
pointer: RendererPointerEventProperties,
|
||||
modifiers: u8,
|
||||
intercept_drags: bool,
|
||||
) -> Result<PendingPageCommand>;
|
||||
fn finish_dispatch_mouse_event_at_point_with_outcome(
|
||||
&mut self,
|
||||
@@ -161,6 +162,7 @@ pub trait PageInputExt {
|
||||
) -> Result<RendererInputDispatchOutcome>;
|
||||
async fn clear_active_drag_data_transfer_async(&mut self) -> Result<()>;
|
||||
fn start_clear_active_drag_data_transfer(&self) -> Result<PendingPageCommand>;
|
||||
fn start_cancel_dragging(&self) -> Result<PendingPageCommand>;
|
||||
fn finish_clear_active_drag_data_transfer(
|
||||
&mut self,
|
||||
completion: CompletedPageCommand,
|
||||
@@ -261,6 +263,7 @@ impl PageInputExt for Page {
|
||||
delta_y,
|
||||
RendererPointerEventProperties::default(),
|
||||
0,
|
||||
false,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -276,6 +279,7 @@ impl PageInputExt for Page {
|
||||
delta_y: f64,
|
||||
pointer: RendererPointerEventProperties,
|
||||
modifiers: u8,
|
||||
intercept_drags: bool,
|
||||
) -> Result<PendingPageCommand> {
|
||||
self.start_page_command(RendererPageCommand::DispatchMouseEventAtPoint {
|
||||
x,
|
||||
@@ -288,6 +292,7 @@ impl PageInputExt for Page {
|
||||
delta_y,
|
||||
pointer,
|
||||
modifiers,
|
||||
intercept_drags,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -413,6 +418,10 @@ impl PageInputExt for Page {
|
||||
self.start_page_command(RendererPageCommand::ClearActiveDragDataTransfer)
|
||||
}
|
||||
|
||||
fn start_cancel_dragging(&self) -> Result<PendingPageCommand> {
|
||||
self.start_page_command(RendererPageCommand::CancelDragging)
|
||||
}
|
||||
|
||||
fn finish_clear_active_drag_data_transfer(
|
||||
&mut self,
|
||||
completion: CompletedPageCommand,
|
||||
|
||||
@@ -106,7 +106,7 @@ pub use moli_renderer_v8::{
|
||||
RendererDomFocusOutcome, RendererDomMutationEvent, RendererDomMutationEventBatch,
|
||||
RendererDomSnapshotCaptureOptions, RendererDomSnapshotCapturePayload, RendererDragData,
|
||||
RendererDragDataItem, RendererDraggedDirectory, RendererDraggedFile, RendererFrameToken,
|
||||
RendererInputDispatchOutcome, RendererInspectorCommandRoute,
|
||||
RendererInputDispatchOutcome, RendererInspectorCommandRoute, RendererInterceptedDrag,
|
||||
RendererJavaScriptDialogCompletion, RendererJavaScriptDialogId, RendererJavaScriptDialogResult,
|
||||
RendererJavaScriptDialogSource, RendererLayoutMetrics, RendererLifecycleEpoch,
|
||||
RendererLifecycleEventStamp, RendererLifecycleStartReason, RendererLifecycleTerminationStamp,
|
||||
@@ -417,6 +417,7 @@ impl Page {
|
||||
delta_y,
|
||||
pointer: RendererPointerEventProperties::default(),
|
||||
modifiers: 0,
|
||||
intercept_drags: false,
|
||||
};
|
||||
let reply = self.dispatch_page_command_async(command).await?;
|
||||
expect_page_reply!(
|
||||
|
||||
@@ -3,7 +3,7 @@ const DRAG_MODIFIER_CTRL: u8 = 2;
|
||||
const DRAG_MODIFIER_META: u8 = 4;
|
||||
const DRAG_MODIFIER_SHIFT: u8 = 8;
|
||||
|
||||
/// Permissions granted to a DataTransfer view of a native data store.
|
||||
/// Access granted to a drag data store while a native drag event is dispatched.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum DragDataStoreMode {
|
||||
ReadWrite,
|
||||
@@ -86,12 +86,16 @@ pub fn drag_operations_mask_from_effect_allowed(effect_allowed: &str) -> i32 {
|
||||
}
|
||||
|
||||
pub fn preferred_drop_effect_from_mask(mask: i32) -> &'static str {
|
||||
if (mask & 1) != 0 {
|
||||
// Chromium prefers move for constrained source operations, but uses copy
|
||||
// for all/uninitialized rather than treating that mask as move-first.
|
||||
if (mask & 19) == 19 {
|
||||
"copy"
|
||||
} else if (mask & 16) != 0 {
|
||||
"move"
|
||||
} else if (mask & 1) != 0 {
|
||||
"copy"
|
||||
} else if (mask & 2) != 0 {
|
||||
"link"
|
||||
} else if (mask & 16) != 0 {
|
||||
"move"
|
||||
} else {
|
||||
"none"
|
||||
}
|
||||
@@ -219,6 +223,21 @@ pub fn child_entry_full_path(parent_path: Option<&str>, name: &str) -> String {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn effect_allowed_round_trips_through_drag_operations_mask() {
|
||||
for effect in [
|
||||
"none", "copy", "link", "move", "copyLink", "copyMove", "linkMove", "all",
|
||||
] {
|
||||
let mask = drag_operations_mask_from_effect_allowed(effect);
|
||||
assert_eq!(drag_effect_allowed_from_mask(mask), effect);
|
||||
}
|
||||
assert_eq!(
|
||||
drag_operations_mask_from_effect_allowed("uninitialized"),
|
||||
19
|
||||
);
|
||||
assert_eq!(drag_operations_mask_from_effect_allowed("bogus"), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalizes_legacy_drag_data_type_aliases() {
|
||||
assert_eq!(normalize_drag_data_type(" text "), "text/plain");
|
||||
@@ -242,7 +261,8 @@ mod tests {
|
||||
assert_eq!(drag_effect_allowed_from_mask(1 | 2 | 16), "all");
|
||||
|
||||
assert_eq!(preferred_drop_effect_from_mask(1 | 2 | 16), "copy");
|
||||
assert_eq!(preferred_drop_effect_from_mask(2 | 16), "link");
|
||||
assert_eq!(preferred_drop_effect_from_mask(2 | 16), "move");
|
||||
assert_eq!(preferred_drop_effect_from_mask(1 | 16), "move");
|
||||
assert_eq!(preferred_drop_effect_from_mask(16), "move");
|
||||
assert_eq!(preferred_drop_effect_from_mask(0), "none");
|
||||
}
|
||||
|
||||
@@ -2128,18 +2128,11 @@ fn command_dispatch_completes_input_owner_commands_without_legacy_fallback() {
|
||||
let step = conn.start_command_dispatch(&raw);
|
||||
assert_eq!(
|
||||
complete_messages(step),
|
||||
vec![json!({
|
||||
"id": 1001,
|
||||
"error": {
|
||||
"code": -32000,
|
||||
"message": crate::domains::input::SET_INTERCEPT_DRAGS_UNSUPPORTED_MESSAGE
|
||||
}
|
||||
})]
|
||||
vec![json!({ "id": 1001, "result": {} })]
|
||||
);
|
||||
assert!(
|
||||
conn.browser_context
|
||||
.as_ref()
|
||||
.is_some_and(|context| !context.active_page_target().input_intercept_drags_enabled)
|
||||
conn.target_page_session_state_for_session(None)
|
||||
.is_some_and(|state| state.input_intercept_drags_enabled)
|
||||
);
|
||||
|
||||
let raw = serde_json::to_string(&json!({
|
||||
|
||||
@@ -51,8 +51,6 @@ pub struct PageTargetHost {
|
||||
pub(crate) tls_verify_host_override: Option<bool>,
|
||||
pub(crate) environment_owner: moli_core::ProcessEnvironmentOwner,
|
||||
pub(crate) effective_emulation_state: EffectiveTargetEmulationState,
|
||||
pub(crate) input_intercept_drags_enabled: bool,
|
||||
pub(crate) input_drag_intercepted: bool,
|
||||
pub(crate) css_enabled: bool,
|
||||
pub(crate) document_cookie_manager_surface: BrowserContextCookieManagerSurface,
|
||||
pub(crate) dom_remote_object_node_cache: HashMap<String, Value>,
|
||||
@@ -78,8 +76,6 @@ impl PageTargetHost {
|
||||
tls_verify_host_override: None,
|
||||
environment_owner: Default::default(),
|
||||
effective_emulation_state: EffectiveTargetEmulationState::default(),
|
||||
input_intercept_drags_enabled: false,
|
||||
input_drag_intercepted: false,
|
||||
css_enabled: false,
|
||||
document_cookie_manager_surface: BrowserContextCookieManagerSurface::default(),
|
||||
dom_remote_object_node_cache: HashMap::new(),
|
||||
|
||||
@@ -182,8 +182,6 @@ impl PageTargetHost {
|
||||
|| self.tls_verify_host_override.is_some()
|
||||
|| self.environment_owner.has_override()
|
||||
|| self.effective_emulation_state != super::EffectiveTargetEmulationState::default()
|
||||
|| self.input_intercept_drags_enabled
|
||||
|| self.input_drag_intercepted
|
||||
|| self.css_enabled
|
||||
|| self.fetch_owner.config_snapshot() != super::fetch::TargetFetchConfig::default()
|
||||
}
|
||||
@@ -198,8 +196,6 @@ impl PageTargetHost {
|
||||
self.http_proxy_override = None;
|
||||
self.http_no_proxy_override = None;
|
||||
self.tls_verify_host_override = None;
|
||||
self.input_intercept_drags_enabled = false;
|
||||
self.input_drag_intercepted = false;
|
||||
self.css_enabled = false;
|
||||
}
|
||||
}
|
||||
@@ -430,6 +426,7 @@ impl PageScreencastSessionState {
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct TargetPageSessionState {
|
||||
pub(crate) input_events_ignored: bool,
|
||||
pub(crate) input_intercept_drags_enabled: bool,
|
||||
pub(crate) page_domain_enabled: bool,
|
||||
pub(crate) page_domain_subscription_generation: u64,
|
||||
pub(crate) page_lifecycle_events: bool,
|
||||
@@ -448,6 +445,7 @@ impl Default for TargetPageSessionState {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
input_events_ignored: false,
|
||||
input_intercept_drags_enabled: false,
|
||||
page_domain_enabled: false,
|
||||
page_domain_subscription_generation: 0,
|
||||
page_lifecycle_events: false,
|
||||
@@ -493,6 +491,7 @@ impl TargetPageSessionState {
|
||||
impl PartialEq for TargetPageSessionState {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
self.input_events_ignored == other.input_events_ignored
|
||||
&& self.input_intercept_drags_enabled == other.input_intercept_drags_enabled
|
||||
&& self.page_domain_enabled == other.page_domain_enabled
|
||||
&& self.page_lifecycle_events == other.page_lifecycle_events
|
||||
&& self.audits == other.audits
|
||||
|
||||
@@ -116,6 +116,7 @@ fn page_domain_subscription_generation_tracks_distinct_enable_lifetimes() {
|
||||
fn input_event_ignore_state_survives_document_context_replacement() {
|
||||
let mut state = TargetPageSessionState {
|
||||
input_events_ignored: true,
|
||||
input_intercept_drags_enabled: true,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
@@ -125,6 +126,10 @@ fn input_event_ignore_state_survives_document_context_replacement() {
|
||||
state.input_events_ignored,
|
||||
"Chromium retains each Inspector session's input ignore handle across renderer navigation"
|
||||
);
|
||||
assert!(
|
||||
state.input_intercept_drags_enabled,
|
||||
"drag interception belongs to the DevTools session, not the replaced Document"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -12,6 +12,19 @@ use moli_core::page::{
|
||||
};
|
||||
|
||||
impl ProtocolWorkflowState<'_> {
|
||||
pub(crate) fn input_page_is_current(
|
||||
&self,
|
||||
owner: &CommandOwnerScope,
|
||||
page_owner: &TargetPageResidenceIdentity,
|
||||
host: Option<&crate::conn::ProtocolPageHostScope>,
|
||||
) -> bool {
|
||||
host.is_none_or(|host| host.is_current(self.connection))
|
||||
&& self
|
||||
.connection
|
||||
.target_page_residence_identity_for_owner(owner)
|
||||
.as_ref()
|
||||
== Some(page_owner)
|
||||
}
|
||||
pub(crate) fn settle_input_page_command(
|
||||
self,
|
||||
owner: &TargetPageResidenceIdentity,
|
||||
@@ -31,13 +44,7 @@ impl ProtocolWorkflowState<'_> {
|
||||
host: Option<&crate::conn::ProtocolPageHostScope>,
|
||||
activation: RendererPendingDownloadActivation,
|
||||
) -> Result<Option<crate::conn::downloads::PreparedDownloadActivationDelivery>> {
|
||||
if host.is_some_and(|host| !host.is_current(self.connection))
|
||||
|| self
|
||||
.connection
|
||||
.target_page_residence_identity_for_owner(owner)
|
||||
.as_ref()
|
||||
!= Some(page_owner)
|
||||
{
|
||||
if !self.input_page_is_current(owner, page_owner, host) {
|
||||
return Ok(None);
|
||||
}
|
||||
self.connection
|
||||
|
||||
@@ -55,8 +55,6 @@ pub(crate) const DISPATCH_TOUCH_EVENT_UNSUPPORTED_MESSAGE: &str = "Input.dispatc
|
||||
pub(crate) const EMULATE_TOUCH_FROM_MOUSE_EVENT_UNSUPPORTED_MESSAGE: &str = "Input.emulateTouchFromMouseEvent is not supported: coordinate-based touch emulation requires layout hit testing.";
|
||||
pub(crate) const SYNTHESIZE_TAP_GESTURE_UNSUPPORTED_MESSAGE: &str = "Input.synthesizeTapGesture is not supported: coordinate-based tap gestures require layout hit testing.";
|
||||
pub(crate) const DISPATCH_DRAG_EVENT_UNSUPPORTED_MESSAGE: &str = "Input.dispatchDragEvent is not supported: coordinate-based drag input requires layout hit testing.";
|
||||
pub(crate) const SET_INTERCEPT_DRAGS_UNSUPPORTED_MESSAGE: &str =
|
||||
"Input.setInterceptDrags is not supported: drag interception events are not implemented.";
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct SetIgnoreInputEventsParams {
|
||||
@@ -129,6 +127,8 @@ struct PendingInputCommandStartError {
|
||||
enum InputAdmissionError {
|
||||
#[error("InvalidParams")]
|
||||
InvalidParams,
|
||||
#[error("NoBrowserContext")]
|
||||
NoBrowserContext,
|
||||
#[error("{0}")]
|
||||
Unsupported(&'static str),
|
||||
#[error("UnsupportedDevToolsCommand")]
|
||||
@@ -145,6 +145,7 @@ enum PendingInputCommandKind {
|
||||
DispatchKeyEvent,
|
||||
DispatchTouchEvent,
|
||||
DispatchDragEvent,
|
||||
CancelDragging,
|
||||
SynthesizeTapGesture,
|
||||
InsertText,
|
||||
}
|
||||
@@ -321,7 +322,7 @@ pub(crate) fn try_start_input_command_dispatch(
|
||||
};
|
||||
match action {
|
||||
InputAction::CancelDragging => {
|
||||
InputCommandDispatchStep::Complete(drag::cancel_dragging_command_output_plan(conn, cmd))
|
||||
start_input_command_dispatch_after_noop_checks(conn, cmd, action)
|
||||
}
|
||||
InputAction::SetInterceptDrags => InputCommandDispatchStep::Complete(
|
||||
drag::set_intercept_drags_command_output_plan(conn, cmd),
|
||||
@@ -675,9 +676,27 @@ fn start_pending_input_command(
|
||||
|page| page.start_insert_text_into_active_control(&text),
|
||||
)
|
||||
}
|
||||
InputAction::CancelDragging
|
||||
| InputAction::SetIgnoreInputEvents
|
||||
| InputAction::SetInterceptDrags => Ok(None),
|
||||
InputAction::CancelDragging => {
|
||||
let slot = conn
|
||||
.runtime_session_owner_slot_for_owner(&owner)
|
||||
.ok_or_else(|| {
|
||||
PendingInputCommandStartError::renderer_error(
|
||||
InputAdmissionError::NoBrowserContext,
|
||||
)
|
||||
})?;
|
||||
if slot.loaded_page().is_none() {
|
||||
// An attached target with no renderer cannot own an active drag.
|
||||
return Ok(None);
|
||||
}
|
||||
start_page_input_command(
|
||||
conn,
|
||||
cmd.id,
|
||||
&owner,
|
||||
PendingInputCommandKind::CancelDragging,
|
||||
|page| page.start_cancel_dragging(),
|
||||
)
|
||||
}
|
||||
InputAction::SetIgnoreInputEvents | InputAction::SetInterceptDrags => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1126,6 +1145,7 @@ fn start_devtools_dispatch_mouse_event_command(
|
||||
tilt_y: command.tilt_y,
|
||||
twist: command.twist,
|
||||
};
|
||||
let intercept_drags = drag::intercept_drags_enabled(conn, owner);
|
||||
start_page_input_command(
|
||||
conn,
|
||||
command_id,
|
||||
@@ -1143,6 +1163,7 @@ fn start_devtools_dispatch_mouse_event_command(
|
||||
command.delta_y,
|
||||
pointer,
|
||||
command.modifiers,
|
||||
intercept_drags,
|
||||
)
|
||||
},
|
||||
)
|
||||
@@ -1426,6 +1447,7 @@ async fn complete_input_operation(
|
||||
PendingInputCommandKind::DispatchTouchEvent
|
||||
| PendingInputCommandKind::SynthesizeTapGesture => "touch event page command",
|
||||
PendingInputCommandKind::DispatchDragEvent => "drag event page command",
|
||||
PendingInputCommandKind::CancelDragging => "cancel dragging page command",
|
||||
PendingInputCommandKind::DispatchKeyEvent => "key event page command",
|
||||
_ => unreachable!(),
|
||||
};
|
||||
@@ -1669,6 +1691,12 @@ async fn handle_input_dispatch_outcome(
|
||||
.await;
|
||||
}
|
||||
}
|
||||
if let Some(drag) = outcome.intercepted_drag
|
||||
&& driver.with_state(|state| state.input_page_is_current(&action_owner, owner, host))?
|
||||
{
|
||||
out.events_mut()
|
||||
.push(drag::drag_intercepted_event(session_id, &drag));
|
||||
}
|
||||
// A navigation triggered by input is frozen by the renderer command turn
|
||||
// into a concrete owner-action record. Protocol ingress schedules that
|
||||
// action independently; this command path must not rescan mutable Page
|
||||
|
||||
@@ -1,14 +1,20 @@
|
||||
use super::*;
|
||||
use crate::conn::build_event;
|
||||
use moli_core::page::RendererInterceptedDrag;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct SetInterceptDragsParams {
|
||||
#[serde(default)]
|
||||
enabled: bool,
|
||||
}
|
||||
|
||||
/// Chromium reports a drag that starts while interception is on through
|
||||
/// `Input.dragIntercepted` and leaves the rest of the drag to the client, which
|
||||
/// replays it with `Input.dispatchDragEvent`. The flag set here travels with
|
||||
/// every mouse event to the renderer, which decides whether `dragstart` ran.
|
||||
pub(super) fn set_intercept_drags_command_output_plan(
|
||||
_conn: &mut CdpConnection,
|
||||
conn: &mut CdpConnection,
|
||||
cmd: &Cmd<'_>,
|
||||
) -> CommandOutputPlan {
|
||||
let params: SetInterceptDragsParams = match cmd.get_params() {
|
||||
@@ -17,18 +23,49 @@ pub(super) fn set_intercept_drags_command_output_plan(
|
||||
return CommandOutputPlan::error(-32602, "InvalidParams");
|
||||
}
|
||||
};
|
||||
let _ = params.enabled;
|
||||
CommandOutputPlan::error(-32000, SET_INTERCEPT_DRAGS_UNSUPPORTED_MESSAGE)
|
||||
}
|
||||
|
||||
pub(super) fn cancel_dragging_command_output_plan(
|
||||
conn: &mut CdpConnection,
|
||||
cmd: &Cmd<'_>,
|
||||
) -> CommandOutputPlan {
|
||||
if !conn.mutate_target_page_state_for_session(cmd.session_id, |state| {
|
||||
state.input_drag_intercepted = false;
|
||||
}) {
|
||||
if conn
|
||||
.with_target_devtools_session_state_for_session_mut(cmd.session_id, |state| {
|
||||
state.page_session_state.input_intercept_drags_enabled = params.enabled;
|
||||
})
|
||||
.is_none()
|
||||
{
|
||||
return CommandOutputPlan::error(-32000, "NoBrowserContext");
|
||||
}
|
||||
CommandOutputPlan::success()
|
||||
}
|
||||
|
||||
pub(super) fn intercept_drags_enabled(conn: &CdpConnection, owner: &CommandOwnerScope) -> bool {
|
||||
conn.target_page_session_state_for_owner(owner)
|
||||
.is_some_and(|state| state.input_intercept_drags_enabled)
|
||||
}
|
||||
|
||||
/// Builds the `Input.dragIntercepted` event for a drag the renderer started.
|
||||
pub(super) fn drag_intercepted_event(
|
||||
session_id: Option<&str>,
|
||||
drag: &RendererInterceptedDrag,
|
||||
) -> BackgroundProtocolEvent {
|
||||
let items: Vec<Value> = drag
|
||||
.items
|
||||
.iter()
|
||||
.map(|item| {
|
||||
let mut value = json!({ "mimeType": item.mime_type, "data": item.data });
|
||||
if let Some(title) = &item.title {
|
||||
value["title"] = json!(title);
|
||||
}
|
||||
if let Some(base_url) = &item.base_url {
|
||||
value["baseURL"] = json!(base_url);
|
||||
}
|
||||
value
|
||||
})
|
||||
.collect();
|
||||
BackgroundProtocolEvent::immediate(build_event(
|
||||
"Input.dragIntercepted",
|
||||
json!({
|
||||
"data": {
|
||||
"items": items,
|
||||
"dragOperationsMask": drag.drag_operations_mask,
|
||||
}
|
||||
}),
|
||||
session_id,
|
||||
))
|
||||
}
|
||||
|
||||
@@ -458,6 +458,37 @@ async fn coordinate_mouse_event_without_document_still_reports_no_document_loade
|
||||
ctx.expect_error(103, -32000, "NoDocumentLoaded");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn cancel_dragging_without_a_loaded_page_preserves_idle_success() {
|
||||
let mut ctx = TestContext::new();
|
||||
let mut bc = BrowserContext::new("BID-idle-drag".into());
|
||||
bc.set_active_target_id("TID-idle-drag");
|
||||
ctx.with_fixture(|conn| conn.install_browser_context_fixture_for_test(bc));
|
||||
assert!(ctx.with_fixture(|conn| {
|
||||
conn.runtime_session_owner_slot(None)
|
||||
.expect("target slot")
|
||||
.loaded_page()
|
||||
.is_none()
|
||||
}));
|
||||
ctx.process_async(json!({ "id": 106, "method": "Input.cancelDragging" }))
|
||||
.await;
|
||||
ctx.expect_result(106, json!({}), None);
|
||||
assert!(
|
||||
ctx.with_fixture(|conn| conn
|
||||
.runtime_session_owner_slot(None)
|
||||
.expect("target slot")
|
||||
.loaded_page()
|
||||
.is_none()),
|
||||
"idle cancellation must not instantiate a Page just to clear a nonexistent drag"
|
||||
);
|
||||
|
||||
let mut without_context = TestContext::new();
|
||||
without_context
|
||||
.process_async(json!({ "id": 107, "method": "Input.cancelDragging" }))
|
||||
.await;
|
||||
without_context.expect_error(107, -32000, "NoBrowserContext");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn coordinate_mouse_down_applies_chromium_focus_default_action() {
|
||||
let mut ctx = TestContext::new();
|
||||
@@ -781,7 +812,7 @@ async fn mock_layout_policy_rejects_coordinate_input_without_dispatching() {
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn set_intercept_drags_fails_explicitly_without_drag_interception_events() {
|
||||
async fn set_intercept_drags_toggles_only_session_state() {
|
||||
let mut ctx = TestContext::new();
|
||||
with_loaded_document(
|
||||
&mut ctx,
|
||||
@@ -789,54 +820,464 @@ async fn set_intercept_drags_fails_explicitly_without_drag_interception_events()
|
||||
)
|
||||
.await;
|
||||
|
||||
for (id, enabled) in [(12, true), (13, false)] {
|
||||
ctx.process_async(json!({
|
||||
"id": id,
|
||||
"method": "Input.setInterceptDrags",
|
||||
"params": { "enabled": enabled }
|
||||
}))
|
||||
.await;
|
||||
ctx.expect_result(id, json!({}), None);
|
||||
assert_eq!(
|
||||
ctx.with_fixture(|conn| conn
|
||||
.target_page_session_state_for_session(None)
|
||||
.expect("Page session")
|
||||
.input_intercept_drags_enabled),
|
||||
enabled,
|
||||
);
|
||||
}
|
||||
|
||||
ctx.process_async(json!({
|
||||
"id": 12,
|
||||
"id": 14,
|
||||
"method": "Input.setInterceptDrags",
|
||||
"params": { "enabled": "yes" }
|
||||
}))
|
||||
.await;
|
||||
ctx.expect_error(14, -32602, "InvalidParams");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drag_interception_reads_the_explicit_target_owner_instead_of_the_active_target() {
|
||||
let ctx = TestContext::new();
|
||||
let mut browser_context = BrowserContext::new("BID-drag-owner".to_owned());
|
||||
browser_context.set_active_target_id("TID-active".to_owned());
|
||||
browser_context.stage_background_target(
|
||||
"TID-explicit".to_owned(),
|
||||
None,
|
||||
"about:blank".to_owned(),
|
||||
None,
|
||||
None,
|
||||
);
|
||||
ctx.with_fixture(|conn| {
|
||||
conn.install_browser_context_fixture_for_test(browser_context);
|
||||
let active = CommandOwnerScope::capture(conn, None);
|
||||
let explicit = CommandOwnerScope::for_route(
|
||||
conn.target_session_route_for_target_id("TID-explicit")
|
||||
.expect("explicit Page route"),
|
||||
);
|
||||
assert_eq!(active.session_id(), None);
|
||||
assert_eq!(explicit.session_id(), None);
|
||||
for (active_enabled, explicit_enabled) in [(true, false), (false, true)] {
|
||||
let browser_context = conn.browser_context.as_mut().expect("browser context");
|
||||
browser_context.active_page_target_mut().devtools_sessions
|
||||
[moli_page_types::DevToolsSessionKey::Primary]
|
||||
.page_session_state
|
||||
.input_intercept_drags_enabled = active_enabled;
|
||||
browser_context
|
||||
.background_target_mut("TID-explicit")
|
||||
.expect("background Page")
|
||||
.devtools_sessions[moli_page_types::DevToolsSessionKey::Primary]
|
||||
.page_session_state
|
||||
.input_intercept_drags_enabled = explicit_enabled;
|
||||
assert_eq!(drag::intercept_drags_enabled(conn, &active), active_enabled);
|
||||
assert_eq!(
|
||||
drag::intercept_drags_enabled(conn, &explicit),
|
||||
explicit_enabled,
|
||||
"route owners without a sessionId must retain their explicit target authority"
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const DRAG_PAGE: &str = r#"<html><body>
|
||||
<div id='source' draggable='true' style='position:absolute;left:0;top:0;width:100px;height:40px'></div>
|
||||
<div id='target' style='position:absolute;left:0;top:100px;width:200px;height:100px'></div>
|
||||
<script>
|
||||
window.__drag = [];
|
||||
const source = document.getElementById('source');
|
||||
const target = document.getElementById('target');
|
||||
source.addEventListener('dragstart', event => {
|
||||
event.dataTransfer.setData('text/plain', 'payload');
|
||||
event.dataTransfer.setData('application/x-moli', 'custom');
|
||||
event.dataTransfer.effectAllowed = 'move';
|
||||
window.__drag.push('dragstart');
|
||||
});
|
||||
source.addEventListener('dragend', () => window.__drag.push('dragend'));
|
||||
target.addEventListener('dragenter', event => {
|
||||
window.__drag.push('dragenter:' + event.dataTransfer.getData('text/plain')
|
||||
+ ':' + Array.from(event.dataTransfer.types).join(','));
|
||||
});
|
||||
target.addEventListener('dragover', event => {
|
||||
event.preventDefault();
|
||||
window.__drag.push('dragover');
|
||||
});
|
||||
target.addEventListener('drop', event => {
|
||||
event.preventDefault();
|
||||
window.__drag.push('drop:' + event.dataTransfer.getData('application/x-moli'));
|
||||
});
|
||||
</script>
|
||||
</body></html>"#;
|
||||
|
||||
async fn dispatch_drag_mouse(
|
||||
ctx: &mut TestContext,
|
||||
id: u64,
|
||||
event_type: &str,
|
||||
y: u32,
|
||||
buttons: u32,
|
||||
) {
|
||||
dispatch_drag_mouse_for_session(ctx, id, event_type, y, buttons, None).await;
|
||||
}
|
||||
|
||||
async fn dispatch_drag_mouse_for_session(
|
||||
ctx: &mut TestContext,
|
||||
id: u64,
|
||||
event_type: &str,
|
||||
y: u32,
|
||||
buttons: u32,
|
||||
session_id: Option<&str>,
|
||||
) {
|
||||
let mut command = json!({
|
||||
"id": id,
|
||||
"method": "Input.dispatchMouseEvent",
|
||||
"params": {
|
||||
"type": event_type,
|
||||
"x": INPUT_HIT_X,
|
||||
"y": y,
|
||||
"button": "left",
|
||||
"buttons": buttons
|
||||
}
|
||||
});
|
||||
if let Some(session_id) = session_id {
|
||||
command["sessionId"] = json!(session_id);
|
||||
}
|
||||
ctx.process_async(command).await;
|
||||
ctx.expect_result(id, json!({}), session_id);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn mouse_drag_with_interception_reports_drag_intercepted_and_replays_through_drag_events() {
|
||||
let mut ctx = TestContext::new();
|
||||
with_loaded_document(&mut ctx, DRAG_PAGE).await;
|
||||
ctx.capture_fixture_layout(None).await;
|
||||
|
||||
ctx.process_async(json!({
|
||||
"id": 1,
|
||||
"method": "Input.setInterceptDrags",
|
||||
"params": { "enabled": true }
|
||||
}))
|
||||
.await;
|
||||
ctx.expect_result(1, json!({}), None);
|
||||
|
||||
ctx.expect_error(12, -32000, SET_INTERCEPT_DRAGS_UNSUPPORTED_MESSAGE);
|
||||
dispatch_drag_mouse(&mut ctx, 2, "mousePressed", 20, 1).await;
|
||||
dispatch_drag_mouse(&mut ctx, 3, "mouseMoved", 120, 1).await;
|
||||
|
||||
let intercepted = json!({
|
||||
"data": {
|
||||
"items": [
|
||||
{ "mimeType": "text/plain", "data": "payload" },
|
||||
{ "mimeType": "application/x-moli", "data": "custom" }
|
||||
],
|
||||
"dragOperationsMask": 16
|
||||
}
|
||||
});
|
||||
ctx.expect_event("Input.dragIntercepted", Some(&intercepted));
|
||||
assert_eq!(
|
||||
evaluate_string(&mut ctx, "window.__drag.join('|')").await,
|
||||
"dragstart"
|
||||
);
|
||||
|
||||
for (id, drag_type) in [(4, "dragEnter"), (5, "dragOver"), (6, "drop")] {
|
||||
ctx.process_async(json!({
|
||||
"id": id,
|
||||
"method": "Input.dispatchDragEvent",
|
||||
"params": {
|
||||
"type": drag_type,
|
||||
"x": INPUT_HIT_X,
|
||||
"y": 120,
|
||||
"data": intercepted["data"]
|
||||
}
|
||||
}))
|
||||
.await;
|
||||
ctx.expect_result(id, json!({}), None);
|
||||
if drag_type == "dragEnter" {
|
||||
assert_eq!(
|
||||
evaluate_string(&mut ctx, "window.__drag.join('|')").await,
|
||||
"dragstart|dragenter::text/plain,application/x-moli"
|
||||
);
|
||||
} else if drag_type == "dragOver" {
|
||||
assert_eq!(
|
||||
evaluate_string(&mut ctx, "window.__drag.join('|')").await,
|
||||
"dragstart|dragenter::text/plain,application/x-moli|dragover"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
assert_eq!(
|
||||
evaluate_string(&mut ctx, "window.__drag.join('|')").await,
|
||||
"dragstart|dragenter::text/plain,application/x-moli|dragover|dragover|drop:custom|dragend"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn mouse_drag_without_interception_emits_no_drag_intercepted_event() {
|
||||
let mut ctx = TestContext::new();
|
||||
with_loaded_document(&mut ctx, DRAG_PAGE).await;
|
||||
ctx.capture_fixture_layout(None).await;
|
||||
|
||||
dispatch_drag_mouse(&mut ctx, 2, "mousePressed", 20, 1).await;
|
||||
dispatch_drag_mouse(&mut ctx, 3, "mouseMoved", 120, 1).await;
|
||||
assert_eq!(
|
||||
evaluate_string(&mut ctx, "window.__drag.join('|')").await,
|
||||
"dragstart|dragenter::text/plain,application/x-moli"
|
||||
);
|
||||
dispatch_drag_mouse(&mut ctx, 4, "mouseReleased", 120, 0).await;
|
||||
|
||||
assert!(
|
||||
ctx.take_all()
|
||||
.iter()
|
||||
.all(|message| message["method"] != "Input.dragIntercepted")
|
||||
);
|
||||
assert_eq!(
|
||||
evaluate_string(&mut ctx, "window.__drag.join('|')").await,
|
||||
"dragstart|dragenter::text/plain,application/x-moli|dragover|drop:custom|dragend"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn pending_drag_interception_uses_the_admitted_command_configuration() {
|
||||
let mut ctx = TestContext::new();
|
||||
with_loaded_document(&mut ctx, DRAG_PAGE).await;
|
||||
ctx.capture_fixture_layout(None).await;
|
||||
ctx.process_async(json!({
|
||||
"id": 70, "method": "Input.setInterceptDrags", "params": { "enabled": true }
|
||||
}))
|
||||
.await;
|
||||
ctx.expect_result(70, json!({}), None);
|
||||
dispatch_drag_mouse(&mut ctx, 71, "mousePressed", 20, 1).await;
|
||||
let raw = json!({
|
||||
"id": 72, "method": "Input.dispatchMouseEvent",
|
||||
"params": { "type": "mouseMoved", "x": INPUT_HIT_X, "y": 120,
|
||||
"button": "left", "buttons": 1 }
|
||||
})
|
||||
.to_string();
|
||||
let pending = match ctx.with_fixture(|conn| conn.start_command_dispatch(&raw)) {
|
||||
CdpCommandTaskStep::Pending(pending) => pending,
|
||||
CdpCommandTaskStep::Complete(_) => {
|
||||
panic!("mouse movement must wait for its renderer outcome")
|
||||
}
|
||||
};
|
||||
// The enqueued command already owns interception, even if the frontend
|
||||
// disables it before protocol consumes that command's renderer reply.
|
||||
ctx.process_async(json!({
|
||||
"id": 73, "method": "Input.setInterceptDrags", "params": { "enabled": false }
|
||||
}))
|
||||
.await;
|
||||
ctx.expect_result(73, json!({}), None);
|
||||
let completed = ctx.drive_owned_wait(pending.wait()).await;
|
||||
let outcome = ctx
|
||||
.state
|
||||
.with_state(|state| {
|
||||
state.complete_command_dispatch_with_context(
|
||||
completed,
|
||||
&mut crate::conn::CommandDispatchContext::default(),
|
||||
)
|
||||
})
|
||||
.expect("live test owner");
|
||||
let (messages, scheduler_events) = ctx.complete_command_task_step_for_test(outcome).await;
|
||||
assert!(scheduler_events.is_empty());
|
||||
assert!(messages.contains(&json!({ "id": 72, "result": {} })));
|
||||
let intercepted = messages
|
||||
.iter()
|
||||
.filter(|message| message["method"] == "Input.dragIntercepted")
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(
|
||||
intercepted.len(),
|
||||
1,
|
||||
"the renderer has already intercepted this drag and must notify its issuing frontend"
|
||||
);
|
||||
assert_eq!(
|
||||
intercepted[0]["params"]["data"]["items"][0]["data"],
|
||||
"payload"
|
||||
);
|
||||
assert_eq!(
|
||||
evaluate_string(&mut ctx, "window.__drag.join('|')").await,
|
||||
"dragstart"
|
||||
);
|
||||
ctx.process_async(json!({ "id": 74, "method": "Input.cancelDragging" }))
|
||||
.await;
|
||||
ctx.expect_result(74, json!({}), None);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn cancel_dragging_clears_reachable_drag_state_and_succeeds_when_idle() {
|
||||
let mut ctx = TestContext::new();
|
||||
with_loaded_document(&mut ctx, "<html><body>drag state</body></html>").await;
|
||||
{
|
||||
let fixture_value = true;
|
||||
ctx.with_fixture(|conn| {
|
||||
conn.browser_context
|
||||
.as_mut()
|
||||
.expect("browser context")
|
||||
.active_page_target_mut()
|
||||
.input_drag_intercepted = fixture_value;
|
||||
});
|
||||
for intercept in [false, true] {
|
||||
let mut ctx = TestContext::new();
|
||||
with_loaded_document(&mut ctx, DRAG_PAGE).await;
|
||||
ctx.capture_fixture_layout(None).await;
|
||||
ctx.process_async(json!({
|
||||
"id": 1, "method": "Input.setInterceptDrags", "params": { "enabled": intercept }
|
||||
}))
|
||||
.await;
|
||||
ctx.expect_result(1, json!({}), None);
|
||||
dispatch_drag_mouse(&mut ctx, 2, "mousePressed", 20, 1).await;
|
||||
dispatch_drag_mouse(&mut ctx, 3, "mouseMoved", 120, 1).await;
|
||||
assert!(
|
||||
evaluate_string(&mut ctx, "window.__drag.join('|')")
|
||||
.await
|
||||
.starts_with("dragstart")
|
||||
);
|
||||
ctx.process_async(json!({ "id": 13, "method": "Input.cancelDragging" }))
|
||||
.await;
|
||||
ctx.expect_result(13, json!({}), None);
|
||||
let terminal = "JSON.stringify({ends:window.__drag.filter(e=>e==='dragend').length,drops:window.__drag.filter(e=>e.startsWith('drop:')).length})";
|
||||
assert_eq!(
|
||||
evaluate_string(&mut ctx, terminal).await,
|
||||
r#"{"ends":1,"drops":0}"#
|
||||
);
|
||||
dispatch_drag_mouse(&mut ctx, 14, "mouseReleased", 120, 0).await;
|
||||
ctx.process_async(json!({ "id": 15, "method": "Input.cancelDragging", "params": {} }))
|
||||
.await;
|
||||
ctx.expect_result(15, json!({}), None);
|
||||
assert_eq!(
|
||||
evaluate_string(&mut ctx, terminal).await,
|
||||
r#"{"ends":1,"drops":0}"#,
|
||||
"mouse release and repeated cancellation must not reuse the completed drag"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
ctx.process_async(json!({
|
||||
"id": 13,
|
||||
"method": "Input.cancelDragging"
|
||||
}))
|
||||
.await;
|
||||
ctx.expect_result(13, json!({}), None);
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn drag_interception_is_scoped_to_the_issuing_session_and_released_on_detach() {
|
||||
let mut ctx = TestContext::new();
|
||||
with_loaded_document(&mut ctx, DRAG_PAGE).await;
|
||||
ctx.capture_fixture_layout(None).await;
|
||||
ctx.with_fixture(|conn| {
|
||||
assert!(!{
|
||||
conn.browser_context
|
||||
.as_ref()
|
||||
.expect("browser context")
|
||||
.active_page_target()
|
||||
.input_drag_intercepted
|
||||
})
|
||||
let browser_context = conn.browser_context.as_mut().expect("browser context");
|
||||
browser_context.attach_active_session("SID-primary");
|
||||
assert!(browser_context.assign_attached_session_to_target("TID-1", "SID-drag".to_owned()));
|
||||
});
|
||||
|
||||
ctx.process_async(json!({
|
||||
"id": 14,
|
||||
"method": "Input.cancelDragging",
|
||||
"params": {}
|
||||
"id": 40, "method": "Input.setInterceptDrags", "sessionId": "SID-drag",
|
||||
"params": { "enabled": true }
|
||||
}))
|
||||
.await;
|
||||
ctx.expect_result(14, json!({}), None);
|
||||
ctx.expect_result(40, json!({}), Some("SID-drag"));
|
||||
assert!(ctx.with_fixture(|conn| {
|
||||
conn.target_page_session_state_for_session(Some("SID-drag"))
|
||||
.is_some_and(|state| state.input_intercept_drags_enabled)
|
||||
}));
|
||||
assert!(!ctx.with_fixture(|conn| {
|
||||
conn.target_page_session_state_for_session(Some("SID-primary"))
|
||||
.is_some_and(|state| state.input_intercept_drags_enabled)
|
||||
}));
|
||||
|
||||
// Another attached frontend must still drive ordinary native dragging.
|
||||
for (id, event_type, y, buttons) in [
|
||||
(41, "mousePressed", 20, 1),
|
||||
(42, "mouseMoved", 120, 1),
|
||||
(43, "mouseReleased", 120, 0),
|
||||
] {
|
||||
dispatch_drag_mouse_for_session(&mut ctx, id, event_type, y, buttons, Some("SID-primary"))
|
||||
.await;
|
||||
}
|
||||
assert!(
|
||||
evaluate_string(&mut ctx, "window.__drag.join('|')")
|
||||
.await
|
||||
.contains("drop:custom")
|
||||
);
|
||||
assert!(
|
||||
ctx.take_all()
|
||||
.iter()
|
||||
.all(|message| message["method"] != "Input.dragIntercepted")
|
||||
);
|
||||
evaluate_string(&mut ctx, "window.__drag = []; 'reset'").await;
|
||||
|
||||
dispatch_drag_mouse_for_session(&mut ctx, 44, "mousePressed", 20, 1, Some("SID-drag")).await;
|
||||
dispatch_drag_mouse_for_session(&mut ctx, 45, "mouseMoved", 120, 1, Some("SID-drag")).await;
|
||||
let intercepted = ctx
|
||||
.take_all()
|
||||
.into_iter()
|
||||
.filter(|message| message["method"] == "Input.dragIntercepted")
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(intercepted.len(), 1);
|
||||
assert_eq!(
|
||||
intercepted[0]["sessionId"], "SID-drag",
|
||||
"the triggering frontend alone receives the intercepted data"
|
||||
);
|
||||
assert_eq!(
|
||||
evaluate_string(&mut ctx, "window.__drag.join('|')").await,
|
||||
"dragstart"
|
||||
);
|
||||
// Switching to a frontend without interception must release the old drag,
|
||||
// without exposing its payload or starting another drag from the held press.
|
||||
dispatch_drag_mouse_for_session(&mut ctx, 46, "mouseMoved", 120, 1, Some("SID-primary")).await;
|
||||
assert_eq!(
|
||||
evaluate_string(&mut ctx, "window.__drag.join('|')").await,
|
||||
"dragstart|dragend"
|
||||
);
|
||||
dispatch_drag_mouse_for_session(&mut ctx, 47, "mouseReleased", 120, 0, Some("SID-primary"))
|
||||
.await;
|
||||
|
||||
ctx.process_async(json!({
|
||||
"id": 48, "method": "Input.setInterceptDrags", "sessionId": "SID-drag",
|
||||
"params": { "enabled": false }
|
||||
}))
|
||||
.await;
|
||||
ctx.expect_result(48, json!({}), Some("SID-drag"));
|
||||
evaluate_string(&mut ctx, "window.__drag = []; 'reset'").await;
|
||||
for (id, event_type, y, buttons) in [
|
||||
(49, "mousePressed", 20, 1),
|
||||
(50, "mouseMoved", 120, 1),
|
||||
(51, "mouseReleased", 120, 0),
|
||||
] {
|
||||
dispatch_drag_mouse_for_session(&mut ctx, id, event_type, y, buttons, Some("SID-drag"))
|
||||
.await;
|
||||
}
|
||||
assert!(
|
||||
evaluate_string(&mut ctx, "window.__drag.join('|')")
|
||||
.await
|
||||
.contains("drop:custom")
|
||||
);
|
||||
assert!(
|
||||
ctx.take_all()
|
||||
.iter()
|
||||
.all(|message| message["method"] != "Input.dragIntercepted")
|
||||
);
|
||||
|
||||
ctx.process_async(json!({
|
||||
"id": 52, "method": "Input.setInterceptDrags", "sessionId": "SID-drag",
|
||||
"params": { "enabled": true }
|
||||
}))
|
||||
.await;
|
||||
ctx.expect_result(52, json!({}), Some("SID-drag"));
|
||||
ctx.process_async(json!({
|
||||
"id": 53, "method": "Target.detachFromTarget", "params": { "sessionId": "SID-drag" }
|
||||
}))
|
||||
.await;
|
||||
ctx.expect_result(53, json!({}), None);
|
||||
assert!(ctx.with_fixture(|conn| {
|
||||
conn.target_page_session_state_for_session(Some("SID-drag"))
|
||||
.is_none()
|
||||
}));
|
||||
evaluate_string(&mut ctx, "window.__drag = []; 'reset'").await;
|
||||
for (id, event_type, y, buttons) in [
|
||||
(54, "mousePressed", 20, 1),
|
||||
(55, "mouseMoved", 120, 1),
|
||||
(56, "mouseReleased", 120, 0),
|
||||
] {
|
||||
dispatch_drag_mouse_for_session(&mut ctx, id, event_type, y, buttons, Some("SID-primary"))
|
||||
.await;
|
||||
}
|
||||
assert!(
|
||||
evaluate_string(&mut ctx, "window.__drag.join('|')")
|
||||
.await
|
||||
.contains("drop:custom")
|
||||
);
|
||||
assert!(
|
||||
ctx.take_all()
|
||||
.iter()
|
||||
.all(|message| message["method"] != "Input.dragIntercepted")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
|
||||
@@ -1696,8 +1696,10 @@ async fn detach_from_target() {
|
||||
},
|
||||
);
|
||||
bc.active_page_target_mut().css_enabled = true;
|
||||
bc.active_page_target_mut().input_intercept_drags_enabled = true;
|
||||
bc.active_page_target_mut().input_drag_intercepted = true;
|
||||
bc.active_page_target_mut().devtools_sessions
|
||||
[moli_page_types::DevToolsSessionKey::Primary]
|
||||
.page_session_state
|
||||
.input_intercept_drags_enabled = true;
|
||||
bc.active_page_target_mut().fetch_owner.configure(
|
||||
Some(sid.clone()),
|
||||
true,
|
||||
@@ -1746,8 +1748,12 @@ async fn detach_from_target() {
|
||||
.bypass_service_worker()
|
||||
);
|
||||
assert!(!bc.active_page_target().css_enabled);
|
||||
assert!(!bc.active_page_target().input_intercept_drags_enabled);
|
||||
assert!(!bc.active_page_target().input_drag_intercepted);
|
||||
assert!(
|
||||
!bc.active_page_target().devtools_sessions
|
||||
[moli_page_types::DevToolsSessionKey::Primary]
|
||||
.page_session_state
|
||||
.input_intercept_drags_enabled
|
||||
);
|
||||
assert!(!bc.active_page_target().fetch_owner.is_enabled());
|
||||
assert!(!bc.active_page_target().fetch_owner.handle_auth_requests());
|
||||
assert!(
|
||||
|
||||
@@ -129,6 +129,7 @@ mod performance_observer_runtime;
|
||||
mod performance_runtime;
|
||||
mod range;
|
||||
mod range_algorithms;
|
||||
pub(crate) use range_algorithms::document_selection_drag_text_at_point;
|
||||
mod range_live;
|
||||
mod range_surface;
|
||||
mod resize_observer_runtime;
|
||||
@@ -315,8 +316,8 @@ pub(crate) use self::file_api::{
|
||||
native_data_transfer_drop_effect, native_data_transfer_drop_effect_is_initialized,
|
||||
native_data_transfer_effect_allowed, native_data_transfer_file_count,
|
||||
native_data_transfer_files, native_data_transfer_get_data, native_data_transfer_has_type,
|
||||
populate_native_drag_data_store, protect_native_drag_source_view,
|
||||
readonly_data_transfer_for_input, replace_drag_data_store_contents,
|
||||
native_set_drag_drop_effect, populate_native_drag_data_store, protect_native_drag_source_view,
|
||||
read_string_drag_data, readonly_data_transfer_for_input, replace_drag_data_store_contents,
|
||||
update_drag_data_store_operations_mask,
|
||||
};
|
||||
pub(crate) use self::file_api::{
|
||||
|
||||
@@ -17,8 +17,8 @@ pub(crate) use data_transfer::{
|
||||
is_branded_data_transfer_object, native_data_transfer_drop_effect,
|
||||
native_data_transfer_drop_effect_is_initialized, native_data_transfer_effect_allowed,
|
||||
native_data_transfer_file_count, native_data_transfer_files, native_data_transfer_get_data,
|
||||
native_data_transfer_has_type, populate_native_drag_data_store,
|
||||
protect_native_drag_source_view, readonly_data_transfer_for_input,
|
||||
native_data_transfer_has_type, native_set_drag_drop_effect, populate_native_drag_data_store,
|
||||
protect_native_drag_source_view, read_string_drag_data, readonly_data_transfer_for_input,
|
||||
replace_drag_data_store_contents, update_drag_data_store_operations_mask,
|
||||
};
|
||||
pub(crate) use data_transfer::{
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
use super::*;
|
||||
use crate::blob;
|
||||
use crate::native_bridge::throw_dom_exception;
|
||||
use crate::runtime::{RendererDragData, RendererDraggedDirectory, RendererDraggedFile};
|
||||
use crate::runtime::{
|
||||
RendererDragData, RendererDragDataItem, RendererDraggedDirectory, RendererDraggedFile,
|
||||
};
|
||||
use crate::util::{get_private_object, get_private_value, set_private_value};
|
||||
use crate::web_api_interfaces;
|
||||
use crate::webidl;
|
||||
use moli_file_api::data_transfer::{
|
||||
DataTransferItemSummary, child_entry_full_path, clear_data_removes_item,
|
||||
contains_string_item_type, data_transfer_types_from_items, drag_effect_allowed_from_mask,
|
||||
normalize_drag_data_type, valid_drop_effect, valid_effect_allowed,
|
||||
drag_operations_mask_from_effect_allowed, normalize_drag_data_type, valid_drop_effect,
|
||||
valid_effect_allowed,
|
||||
};
|
||||
use moli_webapi_declare::{WebApiFunctionTemplate, WebApiObject};
|
||||
|
||||
@@ -474,6 +477,46 @@ pub(crate) fn native_set_drag_drop_effect<'s>(
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads the string items and `effectAllowed` that `dragstart` listeners left
|
||||
/// in the shared store, in the shape CDP reports as `Input.DragData`.
|
||||
pub(crate) fn read_string_drag_data<'s>(
|
||||
scope: &mut v8::PinScope<'s, '_>,
|
||||
store: &NativeDragDataStore,
|
||||
) -> (Vec<RendererDragDataItem>, i32) {
|
||||
let backing = store.local(scope);
|
||||
let mask = drag_operations_mask_from_effect_allowed(
|
||||
&private_string_property(scope, backing, DATA_TRANSFER_EFFECT_ALLOWED_SLOT)
|
||||
.unwrap_or_else(|| "uninitialized".to_owned()),
|
||||
);
|
||||
let mut items = Vec::new();
|
||||
let Some(item_array) =
|
||||
private_property_as_array(scope, backing, DATA_TRANSFER_STORE_ITEMS_SLOT)
|
||||
else {
|
||||
return (items, mask);
|
||||
};
|
||||
for index in 0..item_array.length() {
|
||||
let Some(item) = item_array
|
||||
.get_index(scope, index)
|
||||
.and_then(|value| v8::Local::<v8::Object>::try_from(value).ok())
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
if item_kind(scope, item).as_deref() != Some("string") {
|
||||
continue;
|
||||
}
|
||||
items.push(RendererDragDataItem {
|
||||
mime_type: item_type(scope, item).unwrap_or_default(),
|
||||
// Native interception serializes the store, independently of the
|
||||
// access granted to the last JavaScript event listener.
|
||||
data: private_string_property(scope, item, DATA_TRANSFER_ITEM_STRING_SLOT)
|
||||
.unwrap_or_default(),
|
||||
title: None,
|
||||
base_url: None,
|
||||
});
|
||||
}
|
||||
(items, mask)
|
||||
}
|
||||
|
||||
fn private_string_property<'s>(
|
||||
scope: &mut v8::PinScope<'s, '_>,
|
||||
object: v8::Local<'s, v8::Object>,
|
||||
|
||||
@@ -8,10 +8,11 @@ mod validation;
|
||||
|
||||
pub(super) use exceptions::throw_named_dom_exception;
|
||||
pub(super) use object::{
|
||||
RANGE_WRAPPER_INTERNAL_FIELD_COUNT, current_document_object, initialize_range_object,
|
||||
live_range_from_static_range, native_range_boundary_handles, native_range_boundary_point,
|
||||
new_range_for_document, range_boundary_container_object, range_boundary_offset,
|
||||
range_is_collapsed, range_native_record_handle, set_range_boundary, static_range_snapshot,
|
||||
RANGE_WRAPPER_INTERNAL_FIELD_COUNT, RangeBoundaryHandle, RangeBoundaryHandles,
|
||||
current_document_object, initialize_range_object, live_range_from_static_range,
|
||||
native_range_boundary_handles, native_range_boundary_point, new_range_for_document,
|
||||
range_boundary_container_object, range_boundary_offset, range_is_collapsed,
|
||||
range_native_record_handle, set_range_boundary, static_range_snapshot,
|
||||
};
|
||||
pub(super) use static_storage::{StaticRangeInitMembers, initialize_static_range_object};
|
||||
pub(super) use validation::{
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
use super::super::util::node_wrapper_from_handle;
|
||||
use super::range::{
|
||||
RangeBoundarySide, native_range_boundary_handles, native_range_boundary_point,
|
||||
range_boundary_container_object, range_boundary_offset, range_is_collapsed, set_range_boundary,
|
||||
RangeBoundaryHandle, RangeBoundaryHandles, RangeBoundarySide, native_range_boundary_handles,
|
||||
native_range_boundary_point, range_boundary_container_object, range_boundary_offset,
|
||||
range_is_collapsed, set_range_boundary,
|
||||
};
|
||||
use super::*;
|
||||
use crate::document_runtime::DomHandle;
|
||||
@@ -51,3 +52,53 @@ pub(in crate::context_bootstrap::range_algorithms) use ordering::common_ancestor
|
||||
pub(in crate::context_bootstrap::range_algorithms) use mutation::{
|
||||
range_insert_node_handle_at_boundary, range_select_node_handle,
|
||||
};
|
||||
|
||||
/// Resolve browser-initiated selection drags through the same native Range
|
||||
/// geometry and visible-text serializer used by the Selection Web API.
|
||||
pub(crate) fn document_selection_drag_text_at_point(
|
||||
scope: &mut v8::PinScope<'_, '_>,
|
||||
runtime_ptr: *mut crate::native_bridge::JsContextHost,
|
||||
handle: DomHandle,
|
||||
x: f64,
|
||||
y: f64,
|
||||
) -> Option<String> {
|
||||
let runtime = unsafe { &*runtime_ptr };
|
||||
let host = runtime.dom_host();
|
||||
let document = host.owner_document_handle(handle)?;
|
||||
let selection = runtime.document_selection_snapshot(document)?;
|
||||
let (mut start, mut end) = (selection.start, selection.end);
|
||||
match crate::range_boundary::point_order_in_dom(
|
||||
host,
|
||||
start.container,
|
||||
start.offset,
|
||||
end.container,
|
||||
end.offset,
|
||||
)? {
|
||||
std::cmp::Ordering::Equal => return None,
|
||||
std::cmp::Ordering::Greater => std::mem::swap(&mut start, &mut end),
|
||||
std::cmp::Ordering::Less => {}
|
||||
}
|
||||
let boundaries = RangeBoundaryHandles {
|
||||
start: RangeBoundaryHandle {
|
||||
container: start.container,
|
||||
offset: start.offset,
|
||||
},
|
||||
end: RangeBoundaryHandle {
|
||||
container: end.container,
|
||||
offset: end.offset,
|
||||
},
|
||||
};
|
||||
let rects = geometry::native_range_client_rects_for_boundaries(scope, boundaries)?.ok()?;
|
||||
if !rects.iter().any(|rect| {
|
||||
rect.width > 0.0
|
||||
&& rect.height > 0.0
|
||||
&& x >= rect.left
|
||||
&& x <= rect.right
|
||||
&& y >= rect.top
|
||||
&& y <= rect.bottom
|
||||
}) {
|
||||
return None;
|
||||
}
|
||||
text::range_selection_string_contents_for_boundaries(scope, boundaries)
|
||||
.filter(|text| !text.is_empty())
|
||||
}
|
||||
|
||||
@@ -11,6 +11,13 @@ fn native_range_client_rects<'s>(
|
||||
range: v8::Local<'s, v8::Object>,
|
||||
) -> Option<Result<Vec<ClientRect>, moli_layout::LayoutError>> {
|
||||
let boundaries = native_range_boundary_handles(scope, range)?;
|
||||
native_range_client_rects_for_boundaries(scope, boundaries)
|
||||
}
|
||||
|
||||
pub(super) fn native_range_client_rects_for_boundaries(
|
||||
scope: &mut v8::PinScope<'_, '_>,
|
||||
boundaries: RangeBoundaryHandles,
|
||||
) -> Option<Result<Vec<ClientRect>, moli_layout::LayoutError>> {
|
||||
let host_ptr = context_host_ptr_from_global_bridge(scope)?;
|
||||
let host = unsafe { &*host_ptr };
|
||||
if !host.layout_policy().uses_real_layout() {
|
||||
|
||||
@@ -52,6 +52,31 @@ pub(in crate::context_bootstrap) fn range_selection_string_contents<'s>(
|
||||
range_boundary_offset(scope, range, RangeBoundarySide::End) as u32,
|
||||
)
|
||||
};
|
||||
range_selection_string_contents_for_boundaries(
|
||||
scope,
|
||||
RangeBoundaryHandles {
|
||||
start: RangeBoundaryHandle {
|
||||
container: start,
|
||||
offset: start_offset,
|
||||
},
|
||||
end: RangeBoundaryHandle {
|
||||
container: end,
|
||||
offset: end_offset,
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn range_selection_string_contents_for_boundaries(
|
||||
scope: &mut v8::PinScope<'_, '_>,
|
||||
boundaries: RangeBoundaryHandles,
|
||||
) -> Option<String> {
|
||||
let (start, start_offset, end, end_offset) = (
|
||||
boundaries.start.container,
|
||||
boundaries.start.offset,
|
||||
boundaries.end.container,
|
||||
boundaries.end.offset,
|
||||
);
|
||||
if start == end && start_offset == end_offset {
|
||||
return Some(String::new());
|
||||
}
|
||||
|
||||
@@ -113,6 +113,7 @@ impl RendererPageCommand {
|
||||
| Self::DispatchMouseEventAtPoint { .. }
|
||||
| Self::DispatchTouchEvent { .. }
|
||||
| Self::DispatchDragEventAtPoint { .. }
|
||||
| Self::CancelDragging
|
||||
| Self::ClearActiveDragDataTransfer
|
||||
| Self::InsertTextIntoActiveControl(..)
|
||||
| Self::DispatchKeyEvent { .. }
|
||||
|
||||
+10
-10
@@ -277,16 +277,16 @@ pub use runtime::{
|
||||
RendererGeometryQuad, RendererInputDispatchOutcome, RendererInspectorCommandEnvelope,
|
||||
RendererInspectorCommandRoute, RendererInspectorIngressTicket,
|
||||
RendererInspectorProtocolConfiguration, RendererInspectorProtocolConfigurationCommand,
|
||||
RendererInspectorSessionRestoreSnapshot, RendererJavaScriptDialogCompletion,
|
||||
RendererJavaScriptDialogId, RendererJavaScriptDialogResult, RendererJavaScriptDialogSource,
|
||||
RendererLayoutMetrics, RendererLifecycleDecider, RendererLifecycleDecision,
|
||||
RendererLifecycleEpoch, RendererLifecycleEventStamp, RendererLifecycleSnapshot,
|
||||
RendererLifecycleStartReason, RendererLifecycleTerminationStamp, RendererMainDocumentCommit,
|
||||
RendererMainDocumentResource, RendererMoliDomMemoryDiagnostics, RendererMoliMemoryDiagnostics,
|
||||
RendererMoliMemoryScopeDiagnostics, RendererMoliRuntimeMemoryDiagnostics,
|
||||
RendererNativeCommandReadyResponse, RendererNativeCommandResponseGuard,
|
||||
RendererNativeCommandTerminal, RendererNativeOperation, RendererNativeOperationStep,
|
||||
RendererNativeProtocolError, RendererNativeProtocolNotification,
|
||||
RendererInspectorSessionRestoreSnapshot, RendererInterceptedDrag,
|
||||
RendererJavaScriptDialogCompletion, RendererJavaScriptDialogId, RendererJavaScriptDialogResult,
|
||||
RendererJavaScriptDialogSource, RendererLayoutMetrics, RendererLifecycleDecider,
|
||||
RendererLifecycleDecision, RendererLifecycleEpoch, RendererLifecycleEventStamp,
|
||||
RendererLifecycleSnapshot, RendererLifecycleStartReason, RendererLifecycleTerminationStamp,
|
||||
RendererMainDocumentCommit, RendererMainDocumentResource, RendererMoliDomMemoryDiagnostics,
|
||||
RendererMoliMemoryDiagnostics, RendererMoliMemoryScopeDiagnostics,
|
||||
RendererMoliRuntimeMemoryDiagnostics, RendererNativeCommandReadyResponse,
|
||||
RendererNativeCommandResponseGuard, RendererNativeCommandTerminal, RendererNativeOperation,
|
||||
RendererNativeOperationStep, RendererNativeProtocolError, RendererNativeProtocolNotification,
|
||||
RendererNativeProtocolResponse, RendererNativeProtocolStateUpdate, RendererNavigationInitiator,
|
||||
RendererOutputCursor, RendererOutputFence, RendererOutputFenceLeaseId, RendererOutputItem,
|
||||
RendererOutputPublication, RendererOutputPublicationOrdering, RendererOutputRecord,
|
||||
|
||||
@@ -272,11 +272,12 @@ pub(in crate::native_bridge::element) use events::construct_event;
|
||||
pub(crate) use events::construct_focus_event;
|
||||
pub(crate) use events::{
|
||||
InputEventCoordinates, NodePublicEventDispatchOutcome, TextEditInputType, TouchEventPoint,
|
||||
construct_command_event, construct_drag_event, construct_drop_input_event,
|
||||
construct_input_event, construct_interest_event, construct_keyboard_event,
|
||||
construct_mouse_event_with_detail_and_modifiers, construct_mouse_event_with_modifiers,
|
||||
construct_mouse_event_with_related_target_and_modifiers, construct_pointer_event,
|
||||
construct_pointer_event_with_modifiers, construct_pointer_event_with_related_target,
|
||||
construct_command_event, construct_drag_event, construct_drag_event_with_related_target,
|
||||
construct_drop_input_event, construct_input_event, construct_interest_event,
|
||||
construct_keyboard_event, construct_mouse_event_with_detail_and_modifiers,
|
||||
construct_mouse_event_with_modifiers, construct_mouse_event_with_related_target_and_modifiers,
|
||||
construct_pointer_event, construct_pointer_event_with_modifiers,
|
||||
construct_pointer_event_with_related_target,
|
||||
construct_pointer_event_with_related_target_and_modifiers, construct_simple_event,
|
||||
construct_submit_event, construct_toggle_event, construct_touch_event,
|
||||
construct_touch_event_with_points, construct_wheel_event, dispatch_beforeinput,
|
||||
|
||||
@@ -755,6 +755,7 @@ fn click_handle_internal(
|
||||
triggered_top_level_navigation: false,
|
||||
pending_download: None,
|
||||
pending_file_chooser: None,
|
||||
intercepted_drag: None,
|
||||
};
|
||||
}
|
||||
if focus_behavior == ClickFocusBehavior::Apply
|
||||
@@ -974,6 +975,7 @@ fn click_handle_internal(
|
||||
triggered_top_level_navigation: false,
|
||||
pending_download: None,
|
||||
pending_file_chooser: None,
|
||||
intercepted_drag: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -990,6 +992,7 @@ fn input_dispatch_outcome_after_click(
|
||||
&& unsafe { &*runtime_ptr }.has_pending_location_navigation(),
|
||||
pending_download,
|
||||
pending_file_chooser,
|
||||
intercepted_drag: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,8 +6,8 @@ mod dispatch;
|
||||
pub(crate) use self::constructors::construct_focus_event;
|
||||
pub(crate) use self::constructors::{
|
||||
InputEventCoordinates, TextEditInputType, TouchEventPoint, construct_command_event,
|
||||
construct_drag_event, construct_drop_input_event, construct_input_event,
|
||||
construct_interest_event, construct_keyboard_event,
|
||||
construct_drag_event, construct_drag_event_with_related_target, construct_drop_input_event,
|
||||
construct_input_event, construct_interest_event, construct_keyboard_event,
|
||||
construct_mouse_event_with_detail_and_modifiers, construct_mouse_event_with_modifiers,
|
||||
construct_mouse_event_with_related_target_and_modifiers, construct_pointer_event,
|
||||
construct_pointer_event_with_modifiers, construct_pointer_event_with_related_target,
|
||||
|
||||
@@ -68,6 +68,7 @@ struct DragEventInitDeclaration<'scope> {
|
||||
meta_key: bool,
|
||||
shift_key: bool,
|
||||
data_transfer: v8::Local<'scope, v8::Value>,
|
||||
related_target: Option<v8::Local<'scope, v8::Value>>,
|
||||
}
|
||||
|
||||
#[derive(WebApiObject)]
|
||||
@@ -637,7 +638,11 @@ pub(crate) fn construct_pointer_event_with_related_target_and_modifiers<'s>(
|
||||
let modifier_keys = modifier_key_state(modifiers);
|
||||
let init = PointerEventInitDeclaration::new(
|
||||
!is_enter_or_leave,
|
||||
!is_enter_or_leave,
|
||||
!is_enter_or_leave
|
||||
&& !matches!(
|
||||
event_type,
|
||||
"pointercancel" | "gotpointercapture" | "lostpointercapture"
|
||||
),
|
||||
true,
|
||||
coordinates.screen_x,
|
||||
coordinates.screen_y,
|
||||
@@ -672,11 +677,29 @@ pub(crate) fn construct_drag_event<'s>(
|
||||
coordinates: InputEventCoordinates,
|
||||
data_transfer: v8::Local<'s, v8::Value>,
|
||||
modifiers: u8,
|
||||
) -> Option<v8::Local<'s, v8::Object>> {
|
||||
construct_drag_event_with_related_target(
|
||||
scope,
|
||||
event_type,
|
||||
coordinates,
|
||||
data_transfer,
|
||||
None,
|
||||
modifiers,
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn construct_drag_event_with_related_target<'s>(
|
||||
scope: &mut v8::PinScope<'s, '_>,
|
||||
event_type: &str,
|
||||
coordinates: InputEventCoordinates,
|
||||
data_transfer: v8::Local<'s, v8::Value>,
|
||||
related_target: Option<v8::Local<'s, v8::Value>>,
|
||||
modifiers: u8,
|
||||
) -> Option<v8::Local<'s, v8::Object>> {
|
||||
let modifier_keys = modifier_key_state(modifiers);
|
||||
let init = DragEventInitDeclaration::new(
|
||||
true,
|
||||
true,
|
||||
!matches!(event_type, "dragleave" | "dragend"),
|
||||
true,
|
||||
coordinates.screen_x,
|
||||
coordinates.screen_y,
|
||||
@@ -687,6 +710,7 @@ pub(crate) fn construct_drag_event<'s>(
|
||||
modifier_keys.meta,
|
||||
modifier_keys.shift,
|
||||
data_transfer,
|
||||
related_target,
|
||||
)
|
||||
.bind(scope)
|
||||
.ok()?;
|
||||
|
||||
@@ -128,6 +128,7 @@ fn perform_implicit_form_submission(
|
||||
&& unsafe { &*runtime_ptr }.has_pending_location_navigation(),
|
||||
pending_download: None,
|
||||
pending_file_chooser: None,
|
||||
intercepted_drag: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -370,13 +370,13 @@ pub use self::page_surface::{
|
||||
RendererDragDataItem, RendererDraggedDirectory, RendererDraggedFile, RendererGeometryQuad,
|
||||
RendererInputDispatchOutcome, RendererInspectorProtocolConfiguration,
|
||||
RendererInspectorProtocolConfigurationCommand, RendererInspectorSessionRestoreSnapshot,
|
||||
RendererJavaScriptDialogId, RendererJavaScriptDialogSource, RendererLayoutMetrics,
|
||||
RendererMainDocumentCommit, RendererMoliDomMemoryDiagnostics, RendererMoliMemoryDiagnostics,
|
||||
RendererMoliMemoryScopeDiagnostics, RendererMoliRuntimeMemoryDiagnostics,
|
||||
RendererNavigationInitiator, RendererPageCommand, RendererPageCommandPostResponseContinuation,
|
||||
RendererPageCookieFacadeSnapshotReply, RendererPageCreationDiagnostics,
|
||||
RendererPageDiagnosticsSnapshot, RendererPageDumpFormat, RendererPageDumpOptions,
|
||||
RendererPageDumpStripOptions, RendererPageReply, RendererPageView,
|
||||
RendererInterceptedDrag, RendererJavaScriptDialogId, RendererJavaScriptDialogSource,
|
||||
RendererLayoutMetrics, RendererMainDocumentCommit, RendererMoliDomMemoryDiagnostics,
|
||||
RendererMoliMemoryDiagnostics, RendererMoliMemoryScopeDiagnostics,
|
||||
RendererMoliRuntimeMemoryDiagnostics, RendererNavigationInitiator, RendererPageCommand,
|
||||
RendererPageCommandPostResponseContinuation, RendererPageCookieFacadeSnapshotReply,
|
||||
RendererPageCreationDiagnostics, RendererPageDiagnosticsSnapshot, RendererPageDumpFormat,
|
||||
RendererPageDumpOptions, RendererPageDumpStripOptions, RendererPageReply, RendererPageView,
|
||||
RendererPendingDownloadActivation, RendererPendingDownloadResponse,
|
||||
RendererPendingFileChooserActivation, RendererPendingJavaScriptDialog,
|
||||
RendererPendingPopupActivation, RendererPendingSameDocumentNavigation,
|
||||
|
||||
@@ -122,6 +122,7 @@ impl PageVm {
|
||||
delta_y,
|
||||
pointer,
|
||||
modifiers,
|
||||
intercept_drags: _,
|
||||
} if event_name == "wheel" => self
|
||||
.queue_wheel_event(
|
||||
x,
|
||||
@@ -146,8 +147,9 @@ impl PageVm {
|
||||
delta_y,
|
||||
pointer,
|
||||
modifiers,
|
||||
} => self
|
||||
.dispatch_mouse_event_at_point_with_pointer(
|
||||
intercept_drags,
|
||||
} => {
|
||||
self.dispatch_mouse_event_at_point_with_pointer(
|
||||
x,
|
||||
y,
|
||||
&event_name,
|
||||
@@ -158,8 +160,10 @@ impl PageVm {
|
||||
delta_y,
|
||||
pointer,
|
||||
modifiers,
|
||||
intercept_drags,
|
||||
)
|
||||
.map(RendererPageReply::InputDispatchOutcome),
|
||||
.map(RendererPageReply::InputDispatchOutcome)
|
||||
}
|
||||
RendererPageCommand::DispatchTouchEvent {
|
||||
points,
|
||||
event_name,
|
||||
@@ -180,6 +184,9 @@ impl PageVm {
|
||||
self.clear_active_drag_data_transfer()?;
|
||||
Ok(RendererPageReply::Unit)
|
||||
}
|
||||
RendererPageCommand::CancelDragging => self
|
||||
.cancel_dragging()
|
||||
.map(RendererPageReply::InputDispatchOutcome),
|
||||
RendererPageCommand::MutateDocumentNodeAttribute {
|
||||
reference,
|
||||
mutation,
|
||||
|
||||
@@ -594,6 +594,7 @@ impl PageVm {
|
||||
delta_y: f64,
|
||||
pointer: RendererPointerEventProperties,
|
||||
modifiers: u8,
|
||||
intercept_drags: bool,
|
||||
) -> Result<RendererInputDispatchOutcome> {
|
||||
let mut outcome = self
|
||||
.vm_mut()
|
||||
@@ -608,6 +609,7 @@ impl PageVm {
|
||||
delta_y,
|
||||
pointer,
|
||||
modifiers,
|
||||
intercept_drags,
|
||||
)?;
|
||||
self.bind_input_dispatch_file_chooser_backend_node_id(&mut outcome);
|
||||
Ok(outcome)
|
||||
@@ -626,6 +628,12 @@ impl PageVm {
|
||||
Ok(outcome)
|
||||
}
|
||||
|
||||
pub(crate) fn cancel_dragging(&mut self) -> Result<RendererInputDispatchOutcome> {
|
||||
let mut outcome = self.vm_mut().cancel_dragging()?;
|
||||
self.bind_input_dispatch_file_chooser_backend_node_id(&mut outcome);
|
||||
Ok(outcome)
|
||||
}
|
||||
|
||||
pub(crate) fn dispatch_drag_event_at_point(
|
||||
&mut self,
|
||||
x: f64,
|
||||
|
||||
@@ -479,12 +479,26 @@ pub struct RendererDragData {
|
||||
pub drag_operations_mask: i32,
|
||||
}
|
||||
|
||||
/// String data of a drag that started while the client intercepts drags.
|
||||
///
|
||||
/// Files and directories are not carried: only string items set through
|
||||
/// `DataTransfer.setData` during `dragstart` leave the renderer.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct RendererInterceptedDrag {
|
||||
pub items: Vec<RendererDragDataItem>,
|
||||
pub drag_operations_mask: i32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Clone, PartialEq, Eq)]
|
||||
pub struct RendererInputDispatchOutcome {
|
||||
pub handled: bool,
|
||||
pub triggered_top_level_navigation: bool,
|
||||
pub pending_download: Option<RendererPendingDownloadActivation>,
|
||||
pub pending_file_chooser: Option<RendererPendingFileChooserActivation>,
|
||||
/// Set when `dragstart` ran uncanceled and the command asked to intercept
|
||||
/// drags. The renderer then leaves `dragenter`, `dragover` and `drop` to
|
||||
/// `RendererPageCommand::DispatchDragEventAtPoint`.
|
||||
pub intercepted_drag: Option<RendererInterceptedDrag>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
@@ -4824,6 +4838,8 @@ pub enum RendererPageCommand {
|
||||
delta_y: f64,
|
||||
pointer: RendererPointerEventProperties,
|
||||
modifiers: u8,
|
||||
/// `Input.setInterceptDrags` state of the issuing DevTools session.
|
||||
intercept_drags: bool,
|
||||
},
|
||||
DispatchTouchEvent {
|
||||
points: Vec<RendererTouchPoint>,
|
||||
@@ -4838,6 +4854,7 @@ pub enum RendererPageCommand {
|
||||
modifiers: u8,
|
||||
},
|
||||
ClearActiveDragDataTransfer,
|
||||
CancelDragging,
|
||||
InsertTextIntoActiveControl(String),
|
||||
DispatchKeyEvent {
|
||||
event_name: String,
|
||||
|
||||
@@ -111,6 +111,7 @@ impl PageVm {
|
||||
triggered_top_level_navigation: false,
|
||||
pending_download: None,
|
||||
pending_file_chooser: None,
|
||||
intercepted_drag: None,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -214,6 +215,7 @@ impl PageVm {
|
||||
event.delta_y,
|
||||
event.pointer,
|
||||
event.modifiers,
|
||||
false,
|
||||
);
|
||||
if let Err(error) = result
|
||||
&& first_error.is_none()
|
||||
|
||||
@@ -29,6 +29,7 @@ const dropTarget = document.createElement("div");
|
||||
dropTarget.id = "directory-reader-drop-target";
|
||||
dropTarget.style.width = "100px";
|
||||
dropTarget.style.height = "100px";
|
||||
dropTarget.addEventListener("dragover", event => event.preventDefault());
|
||||
dropTarget.addEventListener("drop", event => {
|
||||
globalThis.__directoryEntry =
|
||||
event.dataTransfer.items[0].webkitGetAsEntry();
|
||||
@@ -39,14 +40,14 @@ document.body.appendChild(dropTarget);
|
||||
"#,
|
||||
)?;
|
||||
page_vm.vm_mut().publish_layout_for_test()?;
|
||||
let outcome = page_vm.dispatch_drag_event_at_point(
|
||||
10.0,
|
||||
10.0,
|
||||
"drop",
|
||||
directory_drag_data(file_count),
|
||||
0,
|
||||
)?;
|
||||
assert!(outcome.handled, "the directory drop fixture must dispatch");
|
||||
let data = directory_drag_data(file_count);
|
||||
for event in ["dragenter", "dragover", "drop"] {
|
||||
let outcome = page_vm.dispatch_drag_event_at_point(10.0, 10.0, event, data.clone(), 0)?;
|
||||
assert!(
|
||||
outcome.handled,
|
||||
"the accepted directory drag fixture must dispatch {event}"
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
page_vm.vm_mut().eval(
|
||||
"__directoryEntry.isDirectory && \
|
||||
|
||||
@@ -448,6 +448,7 @@ window.parserPendingPointerCapture = installPendingPointerCaptureTarget('parser-
|
||||
0.0,
|
||||
RendererPointerEventProperties::default(),
|
||||
0,
|
||||
false,
|
||||
)
|
||||
.expect("JS baseline mousedown should set then clear pending capture");
|
||||
page_vm
|
||||
@@ -462,6 +463,7 @@ window.parserPendingPointerCapture = installPendingPointerCaptureTarget('parser-
|
||||
0.0,
|
||||
RendererPointerEventProperties::default(),
|
||||
0,
|
||||
false,
|
||||
)
|
||||
.expect("JS baseline mouseup should not use removed pending capture");
|
||||
let js_log = page_vm
|
||||
@@ -486,6 +488,7 @@ window.parserPendingPointerCapture = installPendingPointerCaptureTarget('parser-
|
||||
0.0,
|
||||
RendererPointerEventProperties::default(),
|
||||
0,
|
||||
false,
|
||||
)
|
||||
.expect("parser baseline mousedown should set pending capture");
|
||||
|
||||
@@ -527,6 +530,7 @@ window.parserPendingPointerCapture = installPendingPointerCaptureTarget('parser-
|
||||
0.0,
|
||||
RendererPointerEventProperties::default(),
|
||||
0,
|
||||
false,
|
||||
)
|
||||
.expect("parser baseline mouseup should not use removed pending capture");
|
||||
let parser_log = page_vm
|
||||
@@ -618,6 +622,7 @@ window.parserPendingPointerCaptureReparent =
|
||||
0.0,
|
||||
RendererPointerEventProperties::default(),
|
||||
0,
|
||||
false,
|
||||
)
|
||||
.expect("JS baseline mousedown should set then clear pending capture by reparent");
|
||||
page_vm
|
||||
@@ -632,6 +637,7 @@ window.parserPendingPointerCaptureReparent =
|
||||
0.0,
|
||||
RendererPointerEventProperties::default(),
|
||||
0,
|
||||
false,
|
||||
)
|
||||
.expect("JS baseline mouseup should not use reparented pending capture");
|
||||
let js_log = page_vm
|
||||
@@ -656,6 +662,7 @@ window.parserPendingPointerCaptureReparent =
|
||||
0.0,
|
||||
RendererPointerEventProperties::default(),
|
||||
0,
|
||||
false,
|
||||
)
|
||||
.expect("parser baseline mousedown should set pending capture");
|
||||
|
||||
@@ -715,6 +722,7 @@ window.parserPendingPointerCaptureReparent =
|
||||
0.0,
|
||||
RendererPointerEventProperties::default(),
|
||||
0,
|
||||
false,
|
||||
)
|
||||
.expect("parser baseline mouseup should not use reparented pending capture");
|
||||
let parser_log = page_vm
|
||||
|
||||
@@ -360,6 +360,7 @@ async fn dispatch_wheel_for_action_window_test(
|
||||
delta_y,
|
||||
pointer: RendererPointerEventProperties::default(),
|
||||
modifiers: 0,
|
||||
intercept_drags: false,
|
||||
})
|
||||
.await
|
||||
.expect("wheel action should enter the renderer action window");
|
||||
|
||||
@@ -72,6 +72,7 @@ async fn mouse_input_completion_publishes_handler_navigation_once() {
|
||||
delta_y: 0.0,
|
||||
pointer: RendererPointerEventProperties::default(),
|
||||
modifiers: 0,
|
||||
intercept_drags: false,
|
||||
})
|
||||
.await
|
||||
.expect("input command");
|
||||
@@ -219,6 +220,7 @@ async fn mouse_input_completion_preserves_standalone_and_child_navigation_scope(
|
||||
delta_y: 0.0,
|
||||
pointer: RendererPointerEventProperties::default(),
|
||||
modifiers: 0,
|
||||
intercept_drags: false,
|
||||
})
|
||||
.await
|
||||
.expect("scope input command");
|
||||
|
||||
@@ -20,6 +20,7 @@ async fn owner_loop_runs_file_reading_callback_without_retry_command() {
|
||||
r#"<!doctype html><body>
|
||||
<div id="drop" style="width: 100px; height: 100px">drop</div>
|
||||
<script>
|
||||
document.getElementById("drop").addEventListener("dragover", event => event.preventDefault());
|
||||
document.getElementById("drop").addEventListener("drop", event => {
|
||||
const reader =
|
||||
event.dataTransfer.items[0].webkitGetAsEntry().createReader();
|
||||
@@ -35,37 +36,40 @@ document.getElementById("drop").addEventListener("drop", event => {
|
||||
)
|
||||
.await;
|
||||
|
||||
let (reply, _) = page
|
||||
.run_async_command(RendererPageCommand::DispatchDragEventAtPoint {
|
||||
x: 10.0,
|
||||
y: 10.0,
|
||||
event_name: "drop".to_owned(),
|
||||
data: RendererDragData {
|
||||
items: Vec::new(),
|
||||
files: Vec::new(),
|
||||
directories: vec![RendererDraggedDirectory {
|
||||
name: "fixture".to_owned(),
|
||||
files: vec![RendererDraggedFile {
|
||||
bytes: b"body".to_vec(),
|
||||
mime_type: "text/plain".to_owned(),
|
||||
name: "entry.txt".to_owned(),
|
||||
last_modified: 1.0,
|
||||
}],
|
||||
directories: Vec::new(),
|
||||
}],
|
||||
drag_operations_mask: 1,
|
||||
},
|
||||
modifiers: 0,
|
||||
})
|
||||
.await
|
||||
.expect("directory drop command should run");
|
||||
assert!(
|
||||
matches!(
|
||||
reply,
|
||||
RendererPageReply::InputDispatchOutcome(ref outcome) if outcome.handled
|
||||
),
|
||||
"directory drop command should dispatch to the target"
|
||||
);
|
||||
let data = RendererDragData {
|
||||
items: Vec::new(),
|
||||
files: Vec::new(),
|
||||
directories: vec![RendererDraggedDirectory {
|
||||
name: "fixture".to_owned(),
|
||||
files: vec![RendererDraggedFile {
|
||||
bytes: b"body".to_vec(),
|
||||
mime_type: "text/plain".to_owned(),
|
||||
name: "entry.txt".to_owned(),
|
||||
last_modified: 1.0,
|
||||
}],
|
||||
directories: Vec::new(),
|
||||
}],
|
||||
drag_operations_mask: 1,
|
||||
};
|
||||
for event_name in ["dragenter", "dragover", "drop"] {
|
||||
let (reply, _) = page
|
||||
.run_async_command(RendererPageCommand::DispatchDragEventAtPoint {
|
||||
x: 10.0,
|
||||
y: 10.0,
|
||||
event_name: event_name.to_owned(),
|
||||
data: data.clone(),
|
||||
modifiers: 0,
|
||||
})
|
||||
.await
|
||||
.expect("directory drop command should run");
|
||||
assert!(
|
||||
matches!(
|
||||
reply,
|
||||
RendererPageReply::InputDispatchOutcome(ref outcome) if outcome.handled
|
||||
),
|
||||
"directory drop command should dispatch to the target"
|
||||
);
|
||||
}
|
||||
|
||||
tokio::time::timeout(Duration::from_secs(2), callback_request_seen)
|
||||
.await
|
||||
|
||||
@@ -877,6 +877,7 @@ fn input_dispatch_outcome(handled: bool) -> RendererInputDispatchOutcome {
|
||||
triggered_top_level_navigation: false,
|
||||
pending_download: None,
|
||||
pending_file_chooser: None,
|
||||
intercepted_drag: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1043,6 +1044,7 @@ pub(super) struct ScriptVm {
|
||||
suppress_compat_mouse_events: bool,
|
||||
active_scrollbar_drag: Option<ActiveScrollbarDrag>,
|
||||
active_drag_session: Option<ActiveDragSession>,
|
||||
pending_drag_gesture: Option<PendingDragGesture>,
|
||||
promise_reject_dispatch: PromiseRejectDispatchSlot,
|
||||
next_internal_runtime_evaluate_call_id: i32,
|
||||
next_internal_frontend_inspector_call_id: i32,
|
||||
@@ -1113,9 +1115,28 @@ pub(super) struct ActiveTouchPoint {
|
||||
|
||||
pub(super) struct ActiveDragSession {
|
||||
pub data_store: crate::context_bootstrap::NativeDragDataStore,
|
||||
/// Chromium retains the source view for dragend, without restoring data access.
|
||||
pub source_transfer: Option<v8::Global<v8::Object>>,
|
||||
pub drop_effect: String,
|
||||
pub drop_allowed: bool,
|
||||
/// Element that received `dragstart`; `dragend` is dispatched here.
|
||||
/// `None` for sessions created from externally supplied drag data.
|
||||
pub source: Option<DomHandle>,
|
||||
/// The DevTools client asked to take over the drag, so mouse events no
|
||||
/// longer drive `dragover` and `drop`. `Input.dispatchDragEvent` does.
|
||||
pub intercepted: bool,
|
||||
pub target: Option<DomHandle>,
|
||||
pub x: f64,
|
||||
pub y: f64,
|
||||
pub modifiers: u8,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
pub(super) struct PendingDragGesture {
|
||||
pub handle: DomHandle,
|
||||
pub x: f64,
|
||||
pub y: f64,
|
||||
pub root_to_frame: moli_layout::LayoutTransform2D,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
@@ -2554,6 +2575,7 @@ impl ScriptVmDefaultWorldBootstrap {
|
||||
suppress_compat_mouse_events: false,
|
||||
active_scrollbar_drag: None,
|
||||
active_drag_session: None,
|
||||
pending_drag_gesture: None,
|
||||
promise_reject_dispatch,
|
||||
next_internal_runtime_evaluate_call_id: 1,
|
||||
next_internal_frontend_inspector_call_id: -1,
|
||||
|
||||
@@ -192,6 +192,7 @@ impl ScriptVm {
|
||||
0.0,
|
||||
RendererPointerEventProperties::default(),
|
||||
0,
|
||||
false,
|
||||
);
|
||||
let next = match dispatched {
|
||||
Ok(next) => next,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -48,7 +48,18 @@ pub(super) fn clear_input_dispatch_state(vm: &mut ScriptVm) {
|
||||
vm.active_touch_event_handle = None;
|
||||
vm.active_touch_point = None;
|
||||
vm.active_touch_points.clear();
|
||||
let _ = vm.clear_active_drag_data_transfer();
|
||||
// Target views were disabled when their dispatch ended. Retiring the
|
||||
// Document must also disable its protected source view without dragend.
|
||||
if let Some(session) = vm.active_drag_session.take()
|
||||
&& let Some(source) = session.source_transfer
|
||||
{
|
||||
let _ = vm.with_default_context_scope(|scope, _| {
|
||||
let transfer = v8::Local::new(scope, &source);
|
||||
crate::context_bootstrap::finish_drag_data_transfer_view(scope, transfer);
|
||||
Ok(())
|
||||
});
|
||||
}
|
||||
vm.pending_drag_gesture = None;
|
||||
vm.active_scrollbar_drag = None;
|
||||
vm.suppress_compat_mouse_events = false;
|
||||
vm._context_host.borrow_mut().clear_pointer_capture_state();
|
||||
|
||||
@@ -223,12 +223,13 @@ for(const type of ['dragenter','dragover','drop'])document.getElementById('targe
|
||||
serde_json::json!({
|
||||
"records":[
|
||||
["dragenter","copy","none","","",["text/plain","application/x-seed","Files"],[["string","text/plain"],["string","application/x-seed"],["file","application/octet-stream"]],[]],
|
||||
["dragenter","copy","none","","",entered_types,entered_items,[]],
|
||||
["dragover","copy","none","","",entered_types,entered_items,[]],
|
||||
["dragover","move","none","","",entered_types,entered_items,[]],
|
||||
["dragover","move","none","","",entered_types,entered_items,[]],
|
||||
["drop","move","none","drop-text","drop-metadata",["text/plain","application/x-drop","Files"],[["string","text/plain"],["string","application/x-drop"],["file","application/octet-stream"]],[["drop.bin","application/octet-stream",4,123]]]
|
||||
],
|
||||
"prior":[[],[["",0,0,0]],[["",0,0,0],["",0,0,0]],[["",0,0,0],["",0,0,0],["",0,0,0]]],
|
||||
"fileText":"drop","closed":[["",0,0,0],["",0,0,0],["",0,0,0],["",0,0,0]]
|
||||
"prior":[[],[["",0,0,0]],[["",0,0,0],["",0,0,0]],[["",0,0,0],["",0,0,0],["",0,0,0]],[["",0,0,0],["",0,0,0],["",0,0,0],["",0,0,0]]],
|
||||
"fileText":"drop","closed":[["",0,0,0],["",0,0,0],["",0,0,0],["",0,0,0],["",0,0,0]]
|
||||
}),
|
||||
"empty_enter={empty_enter}"
|
||||
);
|
||||
|
||||
+1
@@ -761,6 +761,7 @@ fn iframe_wheel_batch_reuses_one_composite_snapshot_for_every_scroll_step() {
|
||||
delta_y,
|
||||
crate::runtime::RendererPointerEventProperties::default(),
|
||||
0,
|
||||
false,
|
||||
)
|
||||
.expect("batched child wheel step should dispatch");
|
||||
}
|
||||
|
||||
@@ -1010,6 +1010,7 @@ fn data_transfer_directory_entries_use_private_slots_for_reflection_and_spoofing
|
||||
r#"
|
||||
(() => {
|
||||
const target = document.getElementById('drop');
|
||||
target.addEventListener('dragover', event => event.preventDefault());
|
||||
const internalNames = object => Object.getOwnPropertyNames(object)
|
||||
.filter(name => name.startsWith('__lmDataTransfer') || name.startsWith('__lmFileSystem'))
|
||||
.sort();
|
||||
@@ -1084,6 +1085,10 @@ fn data_transfer_directory_entries_use_private_slots_for_reflection_and_spoofing
|
||||
}],
|
||||
drag_operations_mask: 1,
|
||||
};
|
||||
for event_name in ["dragenter", "dragover"] {
|
||||
vm.dispatch_drag_event_at_point(10.0, 10.0, event_name, drag_data.clone(), 0)
|
||||
.expect("directory drag target should accept the drag");
|
||||
}
|
||||
vm.dispatch_drag_event_at_point(10.0, 10.0, "drop", drag_data, 0)
|
||||
.expect("directory drop should dispatch");
|
||||
|
||||
@@ -1463,7 +1468,7 @@ fn mouse_dragstart_bubbles_to_window_once() {
|
||||
|
||||
vm.dispatch_mouse_event_at_point(20.0, 20.0, "mousedown", 0, None, 0.0, 0.0)
|
||||
.expect("mousedown should dispatch");
|
||||
vm.dispatch_mouse_event_at_point(20.0, 20.0, "mousemove", 0, Some(1), 0.0, 0.0)
|
||||
vm.dispatch_mouse_event_at_point(30.0, 20.0, "mousemove", 0, Some(1), 0.0, 0.0)
|
||||
.expect("mousemove should start drag");
|
||||
|
||||
let result = vm
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -3792,6 +3792,7 @@ fn trusted_alt_click_downloads_an_anchor_without_a_download_attribute() {
|
||||
0.0,
|
||||
crate::runtime::RendererPointerEventProperties::default(),
|
||||
1,
|
||||
false,
|
||||
)
|
||||
.expect("alt mousedown should dispatch");
|
||||
let outcome = vm
|
||||
@@ -3806,6 +3807,7 @@ fn trusted_alt_click_downloads_an_anchor_without_a_download_attribute() {
|
||||
0.0,
|
||||
crate::runtime::RendererPointerEventProperties::default(),
|
||||
1,
|
||||
false,
|
||||
)
|
||||
.expect("alt mouseup should dispatch");
|
||||
|
||||
@@ -3852,6 +3854,7 @@ fn alt_input_does_not_turn_window_open_into_a_download_or_background_popup() {
|
||||
0.0,
|
||||
crate::runtime::RendererPointerEventProperties::default(),
|
||||
1,
|
||||
false,
|
||||
)
|
||||
.expect("Alt mousedown should dispatch");
|
||||
let outcome = vm
|
||||
@@ -3866,6 +3869,7 @@ fn alt_input_does_not_turn_window_open_into_a_download_or_background_popup() {
|
||||
0.0,
|
||||
crate::runtime::RendererPointerEventProperties::default(),
|
||||
1,
|
||||
false,
|
||||
)
|
||||
.expect("Alt mouseup should dispatch");
|
||||
|
||||
@@ -3921,6 +3925,7 @@ fn window_open_in_click_listener_uses_current_input_event() {
|
||||
0.0,
|
||||
crate::runtime::RendererPointerEventProperties::default(),
|
||||
platform_modifier,
|
||||
false,
|
||||
)
|
||||
.expect("new-tab-modified mousedown should dispatch");
|
||||
trusted
|
||||
@@ -3935,6 +3940,7 @@ fn window_open_in_click_listener_uses_current_input_event() {
|
||||
0.0,
|
||||
crate::runtime::RendererPointerEventProperties::default(),
|
||||
platform_modifier,
|
||||
false,
|
||||
)
|
||||
.expect("new-tab-modified mouseup should dispatch");
|
||||
let trusted_popups = trusted.take_pending_popup_activations();
|
||||
@@ -4024,6 +4030,7 @@ fn current_mouseup_input_covers_pointer_mouse_click_and_microtask_callbacks() {
|
||||
0.0,
|
||||
crate::runtime::RendererPointerEventProperties::default(),
|
||||
platform_modifier,
|
||||
false,
|
||||
)
|
||||
.expect("new-tab-modified mousedown should dispatch");
|
||||
vm.dispatch_mouse_event_at_point_with_pointer_and_modifiers(
|
||||
@@ -4037,6 +4044,7 @@ fn current_mouseup_input_covers_pointer_mouse_click_and_microtask_callbacks() {
|
||||
0.0,
|
||||
crate::runtime::RendererPointerEventProperties::default(),
|
||||
platform_modifier,
|
||||
false,
|
||||
)
|
||||
.expect("new-tab-modified mouseup should dispatch");
|
||||
|
||||
|
||||
@@ -2355,7 +2355,7 @@ wait_until = "load"
|
||||
timeout_ms = 5000
|
||||
suite = "smoke"
|
||||
tags = ["fileapi", "drag-and-drop", "datatransfer", "events", "input", "actions"]
|
||||
notes = "Manual compat port for renderer-driven drag lifecycle behavior: manifest dispatch-drag actions synthesize dragenter/dragover/drop, use independent protected/readonly DataTransfer views, ignore listener payload mutations, and run the text input drop default action when drop is not canceled."
|
||||
notes = "Manual compat port for renderer-driven drag lifecycle behavior: manifest dispatch-drag actions synthesize dragenter/dragover/drop, give each target dispatch a separate DataTransfer view, keep data protected during dragenter/dragover so listener payload mutations are ignored, and let the user-agent insert the current drop command payload with a negotiated copy operation when the drop is accepted."
|
||||
|
||||
[[test.actions]]
|
||||
type = "dispatch-drag"
|
||||
@@ -2393,7 +2393,7 @@ wait_until = "load"
|
||||
timeout_ms = 5000
|
||||
suite = "smoke"
|
||||
tags = ["fileapi", "drag-and-drop", "datatransfer", "events", "input", "actions"]
|
||||
notes = "Manual compat port for renderer drag operation-mask mapping: source drag_operations_mask values are exposed through DataTransfer.effectAllowed while each event view starts with dropEffect=none; explicit dragover cancellation negotiates author-handled operations, target dropEffect overrides only run text drop defaults when allowed by the source mask, dispatch-drag modifiers populate DragEvent modifier-key state, and Ctrl/Meta/Shift/Alt modifier combinations select copy/move/link defaults with explicit link gestures taking precedence when the source mask allows them."
|
||||
notes = "Manual compat port for renderer drag operation-mask mapping: source drag_operations_mask values are exposed through DataTransfer.effectAllowed while every target event view starts with dropEffect none, explicitly canceled dragover requests only deliver drop and run text defaults when their dropEffect is allowed by the source mask, dispatch-drag modifiers populate DragEvent modifier-key state, and Ctrl/Meta/Shift/Alt modifier combinations select copy/move/link defaults with explicit link gestures taking precedence when the source mask allows them."
|
||||
|
||||
[[test.actions]]
|
||||
type = "dispatch-drag"
|
||||
@@ -2661,7 +2661,7 @@ wait_until = "load"
|
||||
timeout_ms = 5000
|
||||
suite = "smoke"
|
||||
tags = ["fileapi", "drag-and-drop", "datatransfer", "events", "input", "actions"]
|
||||
notes = "Manual compat port for dropEffect/effectAllowed default-action negotiation: an explicitly canceled dragover negotiates the target operation, which must be allowed by the source operation mask before text-control drop default insertion runs."
|
||||
notes = "Manual compat port for dropEffect/effectAllowed default-action negotiation: the target explicitly cancels dragover to request its dropEffect, which must be allowed by the source operation mask before drop delivery or text-control insertion runs."
|
||||
|
||||
[[test.actions]]
|
||||
type = "dispatch-drag"
|
||||
@@ -2802,7 +2802,7 @@ wait_until = "load"
|
||||
timeout_ms = 5000
|
||||
suite = "smoke"
|
||||
tags = ["fileapi", "drag-and-drop", "datatransfer", "events", "input", "selection", "contenteditable", "actions"]
|
||||
notes = "Manual compat port for inherited contenteditable text drop behavior: a drop targeting a descendant of a contenteditable host edits the nearest editing host, while a contenteditable=false descendant stops the inherited default editing action."
|
||||
notes = "Manual compat port for inherited contenteditable text drop behavior: a drop targeting a descendant of a contenteditable host edits the nearest editing host, while a contenteditable=false descendant rejects drop delivery and editing events without changing its text or selection."
|
||||
|
||||
[[test.actions]]
|
||||
type = "dispatch-drag"
|
||||
@@ -2856,6 +2856,10 @@ y = 10.0
|
||||
drag_operations_mask = 1
|
||||
items = [{ mime_type = "text/plain", data = "blocked-text" }]
|
||||
|
||||
[[test.actions]]
|
||||
type = "evaluate"
|
||||
expression = "__finishContenteditableInheritance()"
|
||||
|
||||
[[test]]
|
||||
id = "drag-contenteditable-range-basic"
|
||||
upstream = "web-platform-tests/html/editing/dnd/events/*"
|
||||
@@ -2934,7 +2938,7 @@ wait_until = "load"
|
||||
timeout_ms = 5000
|
||||
suite = "smoke"
|
||||
tags = ["fileapi", "drag-and-drop", "datatransfer", "events", "actions"]
|
||||
notes = "Manual compat port for renderer-driven drag cancellation: manifest dispatch-drag event=dragCancel clears the active DataTransfer session without dispatching a DOM dragCancel event, so a subsequent baseline drop uses its own payload; protected listener mutations cannot change the drag data."
|
||||
notes = "Manual compat port for renderer-driven drag cancellation: manifest dispatch-drag event=dragCancel clears the active DataTransfer session without dispatching a DOM dragCancel event, disables the canceled DataTransfer, and new dragenter/drop commands establish fresh payloads for a subsequent accepted drop. Renderer tests separately cover rejection of a late bare drop, since manifest drag actions require a handled command."
|
||||
|
||||
[[test.actions]]
|
||||
type = "dispatch-drag"
|
||||
@@ -2960,6 +2964,18 @@ y = 15.0
|
||||
drag_operations_mask = 1
|
||||
items = [{ mime_type = "text/plain", data = "ignored-cancel" }]
|
||||
|
||||
[[test.actions]]
|
||||
type = "evaluate"
|
||||
expression = "__assertCanceledDragState()"
|
||||
|
||||
[[test.actions]]
|
||||
type = "dispatch-drag"
|
||||
event = "dragenter"
|
||||
x = 15.0
|
||||
y = 15.0
|
||||
drag_operations_mask = 1
|
||||
items = [{ mime_type = "text/plain", data = "fresh-after-cancel" }]
|
||||
|
||||
[[test.actions]]
|
||||
type = "dispatch-drag"
|
||||
event = "drop"
|
||||
@@ -2982,6 +2998,14 @@ suite = "smoke"
|
||||
tags = ["fileapi", "drag-and-drop", "datatransfer", "events", "input", "file", "actions"]
|
||||
notes = "Manual compat port for renderer-driven file drop behavior: manifest dispatch-drag file payloads populate DataTransfer.files, uncanceled drops assign input[type=file].files and dispatch input/change with fakepath metadata, while preventDefault preserves the existing file selection and suppresses input/change."
|
||||
|
||||
[[test.actions]]
|
||||
type = "dispatch-drag"
|
||||
event = "dragenter"
|
||||
x = 15.0
|
||||
y = 15.0
|
||||
drag_operations_mask = 1
|
||||
files = [{ name = "drag-upload.txt", mime_type = "text/plain", text = "drag-upload", last_modified = 7 }]
|
||||
|
||||
[[test.actions]]
|
||||
type = "dispatch-drag"
|
||||
event = "drop"
|
||||
@@ -2994,6 +3018,14 @@ files = [{ name = "drag-upload.txt", mime_type = "text/plain", text = "drag-uplo
|
||||
type = "evaluate"
|
||||
expression = "__advanceDragFileDropPhase()"
|
||||
|
||||
[[test.actions]]
|
||||
type = "dispatch-drag"
|
||||
event = "dragenter"
|
||||
x = 15.0
|
||||
y = 15.0
|
||||
drag_operations_mask = 1
|
||||
files = [{ name = "blocked-upload.txt", mime_type = "text/plain", text = "blocked-upload", last_modified = 11 }]
|
||||
|
||||
[[test.actions]]
|
||||
type = "dispatch-drag"
|
||||
event = "drop"
|
||||
@@ -3002,6 +3034,10 @@ y = 15.0
|
||||
drag_operations_mask = 1
|
||||
files = [{ name = "blocked-upload.txt", mime_type = "text/plain", text = "blocked-upload", last_modified = 11 }]
|
||||
|
||||
[[test.actions]]
|
||||
type = "evaluate"
|
||||
expression = "__finishDragFileDrop()"
|
||||
|
||||
[[test]]
|
||||
id = "drag-file-multiple-basic"
|
||||
upstream = "web-platform-tests/html/editing/dnd/file/*"
|
||||
@@ -3014,7 +3050,18 @@ wait_until = "load"
|
||||
timeout_ms = 5000
|
||||
suite = "smoke"
|
||||
tags = ["fileapi", "drag-and-drop", "datatransfer", "events", "input", "file", "actions"]
|
||||
notes = "Manual compat port for file input multiple selection semantics under renderer-driven drops: a non-multiple input rejects multiple-file default editing without changing its selection, while an input with multiple=true preserves all dropped files and exposes fakepath metadata for the first selected file."
|
||||
notes = "Manual compat port for file input multiple selection semantics under renderer-driven drops: a non-multiple input rejects a multiple-file drag without drop/input/change events, while an input with multiple=true accepts all dropped files and exposes fakepath metadata for the first selected file. Payload Files are captured synchronously during drop and remain readable after the DataTransfer becomes disabled."
|
||||
|
||||
[[test.actions]]
|
||||
type = "dispatch-drag"
|
||||
event = "dragenter"
|
||||
x = 15.0
|
||||
y = 15.0
|
||||
drag_operations_mask = 1
|
||||
files = [
|
||||
{ name = "first.txt", mime_type = "text/plain", text = "first", last_modified = 1 },
|
||||
{ name = "second.txt", mime_type = "text/plain", text = "second", last_modified = 2 },
|
||||
]
|
||||
|
||||
[[test.actions]]
|
||||
type = "dispatch-drag"
|
||||
@@ -3031,6 +3078,17 @@ files = [
|
||||
type = "evaluate"
|
||||
expression = "__enableMultipleFileDropPhase()"
|
||||
|
||||
[[test.actions]]
|
||||
type = "dispatch-drag"
|
||||
event = "dragenter"
|
||||
x = 15.0
|
||||
y = 15.0
|
||||
drag_operations_mask = 1
|
||||
files = [
|
||||
{ name = "alpha.txt", mime_type = "text/plain", text = "alpha", last_modified = 3 },
|
||||
{ name = "beta.txt", mime_type = "text/plain", text = "beta", last_modified = 4 },
|
||||
]
|
||||
|
||||
[[test.actions]]
|
||||
type = "dispatch-drag"
|
||||
event = "drop"
|
||||
@@ -3042,6 +3100,10 @@ files = [
|
||||
{ name = "beta.txt", mime_type = "text/plain", text = "beta", last_modified = 4 },
|
||||
]
|
||||
|
||||
[[test.actions]]
|
||||
type = "evaluate"
|
||||
expression = "__finishMultipleFileDrop()"
|
||||
|
||||
[[test]]
|
||||
id = "formdata-idlharness"
|
||||
upstream = "web-platform-tests/interfaces/xhr.idl"
|
||||
|
||||
@@ -9,9 +9,22 @@ const target = document.getElementById("target");
|
||||
const events = [];
|
||||
let firstDataTransfer = null;
|
||||
|
||||
window.__assertCanceledDragState = function () {
|
||||
assert_array_equals(
|
||||
events,
|
||||
["dragenter::true", "dragover::false", "dragleave::false"],
|
||||
"cancellation leaves the completed drag lifecycle intact",
|
||||
);
|
||||
assert_equals(firstDataTransfer.getData("text"), "", "canceled data is unreadable");
|
||||
assert_equals(firstDataTransfer.types.length, 0, "canceled store exposes no types");
|
||||
assert_equals(firstDataTransfer.items.length, 0, "canceled store exposes no items");
|
||||
};
|
||||
|
||||
const dropped = new Promise(function (resolve) {
|
||||
target.addEventListener("dragenter", function (event) {
|
||||
firstDataTransfer = event.dataTransfer;
|
||||
if (firstDataTransfer === null) {
|
||||
firstDataTransfer = event.dataTransfer;
|
||||
}
|
||||
events.push(
|
||||
"dragenter:" +
|
||||
event.dataTransfer.getData("text") +
|
||||
@@ -21,6 +34,7 @@ const dropped = new Promise(function (resolve) {
|
||||
});
|
||||
|
||||
target.addEventListener("dragover", function (event) {
|
||||
event.preventDefault();
|
||||
event.dataTransfer.setData("text", "mutated-before-cancel");
|
||||
events.push(
|
||||
"dragover:" +
|
||||
@@ -34,6 +48,15 @@ const dropped = new Promise(function (resolve) {
|
||||
events.push("dragCancel:" + event.dataTransfer.getData("text"));
|
||||
});
|
||||
|
||||
target.addEventListener("dragleave", function (event) {
|
||||
events.push(
|
||||
"dragleave:" +
|
||||
event.dataTransfer.getData("text") +
|
||||
":" +
|
||||
(event.dataTransfer === firstDataTransfer),
|
||||
);
|
||||
});
|
||||
|
||||
target.addEventListener("drop", function (event) {
|
||||
events.push(
|
||||
"drop:" +
|
||||
@@ -52,12 +75,12 @@ promise_test(async function () {
|
||||
[
|
||||
"dragenter::true",
|
||||
"dragover::false",
|
||||
"dragleave::false",
|
||||
"dragenter::false",
|
||||
"dragover::false",
|
||||
"drop:fresh-after-cancel:false",
|
||||
],
|
||||
"dragCancel clears the session, protected mutations are ignored, and a fresh drop receives its own view",
|
||||
"dragCancel ends the store and rejects late drops until a new dragenter starts a fresh session",
|
||||
);
|
||||
assert_equals(firstDataTransfer.getData("text"), "", "completed enter view cannot expose its string data");
|
||||
assert_equals(firstDataTransfer.types.length, 0, "completed enter view exposes no types");
|
||||
assert_equals(firstDataTransfer.items.length, 0, "completed enter view exposes no items");
|
||||
}, "Input.dispatchDragEvent dragCancel clears the active drag session");
|
||||
</script>
|
||||
|
||||
+47
-43
@@ -11,6 +11,7 @@ const host = document.getElementById("host");
|
||||
const child = document.getElementById("child");
|
||||
const events = [];
|
||||
let phase = "allowed";
|
||||
let finishActions = null;
|
||||
|
||||
function selectedText() {
|
||||
const selection = getSelection();
|
||||
@@ -37,49 +38,52 @@ window.__advanceContenteditableInheritancePhase = function () {
|
||||
resetSelection();
|
||||
|
||||
const complete = new Promise(function (resolve) {
|
||||
host.addEventListener("drop", function (event) {
|
||||
events.push(
|
||||
phase +
|
||||
"-drop:" +
|
||||
event.target.id +
|
||||
":" +
|
||||
event.dataTransfer.getData("text") +
|
||||
":" +
|
||||
child.textContent +
|
||||
":" +
|
||||
selectedText(),
|
||||
);
|
||||
if (phase === "blocked") {
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
finishActions = resolve;
|
||||
});
|
||||
|
||||
host.addEventListener("beforeinput", function (event) {
|
||||
events.push(
|
||||
phase +
|
||||
"-beforeinput:" +
|
||||
event.target.id +
|
||||
":" +
|
||||
child.textContent +
|
||||
":" +
|
||||
selectedText(),
|
||||
);
|
||||
});
|
||||
window.__finishContenteditableInheritance = function () {
|
||||
finishActions();
|
||||
};
|
||||
|
||||
host.addEventListener("input", function (event) {
|
||||
const selection = getSelection();
|
||||
events.push(
|
||||
phase +
|
||||
"-input:" +
|
||||
event.target.id +
|
||||
":" +
|
||||
child.textContent +
|
||||
":" +
|
||||
selection.anchorOffset +
|
||||
":" +
|
||||
selection.focusOffset,
|
||||
);
|
||||
});
|
||||
host.addEventListener("drop", function (event) {
|
||||
events.push(
|
||||
phase +
|
||||
"-drop:" +
|
||||
event.target.id +
|
||||
":" +
|
||||
event.dataTransfer.getData("text") +
|
||||
":" +
|
||||
child.textContent +
|
||||
":" +
|
||||
selectedText(),
|
||||
);
|
||||
});
|
||||
|
||||
host.addEventListener("beforeinput", function (event) {
|
||||
events.push(
|
||||
phase +
|
||||
"-beforeinput:" +
|
||||
event.target.id +
|
||||
":" +
|
||||
child.textContent +
|
||||
":" +
|
||||
selectedText(),
|
||||
);
|
||||
});
|
||||
|
||||
host.addEventListener("input", function (event) {
|
||||
const selection = getSelection();
|
||||
events.push(
|
||||
phase +
|
||||
"-input:" +
|
||||
event.target.id +
|
||||
":" +
|
||||
child.textContent +
|
||||
":" +
|
||||
selection.anchorOffset +
|
||||
":" +
|
||||
selection.focusOffset,
|
||||
);
|
||||
});
|
||||
|
||||
promise_test(async function () {
|
||||
@@ -90,10 +94,10 @@ promise_test(async function () {
|
||||
"allowed-drop:child:allowed-text:hello world:world",
|
||||
"allowed-beforeinput:host:hello world:world",
|
||||
"allowed-input:host:hello allowed-text:18:18",
|
||||
"blocked-drop:child:blocked-text:hello world:world",
|
||||
],
|
||||
"contenteditable drop should inherit editing host and stop at contenteditable=false",
|
||||
"inherited contenteditable accepts the drop, while contenteditable=false rejects drop and editing events",
|
||||
);
|
||||
assert_equals(child.textContent, "hello world", "contenteditable=false child was not edited");
|
||||
assert_equals(selectedText(), "world", "rejected drop preserves the selection inside the non-editable child");
|
||||
}, "Input.dispatchDragEvent applies contenteditable inheritance for text drop defaults");
|
||||
</script>
|
||||
|
||||
@@ -82,13 +82,14 @@ promise_test(async function () {
|
||||
[
|
||||
"blocked-enter:none:copy",
|
||||
"blocked-over:move:copy",
|
||||
"blocked-drop:blocked-text:none:copy:hello world",
|
||||
"blocked-over:move:copy",
|
||||
"allowed-enter:none:copyMove",
|
||||
"allowed-over:move:copyMove",
|
||||
"allowed-over:move:copyMove",
|
||||
"allowed-drop:allowed-text:none:copyMove:hello world",
|
||||
"allowed-input:hello allowed-text:18:18",
|
||||
],
|
||||
"independent drop views start with none, while the negotiated operation gates default insertion",
|
||||
"an author-accepted drop is dispatched only when its operation is allowed by the source",
|
||||
);
|
||||
}, "Input.dispatchDragEvent gates text drop defaults on effect negotiation");
|
||||
</script>
|
||||
|
||||
@@ -8,16 +8,20 @@
|
||||
const input = document.getElementById("upload");
|
||||
const events = [];
|
||||
let phase = "default";
|
||||
let preventedDropResolve = null;
|
||||
let defaultDropFile = null;
|
||||
let preventedDropFile = null;
|
||||
let defaultDropTransfer = null;
|
||||
let preventedDropTransfer = null;
|
||||
let finishActions = null;
|
||||
|
||||
const preventedDrop = new Promise(function (resolve) {
|
||||
preventedDropResolve = resolve;
|
||||
const actionsFinished = new Promise(function (resolve) {
|
||||
finishActions = resolve;
|
||||
});
|
||||
|
||||
window.__finishDragFileDrop = function () {
|
||||
finishActions();
|
||||
};
|
||||
|
||||
window.__advanceDragFileDropPhase = function () {
|
||||
phase = "prevented";
|
||||
};
|
||||
@@ -44,7 +48,6 @@ input.addEventListener("drop", function (event) {
|
||||
":" +
|
||||
input.files[0].name,
|
||||
);
|
||||
setTimeout(preventedDropResolve, 0);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -83,7 +86,7 @@ input.addEventListener("change", function () {
|
||||
});
|
||||
|
||||
promise_test(async function () {
|
||||
await preventedDrop;
|
||||
await actionsFinished;
|
||||
assert_array_equals(
|
||||
events,
|
||||
[
|
||||
|
||||
@@ -8,15 +8,19 @@
|
||||
const input = document.getElementById("upload");
|
||||
const events = [];
|
||||
let phase = "single";
|
||||
let multipleChangeResolve = null;
|
||||
let multipleDropFiles = null;
|
||||
let singleSelection = null;
|
||||
let droppedTransfer = null;
|
||||
let finishActions = null;
|
||||
|
||||
const multipleChanged = new Promise(function (resolve) {
|
||||
multipleChangeResolve = resolve;
|
||||
const actionsFinished = new Promise(function (resolve) {
|
||||
finishActions = resolve;
|
||||
});
|
||||
|
||||
window.__finishMultipleFileDrop = function () {
|
||||
finishActions();
|
||||
};
|
||||
|
||||
window.__enableMultipleFileDropPhase = function () {
|
||||
singleSelection = Array.from(input.files).map(file => file.name);
|
||||
input.multiple = true;
|
||||
@@ -59,23 +63,19 @@ input.addEventListener("change", function () {
|
||||
":" +
|
||||
Array.from(input.files).map(file => file.name).join(","),
|
||||
);
|
||||
if (phase === "multiple") {
|
||||
multipleChangeResolve();
|
||||
}
|
||||
});
|
||||
|
||||
promise_test(async function () {
|
||||
await multipleChanged;
|
||||
assert_array_equals(singleSelection, [], "single-file input rejects multiple files without changing selection");
|
||||
await actionsFinished;
|
||||
assert_array_equals(singleSelection, [], "single-file input rejects a multiple-file drag without changing selection");
|
||||
assert_array_equals(
|
||||
events,
|
||||
[
|
||||
"single-drop:2:first.txt,second.txt:0",
|
||||
"multiple-drop:2:alpha.txt,beta.txt:0",
|
||||
"multiple-input:2:alpha.txt,beta.txt:C:\\fakepath\\alpha.txt",
|
||||
"multiple-change:2:alpha.txt,beta.txt",
|
||||
],
|
||||
"single-file default editing rejects multiple files; multiple input retains both and dispatches input/change",
|
||||
"single-file input rejects the drag before drop; multiple input accepts both files and emits input/change",
|
||||
);
|
||||
assert_equals(input.files.length, 2, "multiple input keeps both dropped files");
|
||||
assert_equals(input.files[0].name, "alpha.txt", "first multiple file");
|
||||
@@ -90,7 +90,7 @@ promise_test(async function () {
|
||||
assert_equals(await input.files[1].text(), "beta", "second multiple file bytes");
|
||||
assert_equals(await multipleDropFiles[0].text(), "alpha", "drop DataTransfer first file bytes");
|
||||
assert_equals(await multipleDropFiles[1].text(), "beta", "drop DataTransfer second file bytes");
|
||||
assert_equals(droppedTransfer.files.length, 0, "completed drop disables its DataTransfer while captured Files remain readable");
|
||||
assert_equals(droppedTransfer.files.length, 0, "completed drop disables the retained DataTransfer, while captured Files stay readable");
|
||||
assert_equals(input.value, "C:\\fakepath\\alpha.txt", "fakepath reflects first selected file");
|
||||
}, "Input.dispatchDragEvent respects file input multiple selection semantics");
|
||||
</script>
|
||||
|
||||
@@ -78,6 +78,8 @@ promise_test(async function () {
|
||||
"dragenter:true::none:copyMove",
|
||||
"dragover-before:false:",
|
||||
"dragover-after::move",
|
||||
"dragover-before:false:",
|
||||
"dragover-after::move",
|
||||
"drop:false:drop-text:none:hello world",
|
||||
"input:hello drop-text:15:15",
|
||||
],
|
||||
|
||||
@@ -6,24 +6,20 @@
|
||||
<script src="/wpt/resources/moli-wpt-adapter.js"></script>
|
||||
<script>
|
||||
const target = document.getElementById("target");
|
||||
const events = [];
|
||||
const records = Object.create(null);
|
||||
let phase = "none";
|
||||
let overrideDropEffect = null;
|
||||
let expectedDrops = 0;
|
||||
|
||||
function shouldRecordModifierKeys() {
|
||||
return phase === "modifiers" ||
|
||||
phase === "modifierSelection" ||
|
||||
phase === "modifierPrecedence";
|
||||
function phaseRecord() {
|
||||
if (!records[phase]) {
|
||||
records[phase] = { enter: [], over: [], drop: [], input: [] };
|
||||
}
|
||||
return records[phase];
|
||||
}
|
||||
|
||||
function modifierState(event) {
|
||||
return [
|
||||
event.altKey,
|
||||
event.ctrlKey,
|
||||
event.metaKey,
|
||||
event.shiftKey,
|
||||
].join(",");
|
||||
return [event.altKey, event.ctrlKey, event.metaKey, event.shiftKey].join(",");
|
||||
}
|
||||
|
||||
function resetTargetValue() {
|
||||
@@ -42,119 +38,98 @@ resetTargetValue();
|
||||
|
||||
const complete = new Promise(function (resolve) {
|
||||
target.addEventListener("dragenter", function (event) {
|
||||
if (shouldRecordModifierKeys()) {
|
||||
events.push(phase + "-enter-keys:" + modifierState(event));
|
||||
}
|
||||
events.push(
|
||||
phase +
|
||||
"-enter:" +
|
||||
event.dataTransfer.dropEffect +
|
||||
":" +
|
||||
event.dataTransfer.effectAllowed,
|
||||
);
|
||||
phaseRecord().enter.push([
|
||||
event.dataTransfer.dropEffect,
|
||||
event.dataTransfer.effectAllowed,
|
||||
modifierState(event),
|
||||
event.dataTransfer.getData("text"),
|
||||
]);
|
||||
});
|
||||
|
||||
target.addEventListener("dragover", function (event) {
|
||||
// Exercise author-selected operations rather than the editor's default copy.
|
||||
// This fixture negotiates author-handled operations. Uncanceled editor
|
||||
// drops instead use the editor's default copy operation.
|
||||
event.preventDefault();
|
||||
if (shouldRecordModifierKeys()) {
|
||||
events.push(phase + "-over-keys:" + modifierState(event));
|
||||
}
|
||||
if (overrideDropEffect !== null) {
|
||||
event.dataTransfer.dropEffect = overrideDropEffect;
|
||||
}
|
||||
events.push(
|
||||
phase +
|
||||
"-over:" +
|
||||
event.dataTransfer.dropEffect +
|
||||
":" +
|
||||
event.dataTransfer.effectAllowed,
|
||||
);
|
||||
event.dataTransfer.setData("text", "must-not-replace-the-payload");
|
||||
phaseRecord().over.push([
|
||||
event.dataTransfer.dropEffect,
|
||||
event.dataTransfer.effectAllowed,
|
||||
modifierState(event),
|
||||
event.dataTransfer.getData("text"),
|
||||
]);
|
||||
});
|
||||
|
||||
target.addEventListener("drop", function (event) {
|
||||
if (shouldRecordModifierKeys()) {
|
||||
events.push(phase + "-drop-keys:" + modifierState(event));
|
||||
}
|
||||
events.push(
|
||||
phase +
|
||||
"-drop:" +
|
||||
event.dataTransfer.getData("text") +
|
||||
":" +
|
||||
event.dataTransfer.dropEffect +
|
||||
":" +
|
||||
event.dataTransfer.effectAllowed +
|
||||
":" +
|
||||
target.value,
|
||||
);
|
||||
phaseRecord().drop.push([
|
||||
event.dataTransfer.getData("text"),
|
||||
event.dataTransfer.dropEffect,
|
||||
event.dataTransfer.effectAllowed,
|
||||
target.value,
|
||||
modifierState(event),
|
||||
]);
|
||||
expectedDrops += 1;
|
||||
if (expectedDrops === 9) {
|
||||
if (phase === "modifierPrecedence") {
|
||||
// The default input action runs after the drop listener returns.
|
||||
setTimeout(resolve, 0);
|
||||
}
|
||||
});
|
||||
|
||||
target.addEventListener("input", function () {
|
||||
events.push(
|
||||
phase +
|
||||
"-input:" +
|
||||
target.value +
|
||||
":" +
|
||||
target.selectionStart +
|
||||
":" +
|
||||
target.selectionEnd,
|
||||
);
|
||||
phaseRecord().input.push([
|
||||
target.value,
|
||||
target.selectionStart,
|
||||
target.selectionEnd,
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
promise_test(async function () {
|
||||
await complete;
|
||||
assert_array_equals(
|
||||
events,
|
||||
[
|
||||
"none-enter:none:none",
|
||||
"none-over:none:none",
|
||||
"none-drop:none-text:none:none:hello world",
|
||||
"link-enter:none:link",
|
||||
"link-over:none:link",
|
||||
"link-drop:link-text:none:link:hello world",
|
||||
"link-input:hello link-text:15:15",
|
||||
"move-enter:none:move",
|
||||
"move-over:none:move",
|
||||
"move-drop:move-text:none:move:hello world",
|
||||
"move-input:hello move-text:15:15",
|
||||
"copyLink-enter:none:copyLink",
|
||||
"copyLink-over:move:copyLink",
|
||||
"copyLink-drop:copylink-text:none:copyLink:hello world",
|
||||
"linkMove-enter:none:linkMove",
|
||||
"linkMove-over:copy:linkMove",
|
||||
"linkMove-drop:linkmove-text:none:linkMove:hello world",
|
||||
"all-enter:none:all",
|
||||
"all-over:link:all",
|
||||
"all-drop:all-text:none:all:hello world",
|
||||
"all-input:hello all-text:14:14",
|
||||
"modifiers-enter-keys:true,true,true,true",
|
||||
"modifiers-enter:none:copy",
|
||||
"modifiers-over-keys:false,true,false,true",
|
||||
"modifiers-over:none:copy",
|
||||
"modifiers-drop-keys:true,false,true,false",
|
||||
"modifiers-drop:modifiers-text:none:copy:hello world",
|
||||
"modifiers-input:hello modifiers-text:20:20",
|
||||
"modifierSelection-enter-keys:false,true,false,false",
|
||||
"modifierSelection-enter:none:all",
|
||||
"modifierSelection-over-keys:false,false,false,true",
|
||||
"modifierSelection-over:none:all",
|
||||
"modifierSelection-drop-keys:true,false,false,false",
|
||||
"modifierSelection-drop:auto-text:none:all:hello world",
|
||||
"modifierSelection-input:hello auto-text:15:15",
|
||||
"modifierPrecedence-enter-keys:false,false,true,false",
|
||||
"modifierPrecedence-enter:none:all",
|
||||
"modifierPrecedence-over-keys:false,true,false,true",
|
||||
"modifierPrecedence-over:none:all",
|
||||
"modifierPrecedence-drop-keys:true,false,true,false",
|
||||
"modifierPrecedence-drop:precedence-text:none:all:hello world",
|
||||
"modifierPrecedence-input:hello precedence-text:21:21",
|
||||
],
|
||||
"independent event views expose source masks and modifier keys, while negotiated operations gate editing",
|
||||
);
|
||||
}, "Input.dispatchDragEvent exposes operation-mask effect negotiation and modifier-key matrix");
|
||||
const noKeys = "false,false,false,false";
|
||||
const expected = [
|
||||
{ phase: "none", allowed: "none", over: ["none", "none"] },
|
||||
{ phase: "link", allowed: "link", over: ["none", "none"], text: "link-text" },
|
||||
{ phase: "move", allowed: "move", over: ["none", "none"], text: "move-text" },
|
||||
{ phase: "copyLink", allowed: "copyLink", over: ["move", "move"] },
|
||||
{ phase: "linkMove", allowed: "linkMove", over: ["copy", "copy"] },
|
||||
{ phase: "all", allowed: "all", over: ["link", "link"], text: "all-text" },
|
||||
{
|
||||
phase: "modifiers", allowed: "copy", over: ["none", "none"], text: "modifiers-text",
|
||||
keys: ["true,true,true,true", "false,true,false,true", "true,false,true,false"],
|
||||
},
|
||||
{
|
||||
phase: "modifierSelection", allowed: "all", over: ["none", "none"], text: "auto-text",
|
||||
keys: ["false,true,false,false", "false,false,false,true", "true,false,false,false"],
|
||||
},
|
||||
{
|
||||
phase: "modifierPrecedence", allowed: "all", over: ["none", "none"], text: "precedence-text",
|
||||
keys: ["false,false,true,false", "false,true,false,true", "true,false,true,false"],
|
||||
},
|
||||
];
|
||||
assert_array_equals(Object.keys(records), expected.map(function (item) { return item.phase; }), "every requested operation phase ran");
|
||||
for (const item of expected) {
|
||||
const record = records[item.phase];
|
||||
const keys = item.keys || [noKeys, noKeys, noKeys];
|
||||
assert_equals(record.enter.length, 1, item.phase + ": one dragenter");
|
||||
assert_array_equals(record.enter[0], ["none", item.allowed, keys[0], ""], item.phase + ": event view starts with none and protected data");
|
||||
assert_equals(record.over.length, 2, item.phase + ": explicit over and drop each negotiate dragover");
|
||||
for (let index = 0; index < 2; index++) {
|
||||
assert_array_equals(record.over[index], [item.over[index], item.allowed, keys[index + 1], ""], item.phase + ": operation/modifiers/protected data at over " + index);
|
||||
}
|
||||
if (item.text) {
|
||||
assert_equals(record.drop.length, 1, item.phase + ": allowed operation dispatches drop");
|
||||
assert_array_equals(record.drop[0], [item.text, "none", item.allowed, "hello world", keys[2]], item.phase + ": the drop command payload is readable only during drop");
|
||||
const caret = 6 + item.text.length;
|
||||
assert_equals(record.input.length, 1, item.phase + ": one default edit");
|
||||
assert_array_equals(record.input[0], ["hello " + item.text, caret, caret], item.phase + ": accepted drop replaces the selection");
|
||||
} else {
|
||||
assert_equals(record.drop.length, 0, item.phase + ": forbidden operation must not dispatch drop");
|
||||
assert_equals(record.input.length, 0, item.phase + ": forbidden operation must not edit");
|
||||
}
|
||||
}
|
||||
assert_equals(expectedDrops, 6, "only the six accepted phases dispatch drop");
|
||||
}, "Input.dispatchDragEvent validates operation masks, protected data and modifier-key negotiation");
|
||||
</script>
|
||||
|
||||
Reference in New Issue
Block a user