ltk_webkit/
page_scripts.rs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>

//! Scripts that every engine runs in the page, and the encoding that carries text into them.

/// Nothing from a password field. Percent-encoded, so the JSON result is a plain string.
pub( crate ) const SELECTION_SCRIPT: &str = "(() => {
	const e = document.activeElement;
	const field = e && ( e.tagName === 'TEXTAREA' || ( e.tagName === 'INPUT' && /^(text|search|url|tel|email|password)$/i.test( e.type ) ) );
	if ( field && e.type.toLowerCase() === 'password' ) return '';
	return encodeURIComponent( field ? e.value.substring( e.selectionStart, e.selectionEnd ) : String( window.getSelection() ) );
})()";

/// `{x}`, `{y}` in CSS pixels and `{text}` percent-encoded. Only into an editable, so a middle press on a link does nothing.
const PASTE_SCRIPT: &str = "((x, y, text) => {
	const target = document.elementFromPoint( x, y );
	const editable = target && target.closest( 'textarea, input, [contenteditable]:not([contenteditable=\"false\"])' );
	if ( !editable || editable.disabled || editable.readOnly ) return false;
	editable.focus();
	if ( editable.isContentEditable )
	{
		const range = document.caretRangeFromPoint( x, y );
		if ( range ) { const selection = window.getSelection(); selection.removeAllRanges(); selection.addRange( range ); }
	}
	return document.execCommand( 'insertText', false, text );
})( {x}, {y}, decodeURIComponent( '{text}' ) )";

/// The script that inserts `text` into the editable at `( x, y )`, in CSS pixels.
pub( crate ) fn paste_script( x: f64, y: f64, text: &str ) -> String
{
	PASTE_SCRIPT.replace( "{x}", &x.to_string() ).replace( "{y}", &y.to_string() ).replace( "{text}", &percent_encode( text ) )
}

/// What [`SELECTION_SCRIPT`] answered, as JSON, back to the selected text.
pub( crate ) fn selection_of( json: &str ) -> String
{
	percent_decode( json.trim_matches( '"' ) )
}

/// Every byte but letters, digits, `-`, `_` and `.` as `%XX`, which `decodeURIComponent` reads back; nothing that could end a JS string.
pub( crate ) fn percent_encode( text: &str ) -> String
{
	let mut out = String::with_capacity( text.len() * 3 );
	for byte in text.bytes()
	{
		if byte.is_ascii_alphanumeric() || matches!( byte, b'-' | b'_' | b'.' ) { out.push( byte as char ); } else { out.push_str( &format!( "%{byte:02X}" ) ); }
	}
	out
}

pub( crate ) fn percent_decode( text: &str ) -> String
{
	let bytes = text.as_bytes();
	let mut out = Vec::with_capacity( bytes.len() );
	let mut i = 0;
	while i < bytes.len()
	{
		let hex = if bytes[ i ] == b'%' && i + 2 < bytes.len()
		{
			std::str::from_utf8( &bytes[ i + 1..i + 3 ] ).ok().and_then( |h| u8::from_str_radix( h, 16 ).ok() )
		} else {
			None
		};
		match hex
		{
			Some( byte ) => { out.push( byte ); i += 3; }
			None         => { out.push( bytes[ i ] ); i += 1; }
		}
	}
	String::from_utf8_lossy( &out ).into_owned()
}

#[ cfg( test ) ]
mod tests
{
	use super::*;

	#[ test ]
	fn percent_round_trip()
	{
		for text in [ "plain", "a b", "comillas ' \" y \\ barra", "áé ñ €", "%41", "" ]
		{
			let encoded = percent_encode( text );
			assert!( !encoded.contains( '\'' ) && !encoded.contains( '"' ) && !encoded.contains( '\\' ) );
			assert_eq!( percent_decode( &encoded ), text );
		}
	}

	#[ test ]
	fn a_lone_percent_survives()
	{
		assert_eq!( percent_decode( "100%" ), "100%" );
		assert_eq!( percent_decode( "%4" ), "%4" );
	}

	#[ test ]
	fn paste_script_carries_the_point_and_the_text()
	{
		let script = paste_script( 10.5, 20.0, "a'b" );
		assert!( script.ends_with( "( 10.5, 20, decodeURIComponent( 'a%27b' ) )" ) );
	}
}