Fix raw native mouse drag state across down/move/up (#872)

* Fix native mouse down/move/up state

* Allow clippy too_many_arguments for mouse helper

---------

Co-authored-by: Daniel Vershinin <d@sgml.me>
This commit is contained in:
Daniel Vershinin
2026-03-18 13:29:33 -05:00
committed by GitHub
co-authored by Daniel Vershinin
parent 4be4605543
commit 5b5dffe0a2
5 changed files with 851 additions and 33 deletions
+256 -33
View File
@@ -13,7 +13,8 @@ use super::cdp::chrome::LaunchOptions;
use super::cdp::client::CdpClient;
use super::cdp::types::{
AttachToTargetParams, AttachToTargetResult, CdpEvent, ConsoleApiCalledEvent,
CreateTargetResult, ExceptionThrownEvent, TargetCreatedEvent, TargetDestroyedEvent,
CreateTargetResult, DispatchMouseEventParams, ExceptionThrownEvent, TargetCreatedEvent,
TargetDestroyedEvent,
};
use super::cookies;
use super::diff;
@@ -104,6 +105,13 @@ pub enum BackendType {
WebDriver,
}
#[derive(Debug, Clone, Copy, Default)]
pub struct MouseState {
pub x: f64,
pub y: f64,
pub buttons: i32,
}
pub struct DaemonState {
pub browser: Option<BrowserManager>,
pub appium: Option<AppiumManager>,
@@ -129,6 +137,7 @@ pub struct DaemonState {
pub tracked_requests: Vec<TrackedRequest>,
pub request_tracking: bool,
pub active_frame_id: Option<String>,
pub mouse_state: MouseState,
/// Shared slot for stream server to receive CDP client when browser launches.
pub stream_client: Option<Arc<RwLock<Option<Arc<CdpClient>>>>>,
/// Stream server instance kept alive so the broadcast channel remains open.
@@ -165,11 +174,16 @@ impl DaemonState {
tracked_requests: Vec::new(),
request_tracking: false,
active_frame_id: None,
mouse_state: MouseState::default(),
stream_client: None,
stream_server: None,
}
}
fn reset_input_state(&mut self) {
self.mouse_state = MouseState::default();
}
/// Create state with an optional stream client slot and server instance
/// (for daemon startup with stream server).
pub fn new_with_stream(
@@ -708,6 +722,7 @@ pub async fn execute_command(cmd: &Value, state: &mut DaemonState) -> Value {
let _ = mgr.close().await;
}
state.browser = None;
state.reset_input_state();
state.update_stream_client().await;
}
if let Err(e) = auto_launch(state).await {
@@ -919,6 +934,7 @@ async fn auto_launch(state: &mut DaemonState) -> Result<(), String> {
if let Ok(cdp) = env::var("AGENT_BROWSER_CDP") {
let mgr = BrowserManager::connect_cdp(&cdp).await?;
state.reset_input_state();
state.browser = Some(mgr);
state.subscribe_to_browser_events();
state.update_stream_client().await;
@@ -927,6 +943,7 @@ async fn auto_launch(state: &mut DaemonState) -> Result<(), String> {
}
if env::var("AGENT_BROWSER_AUTO_CONNECT").is_ok() {
state.reset_input_state();
state.browser = Some(connect_auto_with_fresh_tab().await?);
state.subscribe_to_browser_events();
state.update_stream_client().await;
@@ -935,6 +952,7 @@ async fn auto_launch(state: &mut DaemonState) -> Result<(), String> {
}
let mgr = BrowserManager::launch(options, engine.as_deref()).await?;
state.reset_input_state();
state.browser = Some(mgr);
state.subscribe_to_browser_events();
state.update_stream_client().await;
@@ -1041,6 +1059,7 @@ async fn handle_launch(cmd: &Value, state: &mut DaemonState) -> Result<Value, St
if let Some(ref mut b) = state.browser {
b.close().await?;
state.browser = None;
state.reset_input_state();
state.update_stream_client().await;
}
} else {
@@ -1077,6 +1096,7 @@ async fn handle_launch(cmd: &Value, state: &mut DaemonState) -> Result<Value, St
)?;
if let Some(url) = cdp_url {
state.reset_input_state();
state.browser = Some(BrowserManager::connect_cdp(url).await?);
state.subscribe_to_browser_events();
state.update_stream_client().await;
@@ -1084,6 +1104,7 @@ async fn handle_launch(cmd: &Value, state: &mut DaemonState) -> Result<Value, St
}
if let Some(port) = cdp_port {
state.reset_input_state();
state.browser = Some(BrowserManager::connect_cdp(&port.to_string()).await?);
state.subscribe_to_browser_events();
state.update_stream_client().await;
@@ -1091,6 +1112,7 @@ async fn handle_launch(cmd: &Value, state: &mut DaemonState) -> Result<Value, St
}
if auto_connect {
state.reset_input_state();
state.browser = Some(connect_auto_with_fresh_tab().await?);
state.subscribe_to_browser_events();
state.update_stream_client().await;
@@ -1109,6 +1131,7 @@ async fn handle_launch(cmd: &Value, state: &mut DaemonState) -> Result<Value, St
let (ws_url, provider_session) = providers::connect_provider(provider).await?;
match BrowserManager::connect_cdp(&ws_url).await {
Ok(mgr) => {
state.reset_input_state();
state.browser = Some(mgr);
state.subscribe_to_browser_events();
state.update_stream_client().await;
@@ -1195,6 +1218,7 @@ async fn handle_launch(cmd: &Value, state: &mut DaemonState) -> Result<Value, St
state.domain_filter = Some(DomainFilter::new(domains));
}
state.reset_input_state();
state.browser = Some(BrowserManager::launch(options, engine.as_deref()).await?);
state.subscribe_to_browser_events();
state.update_stream_client().await;
@@ -1245,6 +1269,7 @@ async fn launch_ios(cmd: &Value, state: &mut DaemonState) -> Result<Value, Strin
state.appium = Some(appium);
state.backend_type = BackendType::WebDriver;
state.reset_input_state();
Ok(json!({
"launched": true,
@@ -1288,6 +1313,7 @@ async fn launch_safari(cmd: &Value, state: &mut DaemonState) -> Result<Value, St
state.safari_driver = Some(driver);
state.webdriver_backend = Some(WebDriverBackend::new(client));
state.backend_type = BackendType::WebDriver;
state.reset_input_state();
Ok(json!({
"launched": true,
@@ -1477,6 +1503,7 @@ async fn handle_close(state: &mut DaemonState) -> Result<Value, String> {
mgr.close().await?;
}
state.browser = None;
state.reset_input_state();
state.update_stream_client().await;
// Close WebDriver sessions
@@ -5643,28 +5670,110 @@ async fn handle_device_list() -> Result<Value, String> {
// Input event handlers
// ---------------------------------------------------------------------------
async fn handle_input_mouse(cmd: &Value, state: &DaemonState) -> Result<Value, String> {
fn mouse_button_mask(button: &str) -> i32 {
match button {
"left" => 1,
"right" => 2,
"middle" => 4,
"back" => 8,
"forward" => 16,
_ => 0,
}
}
fn primary_button_from_mask(buttons: i32) -> &'static str {
if buttons & 1 != 0 {
"left"
} else if buttons & 2 != 0 {
"right"
} else if buttons & 4 != 0 {
"middle"
} else if buttons & 8 != 0 {
"back"
} else if buttons & 16 != 0 {
"forward"
} else {
"none"
}
}
#[allow(clippy::too_many_arguments)]
fn build_mouse_event_params(
mouse_state: &mut MouseState,
event_type: &str,
x: Option<f64>,
y: Option<f64>,
button: Option<&str>,
buttons: Option<i32>,
click_count: Option<i32>,
delta_x: Option<f64>,
delta_y: Option<f64>,
modifiers: Option<i32>,
) -> DispatchMouseEventParams {
let x = x.unwrap_or(mouse_state.x);
let y = y.unwrap_or(mouse_state.y);
mouse_state.x = x;
mouse_state.y = y;
let mut next_buttons = buttons.unwrap_or(mouse_state.buttons);
if buttons.is_none() {
match event_type {
"mousePressed" => {
next_buttons |= mouse_button_mask(button.unwrap_or("left"));
}
"mouseReleased" => {
next_buttons &= !mouse_button_mask(button.unwrap_or("left"));
}
_ => {}
}
}
mouse_state.buttons = next_buttons;
DispatchMouseEventParams {
event_type: event_type.to_string(),
x,
y,
button: Some(
button
.unwrap_or(primary_button_from_mask(next_buttons))
.to_string(),
),
buttons: Some(next_buttons),
click_count,
delta_x,
delta_y,
modifiers,
}
}
async fn handle_input_mouse(cmd: &Value, state: &mut DaemonState) -> Result<Value, String> {
let mgr = state.browser.as_ref().ok_or("Browser not launched")?;
let session_id = mgr.active_session_id()?.to_string();
let event_type = cmd
.get("type")
.and_then(|v| v.as_str())
.unwrap_or("mouseMoved");
let x = cmd.get("x").and_then(|v| v.as_f64()).unwrap_or(0.0);
let y = cmd.get("y").and_then(|v| v.as_f64()).unwrap_or(0.0);
let params = build_mouse_event_params(
&mut state.mouse_state,
event_type,
cmd.get("x").and_then(|v| v.as_f64()),
cmd.get("y").and_then(|v| v.as_f64()),
cmd.get("button").and_then(|v| v.as_str()),
cmd.get("buttons")
.and_then(|v| v.as_i64())
.map(|v| v as i32),
cmd.get("clickCount")
.and_then(|v| v.as_i64())
.map(|v| v as i32),
cmd.get("deltaX").and_then(|v| v.as_f64()),
cmd.get("deltaY").and_then(|v| v.as_f64()),
cmd.get("modifiers")
.and_then(|v| v.as_i64())
.map(|v| v as i32),
);
mgr.client
.send_command(
"Input.dispatchMouseEvent",
Some(json!({
"type": event_type, "x": x, "y": y,
"button": cmd.get("button").and_then(|v| v.as_str()).unwrap_or("none"),
"clickCount": cmd.get("clickCount").and_then(|v| v.as_i64()).unwrap_or(0),
"deltaX": cmd.get("deltaX").and_then(|v| v.as_f64()).unwrap_or(0.0),
"deltaY": cmd.get("deltaY").and_then(|v| v.as_f64()).unwrap_or(0.0),
})),
Some(&session_id),
)
.send_command_typed::<_, Value>("Input.dispatchMouseEvent", &params, Some(&session_id))
.await?;
Ok(json!({ "dispatched": event_type }))
}
@@ -5765,48 +5874,72 @@ async fn handle_inserttext(cmd: &Value, state: &DaemonState) -> Result<Value, St
Ok(json!({ "inserted": true }))
}
async fn handle_mousemove(cmd: &Value, state: &DaemonState) -> Result<Value, String> {
async fn handle_mousemove(cmd: &Value, state: &mut DaemonState) -> Result<Value, String> {
let mgr = state.browser.as_ref().ok_or("Browser not launched")?;
let session_id = mgr.active_session_id()?.to_string();
let x = cmd.get("x").and_then(|v| v.as_f64()).unwrap_or(0.0);
let y = cmd.get("y").and_then(|v| v.as_f64()).unwrap_or(0.0);
let params = build_mouse_event_params(
&mut state.mouse_state,
"mouseMoved",
Some(x),
Some(y),
None,
None,
None,
None,
None,
None,
);
mgr.client
.send_command(
"Input.dispatchMouseEvent",
Some(json!({ "type": "mouseMoved", "x": x, "y": y })),
Some(&session_id),
)
.send_command_typed::<_, Value>("Input.dispatchMouseEvent", &params, Some(&session_id))
.await?;
Ok(json!({ "moved": true }))
}
async fn handle_mousedown(cmd: &Value, state: &DaemonState) -> Result<Value, String> {
async fn handle_mousedown(cmd: &Value, state: &mut DaemonState) -> Result<Value, String> {
let mgr = state.browser.as_ref().ok_or("Browser not launched")?;
let session_id = mgr.active_session_id()?.to_string();
let button = cmd.get("button").and_then(|v| v.as_str()).unwrap_or("left");
let params = build_mouse_event_params(
&mut state.mouse_state,
"mousePressed",
None,
None,
Some(button),
None,
Some(1),
None,
None,
None,
);
mgr.client
.send_command(
"Input.dispatchMouseEvent",
Some(json!({ "type": "mousePressed", "x": 0, "y": 0, "button": button, "clickCount": 1 })),
Some(&session_id),
)
.send_command_typed::<_, Value>("Input.dispatchMouseEvent", &params, Some(&session_id))
.await?;
Ok(json!({ "pressed": true }))
}
async fn handle_mouseup(cmd: &Value, state: &DaemonState) -> Result<Value, String> {
async fn handle_mouseup(cmd: &Value, state: &mut DaemonState) -> Result<Value, String> {
let mgr = state.browser.as_ref().ok_or("Browser not launched")?;
let session_id = mgr.active_session_id()?.to_string();
let button = cmd.get("button").and_then(|v| v.as_str()).unwrap_or("left");
let params = build_mouse_event_params(
&mut state.mouse_state,
"mouseReleased",
None,
None,
Some(button),
None,
Some(1),
None,
None,
None,
);
mgr.client
.send_command(
"Input.dispatchMouseEvent",
Some(json!({ "type": "mouseReleased", "x": 0, "y": 0, "button": button, "clickCount": 1 })),
Some(&session_id),
)
.send_command_typed::<_, Value>("Input.dispatchMouseEvent", &params, Some(&session_id))
.await?;
Ok(json!({ "released": true }))
}
@@ -5862,6 +5995,96 @@ mod tests {
assert_eq!(state.session_id, "default");
assert!(!state.tracing_state.active);
assert!(!state.recording_state.active);
assert_eq!(state.mouse_state.x, 0.0);
assert_eq!(state.mouse_state.y, 0.0);
assert_eq!(state.mouse_state.buttons, 0);
}
#[test]
fn test_mouse_event_params_preserve_position_and_buttons() {
let mut mouse_state = MouseState::default();
let move_params = build_mouse_event_params(
&mut mouse_state,
"mouseMoved",
Some(120.0),
Some(240.0),
None,
None,
None,
None,
None,
None,
);
assert_eq!(move_params.x, 120.0);
assert_eq!(move_params.y, 240.0);
assert_eq!(move_params.buttons, Some(0));
let down_params = build_mouse_event_params(
&mut mouse_state,
"mousePressed",
None,
None,
Some("left"),
None,
Some(1),
None,
None,
None,
);
assert_eq!(down_params.x, 120.0);
assert_eq!(down_params.y, 240.0);
assert_eq!(down_params.button.as_deref(), Some("left"));
assert_eq!(down_params.buttons, Some(1));
assert_eq!(mouse_state.buttons, 1);
let drag_move_params = build_mouse_event_params(
&mut mouse_state,
"mouseMoved",
Some(150.0),
Some(260.0),
None,
None,
None,
None,
None,
None,
);
assert_eq!(drag_move_params.buttons, Some(1));
assert_eq!(drag_move_params.button.as_deref(), Some("left"));
assert_eq!(mouse_state.x, 150.0);
assert_eq!(mouse_state.y, 260.0);
let up_params = build_mouse_event_params(
&mut mouse_state,
"mouseReleased",
None,
None,
Some("left"),
None,
Some(1),
None,
None,
None,
);
assert_eq!(up_params.x, 150.0);
assert_eq!(up_params.y, 260.0);
assert_eq!(up_params.buttons, Some(0));
assert_eq!(mouse_state.buttons, 0);
}
#[test]
fn test_reset_input_state_clears_mouse_state() {
let mut state = DaemonState::new();
state.mouse_state.x = 12.0;
state.mouse_state.y = 34.0;
state.mouse_state.buttons = 1;
state.reset_input_state();
assert_eq!(state.mouse_state.x, 0.0);
assert_eq!(state.mouse_state.y, 0.0);
assert_eq!(state.mouse_state.buttons, 0);
}
#[test]
+256
View File
@@ -7,6 +7,7 @@
//! Run serially to avoid Chrome instance contention:
//! cargo test e2e -- --ignored --test-threads=1
use base64::{engine::general_purpose::STANDARD, Engine};
use serde_json::{json, Value};
use super::actions::{execute_command, DaemonState};
@@ -24,6 +25,22 @@ fn get_data(resp: &Value) -> &Value {
resp.get("data").expect("Missing 'data' in response")
}
fn native_test_fixture_html(name: &str) -> &'static str {
match name {
"drag_probe" => include_str!("test_fixtures/drag_probe.html"),
"html5_drag_probe" => include_str!("test_fixtures/html5_drag_probe.html"),
"pointer_capture_probe" => include_str!("test_fixtures/pointer_capture_probe.html"),
_ => panic!("Unknown native test fixture: {}", name),
}
}
fn native_test_fixture_url(name: &str) -> String {
format!(
"data:text/html;base64,{}",
STANDARD.encode(native_test_fixture_html(name))
)
}
// ---------------------------------------------------------------------------
// Core: launch, navigate, evaluate, url, title, close
// ---------------------------------------------------------------------------
@@ -1152,6 +1169,245 @@ async fn e2e_hover_scroll_press() {
assert_success(&resp);
}
// ---------------------------------------------------------------------------
// Raw mouse regressions
// ---------------------------------------------------------------------------
#[tokio::test]
#[ignore]
async fn e2e_mouse_down_move_up_preserves_drag_state() {
let mut state = DaemonState::new();
let resp = execute_command(
&json!({ "id": "1", "action": "launch", "headless": true }),
&mut state,
)
.await;
assert_success(&resp);
let resp = execute_command(
&json!({
"id": "2",
"action": "navigate",
"url": native_test_fixture_url("drag_probe")
}),
&mut state,
)
.await;
assert_success(&resp);
let resp = execute_command(
&json!({
"id": "3",
"action": "evaluate",
"script": r#"(() => {
const rect = document.getElementById('target').getBoundingClientRect();
return {
left: Math.round(rect.left),
top: Math.round(rect.top),
x: Math.round(rect.left + rect.width / 2),
y: Math.round(rect.top + rect.height / 2)
};
})()"#
}),
&mut state,
)
.await;
assert_success(&resp);
let start = &get_data(&resp)["result"];
let initial_left = start["left"]
.as_i64()
.expect("target left should be numeric");
let initial_top = start["top"].as_i64().expect("target top should be numeric");
let start_x = start["x"].as_i64().expect("target x should be numeric");
let start_y = start["y"].as_i64().expect("target y should be numeric");
let end_x = start_x + 80;
let end_y = start_y + 60;
let resp = execute_command(
&json!({ "id": "4", "action": "mousemove", "x": start_x, "y": start_y }),
&mut state,
)
.await;
assert_success(&resp);
let resp = execute_command(
&json!({ "id": "5", "action": "mousedown", "button": "left" }),
&mut state,
)
.await;
assert_success(&resp);
let resp = execute_command(
&json!({ "id": "6", "action": "mousemove", "x": end_x, "y": end_y }),
&mut state,
)
.await;
assert_success(&resp);
let resp = execute_command(
&json!({ "id": "7", "action": "mouseup", "button": "left" }),
&mut state,
)
.await;
assert_success(&resp);
let resp = execute_command(
&json!({ "id": "8", "action": "evaluate", "script": "window.__dragProbe" }),
&mut state,
)
.await;
assert_success(&resp);
let probe = &get_data(&resp)["result"];
assert_eq!(probe["finalLeft"].as_i64(), Some(initial_left + 80));
assert_eq!(probe["finalTop"].as_i64(), Some(initial_top + 60));
let events = probe["events"]
.as_array()
.expect("drag probe should expose events");
assert!(
events.iter().any(|event| {
event["type"] == "mousedown"
&& event["x"].as_f64() == Some(start_x as f64)
&& event["y"].as_f64() == Some(start_y as f64)
&& event["buttons"].as_i64() == Some(1)
}),
"Expected a non-zero mousedown event in drag probe"
);
assert!(
events.iter().any(|event| {
event["type"] == "mousemove"
&& event["x"].as_f64() == Some(end_x as f64)
&& event["y"].as_f64() == Some(end_y as f64)
&& event["buttons"].as_i64() == Some(1)
}),
"Expected a drag mousemove with the button still pressed"
);
assert!(
events.iter().any(|event| {
event["type"] == "mouseup"
&& event["x"].as_f64() == Some(end_x as f64)
&& event["y"].as_f64() == Some(end_y as f64)
&& event["buttons"].as_i64() == Some(0)
}),
"Expected mouseup at the last drag position"
);
let resp = execute_command(&json!({ "id": "99", "action": "close" }), &mut state).await;
assert_success(&resp);
}
#[tokio::test]
#[ignore]
async fn e2e_mouse_drag_reaches_pointer_capture_target() {
let mut state = DaemonState::new();
let resp = execute_command(
&json!({ "id": "1", "action": "launch", "headless": true }),
&mut state,
)
.await;
assert_success(&resp);
let resp = execute_command(
&json!({
"id": "2",
"action": "navigate",
"url": native_test_fixture_url("pointer_capture_probe")
}),
&mut state,
)
.await;
assert_success(&resp);
let resp = execute_command(
&json!({
"id": "3",
"action": "evaluate",
"script": r#"(() => {
const rect = document.getElementById('handle').getBoundingClientRect();
return {
x: Math.round(rect.left + rect.width / 2),
y: Math.round(rect.top + rect.height / 2)
};
})()"#
}),
&mut state,
)
.await;
assert_success(&resp);
let start = &get_data(&resp)["result"];
let start_x = start["x"].as_i64().expect("handle x should be numeric");
let start_y = start["y"].as_i64().expect("handle y should be numeric");
let end_x = start_x + 80;
let end_y = start_y + 60;
let resp = execute_command(
&json!({ "id": "4", "action": "mousemove", "x": start_x, "y": start_y }),
&mut state,
)
.await;
assert_success(&resp);
let resp = execute_command(
&json!({ "id": "5", "action": "mousedown", "button": "left" }),
&mut state,
)
.await;
assert_success(&resp);
let resp = execute_command(
&json!({ "id": "6", "action": "mousemove", "x": end_x, "y": end_y }),
&mut state,
)
.await;
assert_success(&resp);
let resp = execute_command(
&json!({ "id": "7", "action": "mouseup", "button": "left" }),
&mut state,
)
.await;
assert_success(&resp);
let resp = execute_command(
&json!({ "id": "8", "action": "evaluate", "script": "window.__pointerCaptureProbe" }),
&mut state,
)
.await;
assert_success(&resp);
let probe = &get_data(&resp)["result"];
assert_eq!(probe["moved"].as_bool(), Some(true));
let events = probe["events"]
.as_array()
.expect("pointer capture probe should expose events");
assert!(
events.iter().any(|event| {
event["type"] == "pointermove"
&& event["phase"] == "drag"
&& event["hasCapture"].as_bool() == Some(true)
&& event["x"].as_f64() == Some(end_x as f64)
&& event["y"].as_f64() == Some(end_y as f64)
}),
"Expected pointermove with capture during the drag"
);
assert!(
events.iter().any(|event| {
event["type"] == "pointerup"
&& event["phase"] == "up"
&& event["hadCapture"].as_bool() == Some(true)
}),
"Expected pointerup to observe an active pointer capture"
);
let resp = execute_command(&json!({ "id": "99", "action": "close" }), &mut state).await;
assert_success(&resp);
}
// ---------------------------------------------------------------------------
// State save/load, state management
// ---------------------------------------------------------------------------
@@ -0,0 +1,135 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Drag Probe</title>
<style>
body {
margin: 0;
font: 14px/1.4 sans-serif;
background: #f4f4f4;
}
#pad {
position: relative;
width: 800px;
height: 500px;
margin: 24px;
border: 1px solid #999;
background: white;
overflow: hidden;
}
#target {
position: absolute;
left: 320px;
top: 40px;
width: 100px;
height: 40px;
background: #e34c26;
color: white;
display: flex;
align-items: center;
justify-content: center;
user-select: none;
cursor: grab;
}
#target.dragging {
cursor: grabbing;
background: #0d9488;
}
#log {
margin: 24px;
white-space: pre-wrap;
font-family: ui-monospace, monospace;
}
</style>
</head>
<body>
<div id="pad">
<div id="target">drag me</div>
</div>
<pre id="log"></pre>
<script>
const target = document.getElementById("target");
const logEl = document.getElementById("log");
window.__dragProbe = {
dragging: false,
events: [],
finalLeft: 320,
finalTop: 40,
};
let offsetX = 0;
let offsetY = 0;
function pushEvent(event, extra = {}) {
window.__dragProbe.events.push({
type: event.type,
button: event.button,
buttons: event.buttons,
x: event.clientX,
y: event.clientY,
target: event.target.id || event.target.tagName,
...extra,
});
logEl.textContent = JSON.stringify(window.__dragProbe, null, 2);
}
function onPointerLikeStart(event) {
if (event.type === "mousedown") {
const rect = target.getBoundingClientRect();
offsetX = event.clientX - rect.left;
offsetY = event.clientY - rect.top;
window.__dragProbe.dragging = true;
target.classList.add("dragging");
event.preventDefault();
}
pushEvent(event, { phase: "start" });
}
target.addEventListener("mousedown", (event) => {
const rect = target.getBoundingClientRect();
offsetX = event.clientX - rect.left;
offsetY = event.clientY - rect.top;
window.__dragProbe.dragging = true;
target.classList.add("dragging");
event.preventDefault();
pushEvent(event, { phase: "start" });
});
target.addEventListener("pointerdown", onPointerLikeStart);
document.addEventListener("mousemove", (event) => {
if (window.__dragProbe.dragging) {
const left = event.clientX - offsetX;
const top = event.clientY - offsetY;
target.style.left = `${left}px`;
target.style.top = `${top}px`;
window.__dragProbe.finalLeft = left;
window.__dragProbe.finalTop = top;
}
pushEvent(event);
});
document.addEventListener("pointermove", (event) => {
pushEvent(event);
});
document.addEventListener("mouseup", (event) => {
if (window.__dragProbe.dragging) {
window.__dragProbe.dragging = false;
target.classList.remove("dragging");
}
pushEvent(event, { phase: "end" });
});
document.addEventListener("pointerup", (event) => {
pushEvent(event, { phase: "end" });
});
target.addEventListener("dragstart", (event) => {
pushEvent(event, { phase: "dragstart" });
});
</script>
</body>
</html>
@@ -0,0 +1,91 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>HTML5 Drag Probe</title>
<style>
body {
margin: 24px;
font: 14px/1.4 sans-serif;
}
#source, #dest {
width: 120px;
height: 80px;
display: inline-flex;
align-items: center;
justify-content: center;
border: 1px solid #666;
user-select: none;
margin-right: 40px;
}
#source {
background: #f97316;
color: white;
}
#dest {
background: #e5e7eb;
}
pre {
margin-top: 24px;
white-space: pre-wrap;
font-family: ui-monospace, monospace;
}
</style>
</head>
<body>
<div id="source" draggable="true">drag source</div>
<div id="dest">drop zone</div>
<pre id="log"></pre>
<script>
const source = document.getElementById("source");
const dest = document.getElementById("dest");
const logEl = document.getElementById("log");
window.__html5DragProbe = { events: [] };
function pushEvent(event, extra = {}) {
window.__html5DragProbe.events.push({
type: event.type,
target: event.target.id || event.target.tagName,
x: event.clientX,
y: event.clientY,
button: event.button,
buttons: event.buttons,
...extra,
});
logEl.textContent = JSON.stringify(window.__html5DragProbe, null, 2);
}
for (const type of ["pointerdown", "mousedown", "dragstart", "drag", "dragend"]) {
source.addEventListener(type, (event) => {
if (type === "dragstart") {
event.dataTransfer.setData("text/plain", "probe");
}
pushEvent(event);
});
}
for (const type of ["pointermove", "mousemove", "dragenter", "dragover", "drop", "pointerup", "mouseup"]) {
document.addEventListener(type, (event) => {
if (type === "dragover") {
event.preventDefault();
}
if (type === "drop") {
pushEvent(event, { dropped: event.dataTransfer.getData("text/plain") });
return;
}
pushEvent(event);
});
}
dest.addEventListener("dragover", (event) => event.preventDefault());
dest.addEventListener("drop", (event) => {
pushEvent(event, { dropped: event.dataTransfer.getData("text/plain") });
});
</script>
</body>
</html>
@@ -0,0 +1,113 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Pointer Capture Probe</title>
<style>
body {
margin: 24px;
font: 14px/1.4 sans-serif;
}
#crop {
position: relative;
width: 240px;
height: 180px;
border: 2px solid #fff;
outline: 1px solid #555;
background: rgba(0, 0, 0, 0.2);
}
#handle {
position: absolute;
width: 20px;
height: 20px;
top: -16px;
left: -16px;
padding-top: 13px;
padding-left: 13px;
box-sizing: content-box;
background: rgba(255, 0, 0, 0.25);
}
#handle::after {
content: "";
display: block;
width: 20px;
height: 20px;
border-top: 2px solid white;
border-left: 2px solid white;
}
pre {
margin-top: 24px;
white-space: pre-wrap;
font-family: ui-monospace, monospace;
}
</style>
</head>
<body>
<div id="crop" aria-label="crop area">
<div id="handle" aria-label="crop handle topLeft" data-anchor="topLeft"></div>
</div>
<pre id="log"></pre>
<script>
const crop = document.getElementById("crop");
const handle = document.getElementById("handle");
const logEl = document.getElementById("log");
const state = {
targetAnchor: null,
dragging: false,
moved: false,
events: [],
};
window.__pointerCaptureProbe = state;
function sync() {
logEl.textContent = JSON.stringify(state, null, 2);
}
function push(event, extra = {}) {
state.events.push({
type: event.type,
target: event.target.id || event.target.tagName,
currentTarget: event.currentTarget.id || event.currentTarget.tagName,
pointerId: event.pointerId,
button: event.button,
buttons: event.buttons,
hasCapture: event.currentTarget.hasPointerCapture?.(event.pointerId) ?? false,
x: event.clientX,
y: event.clientY,
...extra,
});
sync();
}
crop.addEventListener("pointerdown", (event) => {
state.targetAnchor = event.target.getAttribute("data-anchor");
crop.setPointerCapture(event.pointerId);
event.preventDefault();
push(event, { phase: "down", targetAnchor: state.targetAnchor });
});
crop.addEventListener("pointermove", (event) => {
const hasCapture = crop.hasPointerCapture(event.pointerId);
if (hasCapture && state.targetAnchor) {
state.dragging = true;
state.moved = true;
}
push(event, { phase: hasCapture ? "drag" : "hover", targetAnchor: state.targetAnchor });
});
crop.addEventListener("pointerup", (event) => {
const hadCapture = crop.hasPointerCapture(event.pointerId);
state.dragging = false;
push(event, { phase: "up", targetAnchor: state.targetAnchor, hadCapture });
state.targetAnchor = null;
});
handle.addEventListener("pointerdown", (event) => push(event, { listener: "handle" }));
handle.addEventListener("pointermove", (event) => push(event, { listener: "handle" }));
handle.addEventListener("pointerup", (event) => push(event, { listener: "handle" }));
sync();
</script>
</body>
</html>