From 06b97ec728bcff01d04d11310b4fe8e56f51f8e5 Mon Sep 17 00:00:00 2001 From: koko Date: Fri, 28 Feb 2025 01:39:55 +0100 Subject: [PATCH 1/4] Implement form submission --- Cargo.lock | 1 + Cargo.toml | 1 + apps/readme/src/main.rs | 8 +- apps/readme/src/readme_application.rs | 4 +- packages/blitz-dom/Cargo.toml | 1 + packages/blitz-dom/src/document.rs | 4 + packages/blitz-dom/src/events/keyboard.rs | 62 ++- packages/blitz-dom/src/events/mouse.rs | 30 +- packages/blitz-dom/src/form.rs | 469 ++++++++++++++++++++++ packages/blitz-dom/src/lib.rs | 2 + packages/blitz-dom/src/net.rs | 23 ++ packages/blitz-dom/src/util.rs | 32 +- packages/blitz-html/src/html_sink.rs | 14 + packages/blitz-shell/src/application.rs | 5 +- packages/blitz-shell/src/event.rs | 3 +- packages/blitz-traits/src/navigation.rs | 91 ++++- 16 files changed, 720 insertions(+), 30 deletions(-) create mode 100644 packages/blitz-dom/src/form.rs diff --git a/Cargo.lock b/Cargo.lock index 2cc092760..597a1371d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -651,6 +651,7 @@ dependencies = [ "markup5ever", "parley", "peniko", + "percent-encoding", "selectors", "slab", "smallvec", diff --git a/Cargo.toml b/Cargo.toml index 62fe165a6..32321a202 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -74,6 +74,7 @@ image = { version = "0.25", default-features = false } woff = "0.3" woff2 = "0.3" html-escape = "0.2.13" +percent-encoding = "2.3.1" # Other dependencies rustc-hash = "1.1.0" diff --git a/apps/readme/src/main.rs b/apps/readme/src/main.rs index b30f22ac6..1b551a0bc 100644 --- a/apps/readme/src/main.rs +++ b/apps/readme/src/main.rs @@ -3,7 +3,7 @@ mod readme_application; use blitz_html::HtmlDocument; use blitz_net::Provider; -use blitz_traits::navigation::NavigationProvider; +use blitz_traits::navigation::{NavigationOptions, NavigationProvider}; use markdown::{markdown_to_html, BLITZ_MD_STYLES, GITHUB_MD_STYLES}; use notify::{Error as NotifyError, Event as NotifyEvent, RecursiveMode, Watcher as _}; use readme_application::{ReadmeApplication, ReadmeEvent}; @@ -27,8 +27,10 @@ struct ReadmeNavigationProvider { } impl NavigationProvider for ReadmeNavigationProvider { - fn navigate_new_page(&self, url: String) { - let _ = self.proxy.send_event(BlitzShellEvent::Navigate(url)); + fn navigate_to(&self, opts: NavigationOptions) { + let _ = self + .proxy + .send_event(BlitzShellEvent::Navigate(Box::new(opts))); } } diff --git a/apps/readme/src/readme_application.rs b/apps/readme/src/readme_application.rs index 3f2b7fc58..2d3fae44d 100644 --- a/apps/readme/src/readme_application.rs +++ b/apps/readme/src/readme_application.rs @@ -130,8 +130,8 @@ impl ApplicationHandler for ReadmeApplication { self.reload_document(true); } } - BlitzShellEvent::Navigate(url) => { - self.raw_url = url; + BlitzShellEvent::Navigate(opts) => { + self.raw_url = opts.url.into(); self.reload_document(false); } event => self.inner.user_event(event_loop, event), diff --git a/packages/blitz-dom/Cargo.toml b/packages/blitz-dom/Cargo.toml index 16fde17f7..876dd2aac 100644 --- a/packages/blitz-dom/Cargo.toml +++ b/packages/blitz-dom/Cargo.toml @@ -50,6 +50,7 @@ usvg = { workspace = true, optional = true } woff = { workspace = true, optional = true } woff2 = { workspace = true, optional = true } html-escape = { workspace = true } +percent-encoding = { workspace = true } # IO & Networking url = { workspace = true, features = ["serde"] } diff --git a/packages/blitz-dom/src/document.rs b/packages/blitz-dom/src/document.rs index a48b37a43..f6cd690cd 100644 --- a/packages/blitz-dom/src/document.rs +++ b/packages/blitz-dom/src/document.rs @@ -126,6 +126,8 @@ pub struct BaseDocument { pub changed: HashSet, + pub controls_to_form: HashMap, + /// Network provider. Can be used to fetch assets. pub net_provider: SharedProvider, @@ -211,6 +213,7 @@ impl BaseDocument { focus_node_id: None, active_node_id: None, changed: HashSet::new(), + controls_to_form: HashMap::new(), net_provider: Arc::new(DummyNetProvider::default()), navigation_provider: Arc::new(DummyNavigationProvider {}), }; @@ -628,6 +631,7 @@ impl BaseDocument { Resource::Font(bytes) => { self.font_ctx.collection.register_fonts(bytes.to_vec()); } + _ => {} } } diff --git a/packages/blitz-dom/src/events/keyboard.rs b/packages/blitz-dom/src/events/keyboard.rs index 05027ee37..66b9a3222 100644 --- a/packages/blitz-dom/src/events/keyboard.rs +++ b/packages/blitz-dom/src/events/keyboard.rs @@ -4,6 +4,7 @@ use crate::{ }; use blitz_traits::BlitzKeyEvent; use keyboard_types::{Key, Modifiers}; +use markup5ever::local_name; use parley::{FontContext, LayoutContext}; pub(crate) fn handle_keypress(doc: &mut BaseDocument, target: usize, event: BlitzKeyEvent) { @@ -13,14 +14,18 @@ pub(crate) fn handle_keypress(doc: &mut BaseDocument, target: usize, event: Blit } let node = &mut doc.nodes[node_id]; - let text_input_data = node - .data - .downcast_element_mut() - .and_then(|el| el.text_input_data_mut()); + let Some(element_data) = node.element_data_mut() else { + return; + }; - if let Some(input_data) = text_input_data { + if let Some(input_data) = element_data.text_input_data_mut() { println!("Sent text event to {}", node_id); - apply_keypress_event(input_data, &mut doc.font_ctx, &mut doc.layout_ctx, event); + let implicit_submission = + apply_keypress_event(input_data, &mut doc.font_ctx, &mut doc.layout_ctx, event); + + if implicit_submission { + implicit_form_submission(doc, target); + } } } } @@ -35,10 +40,10 @@ pub(crate) fn apply_keypress_event( font_ctx: &mut FontContext, layout_ctx: &mut LayoutContext, event: BlitzKeyEvent, -) { +) -> bool { // Do nothing if it is a keyup event if !event.state.is_pressed() { - return; + return false; } let mods = event.modifiers; @@ -165,9 +170,50 @@ pub(crate) fn apply_keypress_event( Key::Enter => { if is_multiline { driver.insert_or_replace_selection("\n"); + } else { + return true; } } Key::Character(s) => driver.insert_or_replace_selection(&s), _ => {} }; + false +} + +/// https://html.spec.whatwg.org/multipage/form-control-infrastructure.html#field-that-blocks-implicit-submission +fn implicit_form_submission(doc: &BaseDocument, text_target: usize) { + let Some(form_owner_id) = doc.controls_to_form.get(&text_target) else { + return; + }; + if doc + .controls_to_form + .iter() + .filter(|(_control_id, form_id)| *form_id == form_owner_id) + .filter_map(|(control_id, _)| doc.nodes[*control_id].element_data()) + .filter(|element_data| { + element_data.attr(local_name!("type")).is_some_and(|t| { + matches!( + t, + "text" + | "search" + | "email" + | "url" + | "tel" + | "password" + | "date" + | "month" + | "week" + | "time" + | "datetime-local" + | "number" + ) + }) + }) + .count() + > 1 + { + return; + } + + doc.submit_form(*form_owner_id, *form_owner_id); } diff --git a/packages/blitz-dom/src/events/mouse.rs b/packages/blitz-dom/src/events/mouse.rs index fcccf7afc..5cbbc9e4c 100644 --- a/packages/blitz-dom/src/events/mouse.rs +++ b/packages/blitz-dom/src/events/mouse.rs @@ -1,4 +1,4 @@ -use blitz_traits::{HitResult, MouseEventButtons}; +use blitz_traits::{navigation::NavigationOptions, HitResult, MouseEventButtons}; use markup5ever::local_name; use crate::{node::NodeSpecificData, util::resolve_url, BaseDocument, Node}; @@ -99,10 +99,14 @@ pub(crate) fn handle_click(doc: &mut BaseDocument, _target: usize, x: f32, y: f3 let mut maybe_hit = doc.hit(x, y); while let Some(hit) = maybe_hit { - let node = &mut doc.nodes[hit.node_id]; + let node_id = hit.node_id; + let maybe_element = { + let node = &mut doc.nodes[node_id]; + node.data.downcast_element_mut() + }; - let Some(el) = node.data.downcast_element_mut() else { - maybe_hit = parent_hit(node, x, y); + let Some(el) = maybe_element else { + maybe_hit = parent_hit(&doc.nodes[node_id], x, y); continue; }; @@ -117,20 +121,18 @@ pub(crate) fn handle_click(doc: &mut BaseDocument, _target: usize, x: f32, y: f3 && matches!(el.attr(local_name!("type")), Some("checkbox")) { BaseDocument::toggle_checkbox(el); - doc.set_focus_to(hit.node_id); + doc.set_focus_to(node_id); return; } else if el.name.local == local_name!("input") && matches!(el.attr(local_name!("type")), Some("radio")) { - let node_id = node.id; let radio_set = el.attr(local_name!("name")).unwrap().to_string(); BaseDocument::toggle_radio(doc, radio_set, node_id); - BaseDocument::set_focus_to(doc, hit.node_id); + BaseDocument::set_focus_to(doc, node_id); return; } // Clicking labels triggers click, and possibly input event, of associated input else if el.name.local == local_name!("label") { - let node_id = node.id; if let Some(target_node_id) = doc .label_bound_input_elements(node_id) .first() @@ -146,7 +148,8 @@ pub(crate) fn handle_click(doc: &mut BaseDocument, _target: usize, x: f32, y: f3 } else if el.name.local == local_name!("a") { if let Some(href) = el.attr(local_name!("href")) { if let Some(url) = resolve_url(&doc.base_url, href) { - doc.navigation_provider.navigate_new_page(url.into()); + doc.navigation_provider + .navigate_to(NavigationOptions::new(url, doc.id())); } else { println!( "{href} is not parseable as a url. : {base_url:?}", @@ -157,10 +160,17 @@ pub(crate) fn handle_click(doc: &mut BaseDocument, _target: usize, x: f32, y: f3 } else { println!("Clicked link without href: {:?}", el.attrs()); } + } else if el.name.local == local_name!("input") + && el.attr(local_name!("type")) == Some("submit") + || el.name.local == local_name!("button") + { + if let Some(form_owner) = doc.controls_to_form.get(&node_id) { + doc.submit_form(*form_owner, node_id); + } } // No match. Recurse up to parent. - maybe_hit = parent_hit(&doc.nodes[hit.node_id], x, y) + maybe_hit = parent_hit(&doc.nodes[node_id], x, y) } // If nothing is matched then clear focus diff --git a/packages/blitz-dom/src/form.rs b/packages/blitz-dom/src/form.rs new file mode 100644 index 000000000..eca16885f --- /dev/null +++ b/packages/blitz-dom/src/form.rs @@ -0,0 +1,469 @@ +use markup5ever::{local_name, LocalName}; + +use crate::{util::TreeTraverser, BaseDocument, ElementNodeData, Node}; +use blitz_traits::navigation::{DocumentResource, NavigationOptions, RequestContentType}; +use core::str::FromStr; + +impl BaseDocument { + /// Resets the form owner for a given node by either using an explicit form attribute + /// or finding the nearest ancestor form element + /// + /// # Arguments + /// * `node_id` - The ID of the node whose form owner needs to be reset + /// + /// https://html.spec.whatwg.org/multipage/form-control-infrastructure.html#reset-the-form-owner + pub fn reset_form_owner(&mut self, node_id: usize) { + let node = &self.nodes[node_id]; + let Some(element) = node.element_data() else { + return; + }; + + // First try explicit form attribute + let final_owner_id = element + .attr(local_name!("form")) + .and_then(|owner| self.nodes_to_id.get(owner)) + .and_then(|owner_id| { + if self + .nodes + .get(*owner_id) + .and_then(|node| node.element_data()) + .is_some_and(|element_data| element_data.name.local == local_name!("form")) + { + Some(*owner_id) + } else { + None + } + }) + .or_else(|| { + self.node_chain(node_id).drain(..).find_map(|ancestor_id| { + let node = &self.nodes[ancestor_id]; + node.element_data() + .filter(|element| element.name.local == local_name!("form")) + .map(|_| ancestor_id) + }) + }); + + if let Some(final_owner_id) = final_owner_id { + self.controls_to_form.insert(node_id, final_owner_id); + } + } + + /// Submits a form with the given form node ID and submitter node ID + /// + /// # Arguments + /// * `node_id` - The ID of the form node to submit + /// * `submitter_id` - The ID of the node that triggered the submission + /// + /// https://html.spec.whatwg.org/multipage/form-control-infrastructure.html#form-submission-algorithm + pub fn submit_form(&self, node_id: usize, submitter_id: usize) { + let node = &self.nodes[node_id]; + let Some(element) = node.element_data() else { + return; + }; + + let entry = construct_entry_list(self, node_id, submitter_id); + + let method = get_form_attr( + self, + element, + local_name!("method"), + submitter_id, + local_name!("formmethod"), + ) + .and_then(|method| method.parse::().ok()) + .unwrap_or(FormMethod::Get); + + let action = get_form_attr( + self, + element, + local_name!("action"), + submitter_id, + local_name!("formaction"), + ) + .unwrap_or_default(); + + let mut parsed_action = self.resolve_url(action); + + let scheme = parsed_action.scheme(); + + let enctype = get_form_attr( + self, + element, + local_name!("enctype"), + submitter_id, + local_name!("formenctype"), + ) + .and_then(|enctype| enctype.parse::().ok()) + .unwrap_or(RequestContentType::FormUrlEncoded); + + let mut post_resource = None; + + match (scheme, method) { + ("http" | "https" | "data", FormMethod::Get) => { + let pairs = entry.convert_to_list_of_name_value_pairs(); + + let mut query = String::new(); + url::form_urlencoded::Serializer::new(&mut query).extend_pairs(pairs); + + parsed_action.set_query(Some(&query)); + } + + ("http" | "https", FormMethod::Post) => match enctype { + RequestContentType::FormUrlEncoded => { + let pairs = entry.convert_to_list_of_name_value_pairs(); + let mut body = String::new(); + url::form_urlencoded::Serializer::new(&mut body).extend_pairs(pairs); + post_resource = Some(DocumentResource::PostResource { + body: body.into(), + content_type: enctype, + }) + } + RequestContentType::MultipartFormData => { + #[cfg(feature = "tracing")] + tracing::warn!("Multipart Forms are currently not supported"); + return; + } + RequestContentType::TextPlain => { + let pairs = entry.convert_to_list_of_name_value_pairs(); + let body = encode_text_plain(&pairs).into(); + post_resource = Some(DocumentResource::PostResource { + body, + content_type: enctype, + }) + } + }, + ("mailto", FormMethod::Get) => { + let pairs = entry.convert_to_list_of_name_value_pairs(); + + parsed_action.query_pairs_mut().extend_pairs(pairs); + } + ("mailto", FormMethod::Post) => { + let pairs = entry.convert_to_list_of_name_value_pairs(); + let body = match enctype { + RequestContentType::TextPlain => { + let body = encode_text_plain(&pairs); + + /// https://url.spec.whatwg.org/#default-encode-set + const DEFAULT_ENCODE_SET: percent_encoding::AsciiSet = + percent_encoding::CONTROLS + // Query Set + .add(b' ') + .add(b'"') + .add(b'#') + .add(b'<') + .add(b'>') + // Path Set + .add(b'?') + .add(b'`') + .add(b'{') + .add(b'}'); + + // Set body to the result of running UTF-8 percent-encode on body using the default encode set. [URL] + percent_encoding::utf8_percent_encode(&body, &DEFAULT_ENCODE_SET) + .to_string() + } + _ => { + let mut body = String::new(); + url::form_urlencoded::Serializer::new(&mut body).extend_pairs(pairs); + body + } + }; + let mut query = if let Some(query) = parsed_action.query() { + let mut query = query.to_string(); + query.push('&'); + query + } else { + String::new() + }; + query.push_str("body="); + query.push_str(&body); + parsed_action.set_query(Some(&query)); + } + _ => { + #[cfg(feature = "tracing")] + tracing::warn!( + "Scheme {} with method {:?} is not implemented", + scheme, + method + ); + return; + } + } + + let navigation_options = + NavigationOptions::new(parsed_action, self.id()).set_document_resource(post_resource); + + self.navigation_provider.navigate_to(navigation_options) + } +} + +/// Constructs a list of form entries from form controls +/// +/// # Arguments +/// * `doc` - Reference to the base document +/// * `form_id` - ID of the form element +/// * `submitter_id` - ID of the element that triggered form submission +/// +/// # Returns +/// Returns an EntryList containing all valid form control entries +/// +/// https://html.spec.whatwg.org/multipage/form-control-infrastructure.html#constructing-the-form-data-set +fn construct_entry_list(doc: &BaseDocument, form_id: usize, submitter_id: usize) -> EntryList { + let mut entry_list = EntryList::new(); + + let mut create_entry = |name: &str, value: &str| { + entry_list.0.push(Entry::new(name, value)); + }; + + fn datalist_ancestor(doc: &BaseDocument, node: &Node) -> bool { + node.element_data() + .is_some_and(|element| element.name.local == local_name!("datalist")) + || node + .parent + .and_then(|parent_id| doc.get_node(parent_id)) + .is_some_and(|node| datalist_ancestor(doc, node)) + } + + for control_id in TreeTraverser::new(doc) { + let Some(node) = doc.get_node(control_id) else { + continue; + }; + let Some(element) = node.element_data() else { + continue; + }; + + // Check if the form owner is same as form_id + if doc + .controls_to_form + .get(&control_id) + .map(|owner_id| *owner_id != form_id) + .unwrap_or(true) + { + continue; + } + + let element_type = element.attr(local_name!("type")); + + // If any of the following are true: + // field has a datalist element ancestor; + // field is disabled; + // field is a button but it is not submitter; + // field is an input element whose type attribute is in the Checkbox state and whose checkedness is false; or + // field is an input element whose type attribute is in the Radio Button state and whose checkedness is false, + // then continue. + if datalist_ancestor(doc, node) + || element.attr(local_name!("disabled")).is_some() + || element.name.local == local_name!("button") && node.id != submitter_id + || element.name.local == local_name!("input") + && matches!(element_type, Some("checkbox" | "radio")) + && !element.checkbox_input_checked().unwrap_or(false) + { + continue; + } + + // If the field element is an input element whose type attribute is in the Image Button state, then: + if element_type == Some("image") { + // If the field element is not submitter, then continue. + if node.id != submitter_id { + continue; + } + // TODO: If the field element has a name attribute specified and its value is not the empty string, let name be that value followed by U+002E (.). Otherwise, let name be the empty string. + // Let namex be the concatenation of name and U+0078 (x). + // Let namey be the concatenation of name and U+0079 (y). + // Let (x, y) be the selected coordinate. + // Create an entry with namex and x, and append it to entry list. + // Create an entry with namey and y, and append it to entry list. + // Continue. + continue; + } + + // TODO: If the field is a form-associated custom element, + // then perform the entry construction algorithm given field and entry list, + // then continue. + + // If either the field element does not have a name attribute specified, or its name attribute's value is the empty string, then continue. + // Let name be the value of the field element's name attribute. + let Some(name) = element.attr(local_name!("name")) else { + continue; + }; + + // TODO: If the field element is a select element, + // then for each option element in the select element's + // list of options whose selectedness is true and that is not disabled, + // create an entry with name and the value of the option element, + // and append it to entry list. + + // Otherwise, if the field element is an input element whose type attribute is in the Checkbox state or the Radio Button state, then: + if element.name.local == local_name!("input") + && matches!(element_type, Some("checkbox" | "radio")) + { + // If the field element has a value attribute specified, then let value be the value of that attribute; otherwise, let value be the string "on". + let value = element.attr(local_name!("value")).unwrap_or("on"); + // Create an entry with name and value, and append it to entry list. + create_entry(name, value); + } + // TODO: Otherwise, if the field element is an input element whose type attribute is in the File Upload state, then: + // + // If there are no selected files, then create an entry with name and a new File object with an empty name, application/octet-stream as type, and an empty body, and append it to entry list. + // Otherwise, for each file in selected files, create an entry with name and a File object representing the file, and append it to entry list. + + //Otherwise, if the field element is an input element whose type attribute is in the Hidden state and name is an ASCII case-insensitive match for "_charset_": + else if element.name.local == local_name!("input") + && element_type == Some("hidden") + && name.eq_ignore_ascii_case("_charset_") + { + // Let charset be the name of encoding. + let charset = "UTF-8"; // TODO: Support multiple encodings. + // Create an entry with name and charset, and append it to entry list. + create_entry(name, charset); + } + // Otherwise, create an entry with name and the value of the field element, and append it to entry list. + else { + let value = element.attr(local_name!("value")); + if let Some(value) = value { + create_entry(name, value); + } + // ... + else if let Some(text) = element.text_input_data() { + create_entry(name, &text.editor.text().to_string()); + } + } + } + entry_list +} + +/// Normalizes line endings in a string according to HTML spec +/// +/// Converts single CR or LF to CRLF pairs according to HTML form submission requirements +/// +/// # Arguments +/// * `input` - The string whose line endings need to be normalized +/// +/// # Returns +/// A new string with normalized CRLF line endings +fn normalize_line_endings(input: &str) -> String { + // Replace every occurrence of U+000D (CR) not followed by U+000A (LF), + // and every occurrence of U+000A (LF) not preceded by U+000D (CR), + // in value, by a string consisting of U+000D (CR) and U+000A (LF). + + let mut result = String::with_capacity(input.len()); + let mut chars = input.chars().peekable(); + + while let Some(current) = chars.next() { + match (current, chars.peek()) { + ('\r', Some('\n')) => { + result.push_str("\r\n"); + chars.next(); + } + ('\r' | '\n', _) => { + result.push_str("\r\n"); + } + _ => result.push(current), + } + } + + result +} + +fn get_form_attr<'a>( + doc: &'a BaseDocument, + form: &'a ElementNodeData, + form_local: impl PartialEq, + submitter_id: usize, + submitter_local: impl PartialEq, +) -> Option<&'a str> { + get_submitter_attr(doc, submitter_id, submitter_local).or_else(|| form.attr(form_local)) +} + +fn get_submitter_attr( + doc: &BaseDocument, + submitter_id: usize, + local_name: impl PartialEq, +) -> Option<&str> { + doc.get_node(submitter_id) + .and_then(|node| node.element_data()) + .and_then(|element_data| { + if element_data.name.local == local_name!("button") + && element_data.attr(local_name!("type")) == Some("submit") + { + element_data.attr(local_name) + } else { + None + } + }) +} +/// Encodes form data as text/plain according to HTML spec +/// +/// # Arguments +/// * `input` - Slice of name-value pairs to encode +/// +/// # Returns +/// A string with the encoded form data +/// +/// https://html.spec.whatwg.org/multipage/form-control-infrastructure.html#text/plain-encoding-algorithm +fn encode_text_plain(input: &[(String, String)]) -> String { + let mut out = String::new(); + for (name, value) in input { + out.push_str(name); + out.push('='); + out.push_str(value); + out.push_str("\r\n"); + } + out +} + +#[derive(Debug, Copy, Clone, PartialEq, Eq)] +enum FormMethod { + Get, + Post, + Dialog, +} +impl FromStr for FormMethod { + type Err = (); + fn from_str(s: &str) -> Result { + Ok(match s.to_lowercase().as_str() { + "get" => FormMethod::Get, + "post" => FormMethod::Post, + "dialog" => FormMethod::Dialog, + _ => return Err(()), + }) + } +} + +/// A list of form entries used for form submission +#[derive(Debug, Clone, PartialEq, Default)] +pub struct EntryList(pub Vec); +impl EntryList { + /// Creates a new empty EntryList + pub fn new() -> Self { + EntryList(Vec::new()) + } + + /// Converts the entry list to a vector of name-value pairs with normalized line endings + pub fn convert_to_list_of_name_value_pairs(&self) -> Vec<(String, String)> { + self.0 + .iter() + .map(|entry| { + let name = normalize_line_endings(&entry.name); + let value = normalize_line_endings(&entry.value); + (name, value) + }) + .collect() + } +} + +/// A single form entry consisting of a name and value +#[derive(Debug, Clone, PartialEq, Default)] +pub struct Entry { + pub name: String, + pub value: String, +} + +impl Entry { + pub fn new(name: &str, value: &str) -> Self { + Self { + name: name.to_string(), + value: value.to_string(), + } + } +} diff --git a/packages/blitz-dom/src/lib.rs b/packages/blitz-dom/src/lib.rs index 011c42581..b357c64ea 100644 --- a/packages/blitz-dom/src/lib.rs +++ b/packages/blitz-dom/src/lib.rs @@ -33,6 +33,8 @@ pub mod stylo; pub mod stylo_to_cursor_icon; pub mod stylo_to_parley; +mod form; + pub mod util; pub mod debug; diff --git a/packages/blitz-dom/src/net.rs b/packages/blitz-dom/src/net.rs index 19685f79e..3dde78bd6 100644 --- a/packages/blitz-dom/src/net.rs +++ b/packages/blitz-dom/src/net.rs @@ -30,6 +30,10 @@ pub enum Resource { Svg(usize, ImageType, Box), Css(usize, DocumentStyleSheet), Font(Bytes), + Navigation { + url: String, + document: Bytes, + }, } pub struct CssHandler { pub node: usize, @@ -291,3 +295,22 @@ impl NetHandler for ImageHandler { } } } + +pub struct NavigationHandler(String); +impl NavigationHandler { + pub fn boxed(url: impl Into) -> Box { + Box::new(Self(url.into())) + } +} +impl NetHandler for NavigationHandler { + type Data = Resource; + fn bytes(self: Box, doc_id: usize, bytes: Bytes, callback: SharedCallback) { + callback.call( + doc_id, + Resource::Navigation { + url: self.0, + document: bytes, + }, + ); + } +} diff --git a/packages/blitz-dom/src/util.rs b/packages/blitz-dom/src/util.rs index 607da188f..13e44dc34 100644 --- a/packages/blitz-dom/src/util.rs +++ b/packages/blitz-dom/src/util.rs @@ -1,4 +1,7 @@ -use crate::node::{Node, NodeData}; +use crate::{ + node::{Node, NodeData}, + BaseDocument, +}; use color::{AlphaColor, Srgb}; use style::color::AbsoluteColor; @@ -119,3 +122,30 @@ impl ToColorColor for AbsoluteColor { ) } } + +pub struct TreeTraverser<'a> { + doc: &'a BaseDocument, + stack: Vec, +} + +impl<'a> TreeTraverser<'a> { + pub fn new(doc: &'a BaseDocument) -> Self { + Self::new_with_root(doc, 0) + } + pub fn new_with_root(doc: &'a BaseDocument, root: usize) -> Self { + TreeTraverser { + doc, + stack: vec![root], + } + } +} +impl Iterator for TreeTraverser<'_> { + type Item = usize; + + fn next(&mut self) -> Option { + let id = self.stack.pop()?; + let node = self.doc.get_node(id)?; + self.stack.extend(node.children.iter().rev()); + Some(id) + } +} diff --git a/packages/blitz-html/src/html_sink.rs b/packages/blitz-html/src/html_sink.rs index cf8181ab2..06826d893 100644 --- a/packages/blitz-html/src/html_sink.rs +++ b/packages/blitz-html/src/html_sink.rs @@ -29,6 +29,7 @@ pub struct DocumentHtmlParser<'a> { doc_id: usize, doc: RefCell<&'a mut BaseDocument>, style_nodes: RefCell>, + form_nodes: RefCell>, /// Errors that occurred during parsing. pub errors: RefCell>>, @@ -49,6 +50,7 @@ impl DocumentHtmlParser<'_> { doc_id: doc.id(), doc: RefCell::new(doc), style_nodes: RefCell::new(Vec::new()), + form_nodes: RefCell::new(Vec::new()), errors: RefCell::new(Vec::new()), quirks_mode: Cell::new(QuirksMode::NoQuirks), net_provider, @@ -198,6 +200,10 @@ impl<'b> TreeSink for DocumentHtmlParser<'b> { doc.process_style_element(*id); } + for id in self.form_nodes.borrow().iter() { + doc.reset_form_owner(*id); + } + for error in self.errors.borrow().iter() { println!("ERROR: {}", error); } @@ -246,6 +252,14 @@ impl<'b> TreeSink for DocumentHtmlParser<'b> { self.doc.borrow_mut().nodes_to_id.insert(id_attr, id); } + // If node is an listed form element add to form elements list + if matches!( + name.local.as_ref(), + "button" | "fieldset" | "input" | "select" | "textarea" | "object" | "output" + ) { + self.form_nodes.borrow_mut().push(id); + } + // Custom post-processing by element tag name match name.local.as_ref() { "link" => self.load_linked_stylesheet(id), diff --git a/packages/blitz-shell/src/application.rs b/packages/blitz-shell/src/application.rs index 55d8660a0..de4fffee0 100644 --- a/packages/blitz-shell/src/application.rs +++ b/packages/blitz-shell/src/application.rs @@ -16,7 +16,7 @@ type D = BaseDocument; pub struct BlitzApplication, Rend: DocumentRenderer> { pub windows: HashMap>, pending_windows: Vec>, - proxy: EventLoopProxy, + pub proxy: EventLoopProxy, #[cfg(all(feature = "menu", not(any(target_os = "android", target_os = "ios"))))] menu_channel: muda::MenuEventReceiver, @@ -113,7 +113,6 @@ impl, Rend: DocumentRenderer> ApplicationHandler window.poll(); }; } - BlitzShellEvent::ResourceLoad { doc_id, data } => { // TODO: Handle multiple documents per window if let Some(window) = self.window_mut_by_doc_id(doc_id) { @@ -142,7 +141,7 @@ impl, Rend: DocumentRenderer> ApplicationHandler BlitzShellEvent::Embedder(_) => { // Do nothing. Should be handled by embedders (if required). } - BlitzShellEvent::Navigate(_url) => { + BlitzShellEvent::Navigate(_opts) => { // Do nothing. Should be handled by embedders (if required). } } diff --git a/packages/blitz-shell/src/event.rs b/packages/blitz-shell/src/event.rs index 36ea0dfe3..80ce0fc6b 100644 --- a/packages/blitz-shell/src/event.rs +++ b/packages/blitz-shell/src/event.rs @@ -1,3 +1,4 @@ +use blitz_traits::navigation::NavigationOptions; use futures_util::task::ArcWake; use std::{any::Any, sync::Arc}; use winit::{event_loop::EventLoopProxy, window::WindowId}; @@ -28,7 +29,7 @@ pub enum BlitzShellEvent { Embedder(Arc), /// Navigate to another URL (triggered by e.g. clicking a link) - Navigate(String), + Navigate(Box), } impl BlitzShellEvent { pub fn embedder_event(value: T) -> Self { diff --git a/packages/blitz-traits/src/navigation.rs b/packages/blitz-traits/src/navigation.rs index 7722335ca..c2aa1ae0d 100644 --- a/packages/blitz-traits/src/navigation.rs +++ b/packages/blitz-traits/src/navigation.rs @@ -1,12 +1,99 @@ +use bytes::Bytes; +use core::str::FromStr; +use http::{HeaderMap, Method}; +use url::Url; + +use crate::net::Request; + /// A provider to enable a document to bubble up navigation events (e.g. clicking a link) pub trait NavigationProvider: Send + Sync + 'static { - fn navigate_new_page(&self, url: String); + fn navigate_to(&self, options: NavigationOptions); } pub struct DummyNavigationProvider; impl NavigationProvider for DummyNavigationProvider { - fn navigate_new_page(&self, _url: String) { + fn navigate_to(&self, _options: NavigationOptions) { // Default impl: do nothing } } + +#[non_exhaustive] +#[derive(Debug, Clone)] +pub struct NavigationOptions { + /// The URL to navigate to + pub url: Url, + + /// Source document for the navigation + pub source_document: usize, + + pub document_resource: Option, +} + +impl NavigationOptions { + pub fn new(url: Url, source_document: usize) -> Self { + Self { + url, + source_document, + document_resource: None, + } + } + pub fn set_document_resource(mut self, document_resource: Option) -> Self { + self.document_resource = document_resource; + self + } + + pub fn into_request(self) -> Request { + if let Some(DocumentResource::PostResource { + body, + content_type: _, + }) = self.document_resource + { + Request { + url: self.url, + method: Method::POST, + headers: HeaderMap::new(), + body, + } + } else { + Request { + url: self.url, + method: Method::GET, + headers: HeaderMap::new(), + body: Bytes::new(), + } + } + } +} + +#[derive(Debug, Clone)] +pub enum DocumentResource { + String(String), + PostResource { + body: Bytes, + content_type: RequestContentType, + }, +} + +/// Supported content types for HTTP requests +#[derive(Debug, Clone)] +pub enum RequestContentType { + /// application/x-www-form-urlencoded + FormUrlEncoded, + /// multipart/form-data + MultipartFormData, + /// text/plain + TextPlain, +} + +impl FromStr for RequestContentType { + type Err = (); + fn from_str(s: &str) -> Result { + Ok(match s { + "application/x-www-form-urlencoded" => RequestContentType::FormUrlEncoded, + "multipart/form-data" => RequestContentType::MultipartFormData, + "text/plain" => RequestContentType::TextPlain, + _ => return Err(()), + }) + } +} From 10eb29f455e75c99b496895ab24872cf1d7155b1 Mon Sep 17 00:00:00 2001 From: koko Date: Tue, 4 Mar 2025 00:58:38 +0100 Subject: [PATCH 2/4] Use custom iterator instead of node_chain --- packages/blitz-dom/src/document.rs | 33 ++++++------------------------ packages/blitz-dom/src/form.rs | 7 +++++-- packages/blitz-dom/src/util.rs | 30 +++++++++++++++++++++++++++ 3 files changed, 41 insertions(+), 29 deletions(-) diff --git a/packages/blitz-dom/src/document.rs b/packages/blitz-dom/src/document.rs index f6cd690cd..b04980b2b 100644 --- a/packages/blitz-dom/src/document.rs +++ b/packages/blitz-dom/src/document.rs @@ -2,7 +2,7 @@ use crate::events::handle_event; use crate::layout::construct::collect_layout_children; use crate::node::{ImageData, NodeSpecificData, Status, TextBrush}; use crate::stylo_to_cursor_icon::stylo_to_cursor_icon; -use crate::util::{resolve_url, ImageType}; +use crate::util::{resolve_url, AncestorTraverser, ImageType, TreeTraverser}; use crate::{ElementNodeData, Node, NodeData, TextNodeData}; use app_units::Au; use blitz_traits::navigation::{DummyNavigationProvider, NavigationProvider}; @@ -24,7 +24,7 @@ use style::values::GenericAtomIdent; use crate::net::{Resource, StylesheetLoader}; use selectors::{matching::QuirksMode, Element}; use slab::Slab; -use std::collections::{BTreeMap, Bound, HashMap, HashSet, VecDeque}; +use std::collections::{BTreeMap, Bound, HashMap, HashSet}; use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::Arc; use style::media_queries::MediaType; @@ -1181,35 +1181,14 @@ impl BaseDocument { where F: FnMut(usize, &Node), { - let mut stack = VecDeque::new(); - stack.push_front(0); - - while let Some(node_key) = stack.pop_back() { - let node = &self.nodes[node_key]; - visit(node_key, node); - - for &child_key in &node.children { - stack.push_front(child_key); - } - } + TreeTraverser::new(self).for_each(|node_id| visit(node_id, &self.nodes[node_id])); } /// Collect the nodes into a chain by traversing upwards pub fn node_chain(&self, node_id: usize) -> Vec { - let mut next_node_id = Some(node_id); - let mut chain = Vec::with_capacity(16); - - while let Some(node_id) = next_node_id { - let node = &self.tree()[node_id]; - - if node.is_element() { - chain.push(node_id); - } - - next_node_id = node.parent; - } - - chain + AncestorTraverser::new(self, node_id) + .filter(|ancestor_id| self.nodes[*ancestor_id].is_element()) + .collect() } } diff --git a/packages/blitz-dom/src/form.rs b/packages/blitz-dom/src/form.rs index eca16885f..c37157173 100644 --- a/packages/blitz-dom/src/form.rs +++ b/packages/blitz-dom/src/form.rs @@ -1,6 +1,9 @@ use markup5ever::{local_name, LocalName}; -use crate::{util::TreeTraverser, BaseDocument, ElementNodeData, Node}; +use crate::{ + util::{AncestorTraverser, TreeTraverser}, + BaseDocument, ElementNodeData, Node, +}; use blitz_traits::navigation::{DocumentResource, NavigationOptions, RequestContentType}; use core::str::FromStr; @@ -35,7 +38,7 @@ impl BaseDocument { } }) .or_else(|| { - self.node_chain(node_id).drain(..).find_map(|ancestor_id| { + AncestorTraverser::new(self, node_id).find_map(|ancestor_id| { let node = &self.nodes[ancestor_id]; node.element_data() .filter(|element| element.name.local == local_name!("form")) diff --git a/packages/blitz-dom/src/util.rs b/packages/blitz-dom/src/util.rs index 13e44dc34..13f540373 100644 --- a/packages/blitz-dom/src/util.rs +++ b/packages/blitz-dom/src/util.rs @@ -123,15 +123,20 @@ impl ToColorColor for AbsoluteColor { } } +#[derive(Clone)] +/// An pre-order tree traverser for a [BaseDocument](crate::document::BaseDocument). pub struct TreeTraverser<'a> { doc: &'a BaseDocument, stack: Vec, } impl<'a> TreeTraverser<'a> { + /// Creates a new tree traverser for the given document which starts at the root node. pub fn new(doc: &'a BaseDocument) -> Self { Self::new_with_root(doc, 0) } + + /// Creates a new tree traverser for the given document which starts at the specified node. pub fn new_with_root(doc: &'a BaseDocument, root: usize) -> Self { TreeTraverser { doc, @@ -149,3 +154,28 @@ impl Iterator for TreeTraverser<'_> { Some(id) } } + +#[derive(Clone)] +/// An ancestor traverser for a [BaseDocument](crate::document::BaseDocument). +pub struct AncestorTraverser<'a> { + doc: &'a BaseDocument, + current: usize, +} +impl<'a> AncestorTraverser<'a> { + /// Creates a new ancestor traverser for the given document and node ID. + pub fn new(doc: &'a BaseDocument, node_id: usize) -> Self { + AncestorTraverser { + doc, + current: node_id, + } + } +} +impl Iterator for AncestorTraverser<'_> { + type Item = usize; + + fn next(&mut self) -> Option { + let current_node = self.doc.get_node(self.current)?; + self.current = current_node.parent?; + Some(self.current) + } +} From b17de95b20a053965477516e39f597d3c85306a4 Mon Sep 17 00:00:00 2001 From: koko Date: Tue, 4 Mar 2025 01:22:07 +0100 Subject: [PATCH 3/4] Simplify some things --- packages/blitz-dom/src/form.rs | 41 +++++++++++++--------------------- 1 file changed, 16 insertions(+), 25 deletions(-) diff --git a/packages/blitz-dom/src/form.rs b/packages/blitz-dom/src/form.rs index c37157173..61275355e 100644 --- a/packages/blitz-dom/src/form.rs +++ b/packages/blitz-dom/src/form.rs @@ -2,7 +2,7 @@ use markup5ever::{local_name, LocalName}; use crate::{ util::{AncestorTraverser, TreeTraverser}, - BaseDocument, ElementNodeData, Node, + BaseDocument, ElementNodeData, }; use blitz_traits::navigation::{DocumentResource, NavigationOptions, RequestContentType}; use core::str::FromStr; @@ -25,24 +25,16 @@ impl BaseDocument { let final_owner_id = element .attr(local_name!("form")) .and_then(|owner| self.nodes_to_id.get(owner)) - .and_then(|owner_id| { - if self - .nodes - .get(*owner_id) - .and_then(|node| node.element_data()) - .is_some_and(|element_data| element_data.name.local == local_name!("form")) - { - Some(*owner_id) - } else { - None - } + .copied() + .filter(|owner_id| { + self.get_node(*owner_id) + .is_some_and(|node| node.data.is_element_with_tag_name(&local_name!("form"))) }) .or_else(|| { - AncestorTraverser::new(self, node_id).find_map(|ancestor_id| { - let node = &self.nodes[ancestor_id]; - node.element_data() - .filter(|element| element.name.local == local_name!("form")) - .map(|_| ancestor_id) + AncestorTraverser::new(self, node_id).find(|ancestor_id| { + self.nodes[*ancestor_id] + .data + .is_element_with_tag_name(&local_name!("form")) }) }); @@ -218,13 +210,12 @@ fn construct_entry_list(doc: &BaseDocument, form_id: usize, submitter_id: usize) entry_list.0.push(Entry::new(name, value)); }; - fn datalist_ancestor(doc: &BaseDocument, node: &Node) -> bool { - node.element_data() - .is_some_and(|element| element.name.local == local_name!("datalist")) - || node - .parent - .and_then(|parent_id| doc.get_node(parent_id)) - .is_some_and(|node| datalist_ancestor(doc, node)) + fn datalist_ancestor(doc: &BaseDocument, node_id: usize) -> bool { + AncestorTraverser::new(doc, node_id).any(|node_id| { + doc.nodes[node_id] + .data + .is_element_with_tag_name(&local_name!("datalist")) + }) } for control_id in TreeTraverser::new(doc) { @@ -254,7 +245,7 @@ fn construct_entry_list(doc: &BaseDocument, form_id: usize, submitter_id: usize) // field is an input element whose type attribute is in the Checkbox state and whose checkedness is false; or // field is an input element whose type attribute is in the Radio Button state and whose checkedness is false, // then continue. - if datalist_ancestor(doc, node) + if datalist_ancestor(doc, node.id) || element.attr(local_name!("disabled")).is_some() || element.name.local == local_name!("button") && node.id != submitter_id || element.name.local == local_name!("input") From 520400b727d20dd353bb75648483ba334edebef3 Mon Sep 17 00:00:00 2001 From: koko Date: Thu, 6 Mar 2025 04:09:07 +0100 Subject: [PATCH 4/4] fix: add check for empty name attribute --- packages/blitz-dom/src/form.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/blitz-dom/src/form.rs b/packages/blitz-dom/src/form.rs index 61275355e..b0de6aa96 100644 --- a/packages/blitz-dom/src/form.rs +++ b/packages/blitz-dom/src/form.rs @@ -277,7 +277,10 @@ fn construct_entry_list(doc: &BaseDocument, form_id: usize, submitter_id: usize) // If either the field element does not have a name attribute specified, or its name attribute's value is the empty string, then continue. // Let name be the value of the field element's name attribute. - let Some(name) = element.attr(local_name!("name")) else { + let Some(name) = element + .attr(local_name!("name")) + .filter(|str| !str.is_empty()) + else { continue; };