Files
obscura/crates/obscura-cdp/src/domains/input.rs
T
zhangyang-crazy-one 5d01b5eb6a fix(cdp): implement triple-click select-all for text inputs (#316 follow-up) (#320)
Passing detail:clickCount to MouseEvent is necessary but not sufficient
for triple-click to select all text. The select-all-on-triple-click is a
browser native behavior that synthetic MouseEvent dispatch doesn't trigger.

Add manual selection after click dispatch: when clickCount >= 3 and the
target is an INPUT or TEXTAREA, set selectionStart=0 and
selectionEnd=value.length.
2026-06-23 13:26:18 +02:00

195 lines
12 KiB
Rust

use serde_json::{json, Value};
use crate::dispatch::CdpContext;
pub async fn handle(
method: &str,
params: &Value,
ctx: &mut CdpContext,
session_id: &Option<String>,
) -> Result<Value, String> {
match method {
"dispatchMouseEvent" => {
let event_type = params.get("type").and_then(|v| v.as_str()).unwrap_or("");
let x = params.get("x").and_then(|v| v.as_f64()).unwrap_or(0.0);
let y = params.get("y").and_then(|v| v.as_f64()).unwrap_or(0.0);
let _button = params.get("button").and_then(|v| v.as_str()).unwrap_or("left");
let click_count = params.get("clickCount").and_then(|v| v.as_u64()).unwrap_or(1);
if event_type == "mousePressed" {
if let Some(page) = ctx.get_session_page_mut(session_id) {
let code = format!(
"(function() {{\
var target = (document.elementFromPoint && document.elementFromPoint({x},{y})) || globalThis.__obscura_click_target || document.activeElement || document.body;\
if (!target) return;\
globalThis.__obscura_click_target = target;\
var evt = globalThis.__obscura_markTrusted(new MouseEvent('mousedown', {{bubbles:true,cancelable:true,clientX:{x},clientY:{y},button:0,detail:{click_count}}}));\
target.dispatchEvent(evt);\
var click = globalThis.__obscura_markTrusted(new MouseEvent('click', {{bubbles:true,cancelable:true,clientX:{x},clientY:{y},button:0,detail:{click_count}}}));\
var cancelled = !target.dispatchEvent(click);\
if (!cancelled) {{\
var link = target.closest ? target.closest('a[href]') : null;\
if (!link && target.tagName === 'A' && target.getAttribute('href')) link = target;\
if (link) {{\
var href = link.getAttribute('href');\
if (href && !href.startsWith('#') && !href.startsWith('javascript:')) {{\
location.assign(href);\
}}\
}} else {{\
var tag = target.tagName;\
var type = (target.getAttribute && target.getAttribute('type') || '').toLowerCase();\
if (tag === 'BUTTON' && type !== 'button' && type !== 'reset') {{\
var form = target.closest ? target.closest('form') : null;\
if (form && typeof form.submit === 'function') {{ try {{ form.submit(target); }} catch(e) {{}} }}\
}} else if (tag === 'INPUT' && (type === 'submit' || type === 'image')) {{\
var form2 = target.closest ? target.closest('form') : null;\
if (form2 && typeof form2.submit === 'function') {{ try {{ form2.submit(target); }} catch(e) {{}} }}\
}} else if (tag === 'INPUT' && (type === 'checkbox' || type === 'radio')) {{\
target.checked = !target.checked;\
try {{ target.dispatchEvent(globalThis.__obscura_markTrusted(new Event('change', {{bubbles:true}}))); }} catch(e) {{}}\
}} else if ({click_count} >= 3 && (tag === 'INPUT' || tag === 'TEXTAREA')) {{\
// Triple-click selects all text (browser native behavior not replicated
// by synthetic MouseEvent, so we do it manually).
var len = target.value ? target.value.length : 0;\
if (target.setSelectionRange) {{\
target.setSelectionRange(0, len);\
}} else {{\
target.selectionStart = 0;\
target.selectionEnd = len;\
}}\
}}\
}}\
}}\
}})()",
x = x, y = y, click_count = click_count,
);
page.evaluate(&code);
page.process_pending_navigation().await.map_err(|e| e.to_string())?;
}
} else if event_type == "mouseReleased" {
if let Some(page) = ctx.get_session_page_mut(session_id) {
let code = format!(
"(function() {{\
var target = (document.elementFromPoint && document.elementFromPoint({x},{y})) || globalThis.__obscura_click_target || document.activeElement || document.body;\
if (!target) return;\
var evt = globalThis.__obscura_markTrusted(new MouseEvent('mouseup', {{bubbles:true,cancelable:true,clientX:{x},clientY:{y},button:0}}));\
target.dispatchEvent(evt);\
}})()",
x = x, y = y,
);
page.evaluate(&code);
}
}
Ok(json!({}))
}
"dispatchKeyEvent" => {
let event_type = params.get("type").and_then(|v| v.as_str()).unwrap_or("");
let key = params.get("key").and_then(|v| v.as_str()).unwrap_or("");
let code = params.get("code").and_then(|v| v.as_str()).unwrap_or("");
let text = params.get("text").and_then(|v| v.as_str()).unwrap_or("");
if let Some(page) = ctx.get_session_page_mut(session_id) {
match event_type {
"keyDown" | "rawKeyDown" => {
let js = format!(
"(function() {{\
var target = document.activeElement || document.body;\
var evt = globalThis.__obscura_markTrusted(new KeyboardEvent('keydown', {{bubbles:true,cancelable:true,key:'{key}',code:'{code}'}}));\
target.dispatchEvent(evt);\
}})()",
key = key.replace('\'', "\\'"),
code = code.replace('\'', "\\'"),
);
page.evaluate(&js);
if !text.is_empty() && text != "\r" && text != "\n" {
// Need to escape backslash BEFORE single-quote so the new
// backslashes from quote escaping don't get double-escaped.
let escaped_text = text.replace('\\', "\\\\").replace('\'', "\\'");
let js = format!(
"(function() {{\
var target = document.activeElement;\
if (target && (target.localName === 'input' || target.localName === 'textarea')) {{\
target.value = (target.value || '') + '{text}';\
target.dispatchEvent(globalThis.__obscura_markTrusted(new Event('input', {{bubbles:true}})));\
}}\
}})()",
text = escaped_text,
);
page.evaluate(&js);
}
if key == "Enter" {
// In a textarea Enter inserts a newline; in input fields
// it submits the containing form. Real Chrome distinguishes
// these two and we should too: previously every Enter tried
// to submit the nearest form even from a textarea.
let js = "(function() {\
var target = document.activeElement;\
if (!target) return;\
target.dispatchEvent(globalThis.__obscura_markTrusted(new KeyboardEvent('keypress', {bubbles:true,key:'Enter',code:'Enter'})));\
if (target.localName === 'textarea') {\
target.value = (target.value || '') + '\\n';\
target.dispatchEvent(globalThis.__obscura_markTrusted(new Event('input', {bubbles:true})));\
} else {\
var form = target.form || (target.closest && target.closest('form'));\
if (form && typeof form.submit === 'function') form.submit();\
}\
})()";
page.evaluate(js);
}
if key == "Backspace" {
let js = "(function() {\
var target = document.activeElement;\
if (target && (target.localName === 'input' || target.localName === 'textarea')) {\
target.value = target.value.slice(0, -1);\
target.dispatchEvent(globalThis.__obscura_markTrusted(new Event('input', {bubbles:true})));\
}\
})()";
page.evaluate(js);
}
}
"keyUp" => {
let js = format!(
"(function() {{\
var target = document.activeElement || document.body;\
var evt = globalThis.__obscura_markTrusted(new KeyboardEvent('keyup', {{bubbles:true,key:'{key}',code:'{code}'}}));\
target.dispatchEvent(evt);\
}})()",
key = key.replace('\'', "\\'"),
code = code.replace('\'', "\\'"),
);
page.evaluate(&js);
}
"char" => {
if !text.is_empty() {
let escaped_text = text.replace('\\', "\\\\").replace('\'', "\\'");
let js = format!(
"(function() {{\
var target = document.activeElement;\
if (target && (target.localName === 'input' || target.localName === 'textarea')) {{\
target.value = (target.value || '') + '{text}';\
target.dispatchEvent(globalThis.__obscura_markTrusted(new Event('input', {{bubbles:true}})));\
}}\
}})()",
text = escaped_text,
);
page.evaluate(&js);
// Pump event loop so Angular change detection picks up the input
page.settle(50).await;
}
}
_ => {}
}
}
Ok(json!({}))
}
"dispatchTouchEvent" => Ok(json!({})),
"setIgnoreInputEvents" => Ok(json!({})),
_ => Err(format!("Unknown Input method: {}", method)),
}
}