From 46f593bc6d29d384b2bc6bdd46ee5e8653a7dd28 Mon Sep 17 00:00:00 2001 From: lizidev Date: Wed, 28 May 2025 13:34:48 +0800 Subject: [PATCH] feat: Support for the picture element --- Cargo.lock | 2 + Cargo.toml | 2 + packages/blitz-dom/Cargo.toml | 2 + packages/blitz-dom/src/document.rs | 68 ++- packages/blitz-dom/src/layout/construct.rs | 10 +- packages/blitz-dom/src/layout/mod.rs | 34 +- packages/blitz-dom/src/mutator.rs | 13 +- packages/blitz-dom/src/node/element.rs | 67 ++- packages/blitz-dom/src/node/image.rs | 476 +++++++++++++++++++++ packages/blitz-dom/src/node/mod.rs | 4 +- packages/blitz-dom/src/node/node.rs | 3 +- packages/blitz-net/src/lib.rs | 82 +++- packages/blitz-traits/src/navigation.rs | 2 + packages/blitz-traits/src/net.rs | 51 ++- 14 files changed, 758 insertions(+), 58 deletions(-) create mode 100644 packages/blitz-dom/src/node/image.rs diff --git a/Cargo.lock b/Cargo.lock index 1b3abef86..ba469bf3a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -689,12 +689,14 @@ dependencies = [ "bitflags 2.9.1", "blitz-traits", "color", + "cssparser", "cursor-icon", "euclid", "html-escape", "image", "keyboard-types", "markup5ever", + "mime", "objc2 0.6.1", "parley", "peniko", diff --git a/Cargo.toml b/Cargo.toml index a869f271d..61b0a654d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -31,6 +31,8 @@ style_traits = { version = "0.4", package = "stylo_traits" } style_config = { version = "0.4", package = "stylo_config" } style_dom = { version = "0.4", package = "stylo_dom" } selectors = { version = "0.29", package = "selectors" } +cssparser = "0.35" +mime = "0.3" markup5ever = "0.16.2" # needs to match stylo web_atoms version html5ever = "0.32" # needs to match stylo web_atoms version diff --git a/packages/blitz-dom/Cargo.toml b/packages/blitz-dom/Cargo.toml index a952aa3de..02b7fcf5a 100644 --- a/packages/blitz-dom/Cargo.toml +++ b/packages/blitz-dom/Cargo.toml @@ -39,6 +39,8 @@ euclid = { workspace = true } atomic_refcell = { workspace = true } markup5ever = { workspace = true } smallvec = { workspace = true } +cssparser = { workspace = true } +mime = { workspace = true } # DioxusLabs dependencies taffy = { workspace = true } diff --git a/packages/blitz-dom/src/document.rs b/packages/blitz-dom/src/document.rs index da2b52a42..64bf5ad2b 100644 --- a/packages/blitz-dom/src/document.rs +++ b/packages/blitz-dom/src/document.rs @@ -40,11 +40,15 @@ use style::values::computed::Overflow; use style::{ dom::{TDocument, TNode}, media_queries::{Device, MediaList}, + parser::ParserContext, selector_parser::SnapshotMap, shared_lock::{SharedRwLock, StylesheetGuards}, - stylesheets::{AllowImportRules, DocumentStyleSheet, Origin, Stylesheet, UrlExtraData}, + stylesheets::{ + AllowImportRules, CssRuleType, DocumentStyleSheet, Origin, Stylesheet, UrlExtraData, + }, stylist::Stylist, }; +use style_traits::ParsingMode; use taffy::AvailableSpace; use url::Url; @@ -153,6 +157,9 @@ pub struct BaseDocument { pub changed: HashSet, + // All image nodes. + image_nodes: HashSet, + /// A map from control node ID's to their associated forms node ID's pub controls_to_form: HashMap, @@ -246,6 +253,7 @@ impl BaseDocument { mousedown_node_id: None, is_animating: false, changed: HashSet::new(), + image_nodes: HashSet::new(), controls_to_form: HashMap::new(), net_provider: Arc::new(DummyNetProvider), navigation_provider: Arc::new(DummyNavigationProvider), @@ -414,6 +422,11 @@ impl BaseDocument { // Mark the new node as changed. self.changed.insert(id); + + if self.is_img_node(id) { + self.image_nodes.insert(id); + } + id } @@ -569,10 +582,13 @@ impl BaseDocument { match kind { ImageType::Image => { - node.element_data_mut().unwrap().special_data = - SpecialElementData::Image(Box::new(ImageData::Raster( - RasterImageData::new(width, height, image_data), + if let SpecialElementData::Image(context) = + &mut node.element_data_mut().unwrap().special_data + { + context.data = Some(ImageData::Raster(RasterImageData::new( + width, height, image_data, ))); + } // Clear layout cache node.cache.clear(); @@ -595,8 +611,11 @@ impl BaseDocument { match kind { ImageType::Image => { - node.element_data_mut().unwrap().special_data = - SpecialElementData::Image(Box::new(ImageData::Svg(tree))); + if let SpecialElementData::Image(context) = + &mut node.element_data_mut().unwrap().special_data + { + context.data = Some(ImageData::Svg(tree)); + } // Clear layout cache node.cache.clear(); @@ -892,6 +911,7 @@ impl BaseDocument { self.stylist.set_device(device, &guards) }; self.stylist.force_stylesheet_origins_dirty(origins); + self.environment_changes(); } pub fn stylist_device(&mut self) -> &Device { @@ -1092,6 +1112,42 @@ impl BaseDocument { false }) } + + /// Used to determine whether a document matches a media query string, + /// and to monitor a document to detect when it matches (or stops matching) that media query. + /// + /// https://developer.mozilla.org/en-US/docs/Web/API/Window/matchMedia + pub fn match_media(&self, media_query_string: &str) -> bool { + let mut input = cssparser::ParserInput::new(media_query_string); + let mut parser = cssparser::Parser::new(&mut input); + + let url_data = UrlExtraData::from( + self.base_url + .clone() + .unwrap_or_else(|| "about:blank".parse::().unwrap()), + ); + let quirks_mode = self.stylist.quirks_mode(); + let context = ParserContext::new( + Origin::Author, + &url_data, + Some(CssRuleType::Style), + ParsingMode::all(), + quirks_mode, + Default::default(), + None, + None, + ); + + let media_list = MediaList::parse(&context, &mut parser); + media_list.evaluate(self.stylist.device(), quirks_mode) + } + + fn environment_changes(&mut self) { + let image_nodes = self.image_nodes.clone(); + for node_id in image_nodes.into_iter() { + self.environment_changes_with_image(node_id); + } + } } impl AsRef for BaseDocument { diff --git a/packages/blitz-dom/src/layout/construct.rs b/packages/blitz-dom/src/layout/construct.rs index 7c3c44db5..50de1620d 100644 --- a/packages/blitz-dom/src/layout/construct.rs +++ b/packages/blitz-dom/src/layout/construct.rs @@ -20,8 +20,8 @@ use style::{ use crate::{ BaseDocument, ElementData, Node, NodeData, node::{ - ListItemLayout, ListItemLayoutPosition, Marker, NodeFlags, NodeKind, SpecialElementData, - TextBrush, TextInputData, TextLayout, + ImageContext, ImageSource, ListItemLayout, ListItemLayoutPosition, Marker, NodeFlags, + NodeKind, SpecialElementData, TextBrush, TextInputData, TextLayout, }, stylo_to_parley, }; @@ -110,7 +110,11 @@ pub(crate) fn collect_layout_children( .unwrap() .element_data_mut() .unwrap() - .special_data = SpecialElementData::Image(Box::new(svg.into())); + .special_data = + SpecialElementData::Image(Box::new(ImageContext::new_with_data( + ImageSource::new("about:blank".to_string()), + svg.into(), + ))); } Err(err) => { println!("{container_node_id} SVG parse failed"); diff --git a/packages/blitz-dom/src/layout/mod.rs b/packages/blitz-dom/src/layout/mod.rs index 9b830ebbb..e324f5bd1 100644 --- a/packages/blitz-dom/src/layout/mod.rs +++ b/packages/blitz-dom/src/layout/mod.rs @@ -227,23 +227,29 @@ impl LayoutPartialTree for BaseDocument { .and_then(|val| val.parse::().ok()), }; - // Get image's native sizespecial_data + // Get image's native size let inherent_size = match &element_data.special_data { - SpecialElementData::Image(image_data) => match &**image_data { - ImageData::Raster(image) => taffy::Size { - width: image.width as f32, - height: image.height as f32, - }, - #[cfg(feature = "svg")] - ImageData::Svg(svg) => { - let size = svg.size(); - taffy::Size { - width: size.width(), - height: size.height(), + SpecialElementData::Image(context) => { + if let Some(image_data) = &context.data { + match image_data { + ImageData::Raster(image) => taffy::Size { + width: image.width as f32, + height: image.height as f32, + }, + #[cfg(feature = "svg")] + ImageData::Svg(svg) => { + let size = svg.size(); + taffy::Size { + width: size.width(), + height: size.height(), + } + } + ImageData::None => taffy::Size::ZERO, } + } else { + taffy::Size::ZERO } - ImageData::None => taffy::Size::ZERO, - }, + } SpecialElementData::Canvas(_) => taffy::Size::ZERO, SpecialElementData::None => taffy::Size::ZERO, _ => unreachable!(), diff --git a/packages/blitz-dom/src/mutator.rs b/packages/blitz-dom/src/mutator.rs index 303e39d08..1f1171ffd 100644 --- a/packages/blitz-dom/src/mutator.rs +++ b/packages/blitz-dom/src/mutator.rs @@ -215,8 +215,11 @@ impl DocumentMutator<'_> { element.flush_style_attribute(&self.doc.guard, self.doc.base_url.clone()); } else if (tag, attr) == tag_and_attr!("input", "checked") { set_input_checked_state(element, value.to_string()); - } else if (tag, attr) == tag_and_attr!("img", "src") { + } else if *tag == local_name!("img") + && (*attr == local_name!("src") || *attr == local_name!("srcset")) + { self.load_image(node_id); + // self.doc.load_image(node_id); } else if (tag, attr) == tag_and_attr!("canvas", "src") { self.load_custom_paint_src(node_id); } @@ -582,6 +585,14 @@ impl<'doc> DocumentMutator<'doc> { } } + // fn maybe_load_image(&mut self, node_ids: &[usize]) { + // for id in node_ids.iter() { + // if self.doc.is_img_node(*id) { + // self.doc.load_image(*id); + // } + // } + // } + fn load_custom_paint_src(&mut self, target_id: usize) { let node = &mut self.doc.nodes[target_id]; if let Some(raw_src) = node.attr(local_name!("src")) { diff --git a/packages/blitz-dom/src/node/element.rs b/packages/blitz-dom/src/node/element.rs index 3fb47a269..1a798af61 100644 --- a/packages/blitz-dom/src/node/element.rs +++ b/packages/blitz-dom/src/node/element.rs @@ -1,3 +1,4 @@ +use blitz_traits::net::AbortController; use color::{AlphaColor, Srgb}; use markup5ever::{LocalName, QualName, local_name}; use parley::{FontContext, LayoutContext}; @@ -14,7 +15,7 @@ use style::{ }; use url::Url; -use super::{Attribute, Attributes}; +use super::{Attribute, Attributes, ImageSource}; use crate::layout::table::TableContext; #[derive(Debug, Clone)] @@ -73,7 +74,7 @@ pub enum SpecialElementType { pub enum SpecialElementData { Stylesheet(DocumentStyleSheet), /// An \ element's image data - Image(Box), + Image(Box), /// A \ element's custom paint source Canvas(CanvasData), /// Pre-computed table layout data @@ -137,15 +138,15 @@ impl ElementData { } pub fn image_data(&self) -> Option<&ImageData> { - match &self.special_data { - SpecialElementData::Image(data) => Some(&**data), + match self.special_data { + SpecialElementData::Image(ref context) => context.data.as_ref(), _ => None, } } pub fn image_data_mut(&mut self) -> Option<&mut ImageData> { match self.special_data { - SpecialElementData::Image(ref mut data) => Some(&mut **data), + SpecialElementData::Image(ref mut context) => context.data.as_mut(), _ => None, } } @@ -302,6 +303,44 @@ impl From for ImageData { } } +#[derive(Debug)] +pub struct ImageContext { + pub selected_source: ImageSource, + pub data: Option, + pub controller: Option, +} + +impl Clone for ImageContext { + fn clone(&self) -> Self { + Self { + selected_source: self.selected_source.clone(), + data: self.data.clone(), + controller: None, + } + } +} + +impl ImageContext { + pub(crate) fn new_with_controller( + selected_source: ImageSource, + controller: AbortController, + ) -> Self { + Self { + selected_source, + data: None, + controller: Some(controller), + } + } + + pub fn new_with_data(selected_source: ImageSource, data: ImageData) -> Self { + Self { + selected_source, + data: Some(data), + controller: None, + } + } +} + #[derive(Debug, Clone, PartialEq)] pub enum Status { Ok, @@ -374,12 +413,18 @@ impl std::fmt::Debug for SpecialElementData { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { SpecialElementData::Stylesheet(_) => f.write_str("NodeSpecificData::Stylesheet"), - SpecialElementData::Image(data) => match **data { - ImageData::Raster(_) => f.write_str("NodeSpecificData::Image(Raster)"), - #[cfg(feature = "svg")] - ImageData::Svg(_) => f.write_str("NodeSpecificData::Image(Svg)"), - ImageData::None => f.write_str("NodeSpecificData::Image(None)"), - }, + SpecialElementData::Image(context) => { + if let Some(image_data) = &context.data { + match image_data { + ImageData::Raster(_) => f.write_str("NodeSpecificData::Image(Raster)"), + #[cfg(feature = "svg")] + ImageData::Svg(_) => f.write_str("NodeSpecificData::Image(Svg)"), + ImageData::None => f.write_str("NodeSpecificData::Image(None)"), + } + } else { + f.write_str("NodeSpecificData::Image(None)") + } + } SpecialElementData::Canvas(_) => f.write_str("NodeSpecificData::Canvas"), SpecialElementData::TableRoot(_) => f.write_str("NodeSpecificData::TableRoot"), SpecialElementData::TextInput(_) => f.write_str("NodeSpecificData::TextInput"), diff --git a/packages/blitz-dom/src/node/image.rs b/packages/blitz-dom/src/node/image.rs new file mode 100644 index 000000000..be0cc85bd --- /dev/null +++ b/packages/blitz-dom/src/node/image.rs @@ -0,0 +1,476 @@ +use super::{ImageContext, SpecialElementData}; +use crate::{BaseDocument, net::ImageHandler, util::ImageType}; +use blitz_traits::net::{AbortController, Request}; +use markup5ever::local_name; +use mime::Mime; +use std::{ + collections::HashSet, + ops::{Deref, DerefMut}, +}; +use style::{ + parser::ParserContext, + stylesheets::{CssRuleType, Origin, UrlExtraData}, + values::specified::source_size_list::SourceSizeList, +}; +use style_traits::ParsingMode; +use url::Url; + +impl BaseDocument { + #[inline] + pub fn is_img_node(&self, node_id: usize) -> bool { + let Some(node) = self.get_node(node_id) else { + return false; + }; + node.data.is_element_with_tag_name(&local_name!("img")) + } + + #[inline] + pub fn is_picture_node(&self, node_id: usize) -> bool { + let Some(node) = self.get_node(node_id) else { + return false; + }; + node.data.is_element_with_tag_name(&local_name!("picture")) + } + + pub(crate) fn load_image(&mut self, target_id: usize) { + let Some(selected_source) = self.select_image_source(target_id) else { + return; + }; + + let src = self.resolve_url(&selected_source.url); + + let node = self.get_node_mut(target_id).unwrap(); + let Some(data) = node.element_data_mut() else { + return; + }; + + let controller = AbortController::default(); + let signal = controller.signal.clone(); + + match &mut data.special_data { + SpecialElementData::Image(context) + if !context + .selected_source + .is_same_image_source(&selected_source) => + { + context.selected_source = selected_source; + if let Some(controller) = context.controller.replace(controller) { + controller.abort(); + } + } + SpecialElementData::None => { + data.special_data = SpecialElementData::Image(Box::new( + ImageContext::new_with_controller(selected_source, controller), + )); + } + _ => return, + } + + self.net_provider.fetch( + self.id(), + Request::get(src).signal(signal), + Box::new(ImageHandler::new(target_id, ImageType::Image)), + ); + } + + // https://html.spec.whatwg.org/multipage/images.html#reacting-to-environment-changes + pub(crate) fn environment_changes_with_image(&mut self, node_id: usize) { + // 2. If the img element does not use srcset or picture. + if !self.use_srcset_or_picture(node_id) { + return; + } + + self.load_image(node_id); + } + + fn use_srcset_or_picture(&self, node_id: usize) -> bool { + let Some(node) = self.get_node(node_id) else { + return false; + }; + + if node.attr(local_name!("srcset")).is_some() { + return true; + } + + if let Some(parent_id) = node.parent { + if self.is_picture_node(parent_id) { + return true; + } + } + + false + } + + /// Selecting an image source + /// + /// https://html.spec.whatwg.org/multipage/#select-an-image-source + fn select_image_source(&self, el_id: usize) -> Option { + let source_set = self.get_source_set(el_id)?; + let image_sources = source_set.image_sources; + let len = image_sources.len(); + + if len == 0 { + return None; + } + + // 1. If an entry b in sourceSet has the same associated pixel density descriptor as + // an earlier entry a in sourceSet, then remove entry b. Repeat this step until none + // of the entries in sourceSet have the same associated pixel density descriptor as + // an earlier entry. + let mut seen = HashSet::new(); + let image_sources = image_sources + .into_inner() + .into_iter() + .filter(|image_source| { + let density = image_source.descriptor.density.unwrap(); + seen.insert(density.to_bits()) + }) + .collect::>(); + + let device_pixel_ratio = self.viewport.scale_f64(); + + // 2.2 In an implementation-defined manner, choose one image source from sourceSet. Let this be selectedSource. + let image_source = image_sources + .iter() + .find(|image_source| { + let density = image_source.descriptor.density.unwrap(); + density >= device_pixel_ratio + }) + .unwrap_or_else(|| image_sources.last().unwrap()); + + Some(image_source.clone()) + } + + /// https://html.spec.whatwg.org/multipage/#update-the-source-set + fn get_source_set(&self, el_id: usize) -> Option { + // 2. Let elements be « el ». + let el = self.get_node(el_id)?; + + // 3. If el is an img element whose parent node is a picture element, + // then replace the contents of elements with el's parent node's child elements, + // retaining relative order. + let elements = if let Some(parent_id) = el.parent { + if self.is_picture_node(parent_id) { + let parent = &self.nodes[parent_id]; + parent.children.clone() + } else { + vec![el_id] + } + } else { + vec![el_id] + }; + + // 5. For each child in elements. + for element in elements { + // 5.1 If child is el. + if element == el_id { + let element = self.get_node(element)?; + // 5.1.1 Let default source be the empty string. + // 5.1.2 Let srcset be the empty string. + let mut source_set = SourceSet::new(); + + // 5.1.4 If el is an img element that has a srcset attribute, + // then set srcset to that attribute's value. + if let Some(srcset) = element.attr(local_name!("srcset")) { + source_set.image_sources = ImageSourceList::parse(srcset); + } + + // 5.1.6 If el is an img element that has a sizes attribute, + // then set sizes to that attribute's value. + if let Some(sizes) = element.attr(local_name!("sizes")) { + source_set.source_size = self.parse_sizes_attribute(sizes); + } + + // 5.1.8. If el is an img element that has a src attribute, + // then set default source to that attribute's value. + let src = element.attr(local_name!("src")); + if let Some(src) = src { + if !src.is_empty() { + source_set + .image_sources + .push(ImageSource::new(src.to_string())) + } + } + + self.normalise_source_densities(&mut source_set); + + // 5.1.11. Return. + return Some(source_set); + } + + let Some(element) = self.get_node(element) else { + continue; + }; + // 5.2 If child is not a source element, then continue. + if element + .element_data() + .is_none_or(|data| data.name.local != local_name!("source")) + { + continue; + } + + let mut source_set = SourceSet::new(); + + // 5.3 If child does not have a srcset attribute, continue to the next child. + let Some(srcset) = element.attr(local_name!("srcset")) else { + continue; + }; + // 5.4 Parse child's srcset attribute and let the returned source set be source set. + source_set.image_sources = ImageSourceList::parse(srcset); + // 5.5 If source set has zero image sources, continue to the next child. + if source_set.image_sources.is_empty() { + continue; + } + + // 5.6 If child has a media attribute, and its value does not match the environment, + // continue to the next child. + if let Some(media) = element.attr(local_name!("media")) { + if !self.match_media(media) { + continue; + } + } + + // 5.7 Parse child's sizes attribute with img, and let source set's + // source size be the returned value. + if let Some(sizes) = element.attr(local_name!("sizes")) { + source_set.source_size = self.parse_sizes_attribute(sizes); + } + + // 5.8 If child has a type attribute, and its value is an unknown or unsupported MIME type, + // continue to the next child. + if let Some(type_) = element.attr(local_name!("type")) { + let Ok(mime) = type_.parse::() else { + continue; + }; + if mime.type_() != mime::IMAGE { + continue; + } + + // Unsupported mime types + if mime.essence_str() != "image/svg+xml" + && image::ImageFormat::from_mime_type(type_).is_none() + { + continue; + } + } + + // 5.10 Normalize the source densities of source set. + self.normalise_source_densities(&mut source_set); + + // 5.12 Return. + return Some(source_set); + } + + None + } + + /// Parsing a `sizes` attribute. + /// + /// https://html.spec.whatwg.org/multipage/images.html#parsing-a-sizes-attribute + fn parse_sizes_attribute(&self, input: &str) -> SourceSizeList { + let mut input = cssparser::ParserInput::new(input); + let mut parser = cssparser::Parser::new(&mut input); + + let url_data = UrlExtraData::from( + self.base_url + .clone() + .unwrap_or_else(|| "about:blank".parse::().unwrap()), + ); + let quirks_mode = self.stylist.quirks_mode(); + let context = ParserContext::new( + Origin::Author, + &url_data, + Some(CssRuleType::Style), + ParsingMode::empty(), + quirks_mode, + Default::default(), + None, + None, + ); + + SourceSizeList::parse(&context, &mut parser) + } + + /// https://html.spec.whatwg.org/multipage/images.html#normalizing-the-source-densities + fn normalise_source_densities(&self, source_set: &mut SourceSet) { + // 1. Let source size be source set's source size. + let source_size = &mut source_set.source_size; + + let source_size_length = + source_size.evaluate(self.stylist.device(), self.stylist.quirks_mode()); + + // 2. For each image source in source set. + for imgsource in source_set.image_sources.iter_mut() { + // 2.1 If the image source has a pixel density descriptor, continue to the next image source. + if imgsource.descriptor.density.is_some() { + continue; + } + // 2.2 Otherwise, if the image source has a width descriptor, replace the width descriptor with + // a pixel density descriptor with a value of the width descriptor value divided by source size and a unit of x. + if let Some(width) = imgsource.descriptor.width { + imgsource.descriptor.density = Some(width as f64 / source_size_length.to_f64_px()); + } else { + // 2.3 Otherwise, give the image source a pixel density descriptor of 1x. + imgsource.descriptor.density = Some(1f64); + } + } + } +} + +#[derive(Debug)] +struct SourceSet { + // srcset + image_sources: ImageSourceList, + source_size: SourceSizeList, +} + +impl SourceSet { + fn new() -> Self { + Self { + image_sources: Default::default(), + source_size: SourceSizeList::empty(), + } + } +} + +#[derive(Debug, Default, PartialEq)] +struct ImageSourceList(Vec); + +impl Deref for ImageSourceList { + type Target = Vec; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl DerefMut for ImageSourceList { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.0 + } +} + +impl ImageSourceList { + /// Parse an `srcset` attribute. + fn parse(input: &str) -> Self { + let mut candidates = vec![]; + + for image_source_str in input.split(",") { + let image_source_str = image_source_str.trim(); + if image_source_str.is_empty() { + continue; + } + + if let Some(image_source) = ImageSource::parse(image_source_str) { + candidates.push(image_source); + } + } + + Self(candidates) + } + + fn into_inner(self) -> Vec { + self.0 + } +} + +/// Srcset attributes +/// +/// https://html.spec.whatwg.org/multipage/images.html#srcset-attributes +#[derive(Debug, PartialEq, Clone)] +pub struct ImageSource { + pub url: String, + pub descriptor: Descriptor, +} + +impl ImageSource { + pub fn new(url: String) -> Self { + Self { + url, + descriptor: Default::default(), + } + } + + #[inline] + fn is_same_image_source(&self, other: &Self) -> bool { + self.url == other.url && self.descriptor.density == other.descriptor.density + } + + fn parse(input: &str) -> Option { + let image_source_split = input.split_ascii_whitespace().collect::>(); + let len = image_source_split.len(); + + match len { + 1 => Some(Self { + url: image_source_split[0].to_string(), + descriptor: Default::default(), + }), + 2 => { + let descriptor = Descriptor::parse(image_source_split[1])?; + + Some(Self { + url: image_source_split[0].to_string(), + descriptor, + }) + } + _ => None, + } + } +} + +#[derive(Debug, Default, PartialEq, Clone)] +pub struct Descriptor { + pub width: Option, + pub density: Option, +} + +impl Descriptor { + fn parse(input: &str) -> Option { + if input.len() < 2 { + return None; + } + let (number, unit) = input.split_at(input.len() - 1); + match unit { + "w" => match number.parse::() { + Ok(number) if number > 0 => Some(Self { + width: Some(number), + density: Default::default(), + }), + _ => None, + }, + "x" => match number.parse::() { + Ok(number) if number.is_normal() && number > 0. => Some(Self { + width: Default::default(), + density: Some(number), + }), + _ => None, + }, + _ => None, + } + } +} + +#[test] +fn test_parse_image_source_list() { + let list = ImageSourceList::parse("/url.jpg, /url.jpg 2x, /url.jpg 2w"); + assert_eq!( + list, + ImageSourceList(vec![ + ImageSource::new("/url.jpg".to_string()), + ImageSource { + url: "/url.jpg".to_string(), + descriptor: Descriptor { + width: None, + density: Some(2.), + }, + }, + ImageSource { + url: "/url.jpg".to_string(), + descriptor: Descriptor { + width: Some(2), + density: None, + }, + }, + ]) + ); +} diff --git a/packages/blitz-dom/src/node/mod.rs b/packages/blitz-dom/src/node/mod.rs index 57156a8e6..92c285550 100644 --- a/packages/blitz-dom/src/node/mod.rs +++ b/packages/blitz-dom/src/node/mod.rs @@ -2,12 +2,14 @@ mod attributes; mod element; +mod image; mod node; pub use attributes::{Attribute, Attributes}; pub use element::{ - BackgroundImageData, CanvasData, ElementData, ImageData, ListItemLayout, + BackgroundImageData, CanvasData, ElementData, ImageContext, ImageData, ListItemLayout, ListItemLayoutPosition, Marker, RasterImageData, SpecialElementData, SpecialElementType, Status, TextBrush, TextInputData, TextLayout, }; +pub use image::ImageSource; pub use node::*; diff --git a/packages/blitz-dom/src/node/node.rs b/packages/blitz-dom/src/node/node.rs index 1177f0c23..39cf69835 100644 --- a/packages/blitz-dom/src/node/node.rs +++ b/packages/blitz-dom/src/node/node.rs @@ -1,3 +1,4 @@ +use super::{Attribute, ElementData}; use atomic_refcell::{AtomicRef, AtomicRefCell}; use bitflags::bitflags; use blitz_traits::{BlitzMouseButtonEvent, DomEventData, HitResult}; @@ -25,8 +26,6 @@ use taffy::{ }; use url::Url; -use super::{Attribute, ElementData}; - #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum DisplayOuter { Block, diff --git a/packages/blitz-net/src/lib.rs b/packages/blitz-net/src/lib.rs index 4705ff2c3..25ae46e02 100644 --- a/packages/blitz-net/src/lib.rs +++ b/packages/blitz-net/src/lib.rs @@ -2,10 +2,12 @@ //! //! Provides an implementation of the [`blitz_traits::net::NetProvider`] trait. -use blitz_traits::net::{BoxedHandler, Bytes, NetCallback, NetProvider, Request, SharedCallback}; +use blitz_traits::net::{ + AbortSignal, BoxedHandler, Bytes, NetCallback, NetProvider, Request, SharedCallback, +}; use data_url::DataUrl; use reqwest::Client; -use std::sync::Arc; +use std::{marker::PhantomData, pin::Pin, sync::Arc, task::Poll}; use tokio::{ runtime::Handle, sync::mpsc::{UnboundedReceiver, UnboundedSender, unbounded_channel}, @@ -67,18 +69,6 @@ impl Provider { }) } - async fn fetch_with_handler( - client: Client, - doc_id: usize, - request: Request, - handler: BoxedHandler, - res_callback: SharedCallback, - ) -> Result<(), ProviderError> { - let (_response_url, bytes) = Self::fetch_inner(client, request).await?; - handler.bytes(doc_id, bytes, res_callback); - Ok(()) - } - #[allow(clippy::type_complexity)] pub fn fetch_with_callback( &self, @@ -112,24 +102,78 @@ impl Provider { } impl NetProvider for Provider { - fn fetch(&self, doc_id: usize, request: Request, handler: BoxedHandler) { + fn fetch(&self, doc_id: usize, mut request: Request, handler: BoxedHandler) { let client = self.client.clone(); let callback = Arc::clone(&self.resource_callback); println!("Fetching {}", &request.url); self.rt.spawn(async move { let url = request.url.to_string(); - let res = Self::fetch_with_handler(client, doc_id, request, handler, callback).await; - if let Err(e) = res { - eprintln!("Error fetching {url}: {e:?}"); + let signal = request.signal.take(); + let result = if let Some(signal) = signal { + AbortFetch::new( + signal, + Box::pin(async move { Self::fetch_inner(client, request).await }), + ) + .await } else { - println!("Success {url}"); + Self::fetch_inner(client, request).await + }; + + match result { + Ok((_response_url, bytes)) => { + handler.bytes(doc_id, bytes, callback); + println!("Success {url}"); + } + Err(e) => { + eprintln!("Error fetching {url}: {e:?}"); + } } }); } } +struct AbortFetch { + signal: AbortSignal, + future: F, + _rt: PhantomData, +} + +impl AbortFetch { + fn new(signal: AbortSignal, future: F) -> Self { + Self { + signal, + future, + _rt: PhantomData, + } + } +} + +impl Future for AbortFetch +where + F: Future + Unpin + Send + 'static, + F::Output: Send + Into> + 'static, + T: Unpin, +{ + type Output = Result; + + fn poll( + mut self: std::pin::Pin<&mut Self>, + cx: &mut std::task::Context<'_>, + ) -> std::task::Poll { + if self.signal.aborted() { + return Poll::Ready(Err(ProviderError::Abort)); + } + + match Pin::new(&mut self.future).poll(cx) { + Poll::Ready(output) => Poll::Ready(output.into()), + Poll::Pending => Poll::Pending, + } + } +} + #[derive(Debug)] pub enum ProviderError { + Abort, Io(std::io::Error), DataUrl(data_url::DataUrlError), DataUrlBase64(data_url::forgiving_base64::InvalidBase64), diff --git a/packages/blitz-traits/src/navigation.rs b/packages/blitz-traits/src/navigation.rs index 68a0740a4..c4eaae799 100644 --- a/packages/blitz-traits/src/navigation.rs +++ b/packages/blitz-traits/src/navigation.rs @@ -58,6 +58,7 @@ impl NavigationOptions { method: Method::POST, headers, body: document_resource, + signal: None, } } else { Request { @@ -65,6 +66,7 @@ impl NavigationOptions { method: Method::GET, headers, body: Bytes::new(), + signal: None, } } } diff --git a/packages/blitz-traits/src/net.rs b/packages/blitz-traits/src/net.rs index 43bc0c261..fe2c9c8d7 100644 --- a/packages/blitz-traits/src/net.rs +++ b/packages/blitz-traits/src/net.rs @@ -1,6 +1,9 @@ pub use bytes::Bytes; pub use http::{self, HeaderMap, Method}; -use std::sync::Arc; +use std::sync::{ + Arc, + atomic::{AtomicBool, Ordering}, +}; pub use url::Url; pub type SharedProvider = Arc>; @@ -40,6 +43,7 @@ pub struct Request { pub method: Method, pub headers: HeaderMap, pub body: Bytes, + pub signal: Option, } impl Request { /// A get request to the specified Url and an empty body @@ -49,8 +53,14 @@ impl Request { method: Method::GET, headers: HeaderMap::new(), body: Bytes::new(), + signal: None, } } + + pub fn signal(mut self, signal: AbortSignal) -> Self { + self.signal = Some(signal); + self + } } /// A default noop NetProvider @@ -66,3 +76,42 @@ pub struct DummyNetCallback; impl NetCallback for DummyNetCallback { fn call(&self, _doc_id: usize, _result: Result>) {} } + +/// The AbortController interface represents a controller object that +/// allows you to abort one or more Web requests as and when desired. +/// +/// https://developer.mozilla.org/en-US/docs/Web/API/AbortController +#[derive(Debug, Default)] +pub struct AbortController { + pub signal: AbortSignal, +} + +impl AbortController { + /// The abort() method of the AbortController interface aborts + /// an asynchronous operation before it has completed. + /// This is able to abort fetch requests. + /// + /// https://developer.mozilla.org/en-US/docs/Web/API/AbortController/abort + pub fn abort(self) { + self.signal.0.store(true, Ordering::SeqCst); + } +} + +/// The AbortSignal interface represents a signal object that allows you to +/// communicate with an asynchronous operation (such as a fetch request) and +/// abort it if required via an AbortController object. +/// +/// https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal +#[derive(Debug, Default, Clone)] +pub struct AbortSignal(Arc); + +impl AbortSignal { + /// The aborted read-only property returns a value that indicates whether + /// the asynchronous operations the signal is communicating with are + /// aborted (true) or not (false). + /// + /// https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal/aborted + pub fn aborted(&self) -> bool { + self.0.load(Ordering::SeqCst) + } +}