next_rs/
image.rs

1use crate::prelude::*;
2use gloo_net::http::Request;
3use wasm_bindgen_futures::spawn_local;
4use web_sys::js_sys::Function;
5use web_sys::wasm_bindgen::JsValue;
6use web_sys::{IntersectionObserver, IntersectionObserverInit, RequestCache};
7
8/// Properties for the Image component.
9#[derive(Properties, Clone, PartialEq)]
10pub struct ImageProps {
11    /// The source URL for the image.
12    #[prop_or_default]
13    pub src: &'static str,
14
15    /// The alternative text for the image.
16    #[prop_or_default]
17    pub alt: &'static str,
18
19    /// The width of the image.
20    #[prop_or_default]
21    pub width: &'static str,
22
23    /// The height of the image.
24    #[prop_or_default]
25    pub height: &'static str,
26
27    // Common props
28    #[prop_or_default]
29    /// The style attribute for the image.
30    pub style: &'static str,
31
32    #[prop_or_default]
33    /// The CSS class for the image.
34    pub class: &'static str,
35
36    #[prop_or_default]
37    /// The sizes attribute for the image.
38    pub sizes: &'static str,
39
40    #[prop_or_default]
41    /// The quality attribute for the image.
42    pub quality: &'static str,
43
44    #[prop_or_default]
45    /// Indicates if the image should have priority loading.
46    pub priority: bool,
47
48    #[prop_or_default]
49    /// The placeholder attribute for the image.
50    pub placeholder: &'static str,
51
52    #[prop_or_default]
53    /// Callback function for handling loading completion.
54    pub on_loading_complete: Callback<()>,
55
56    // Advanced Props
57    #[prop_or_default]
58    /// The object-fit attribute for the image.
59    pub object_fit: &'static str,
60
61    #[prop_or_default]
62    /// The object-position attribute for the image.
63    pub object_position: &'static str,
64
65    #[prop_or_default]
66    /// Callback function for handling errors during image loading.
67    pub on_error: Callback<String>,
68
69    #[prop_or_default]
70    /// The decoding attribute for the image.
71    pub decoding: &'static str,
72
73    #[prop_or_default]
74    /// The blur data URL for placeholder image.
75    pub blur_data_url: &'static str,
76
77    #[prop_or_default]
78    /// The lazy boundary for lazy loading.
79    pub lazy_boundary: &'static str,
80
81    #[prop_or_default]
82    /// Indicates if the image should be unoptimized.
83    pub unoptimized: bool,
84
85    #[prop_or_default]
86    /// Image layout.
87    pub layout: &'static str,
88
89    #[prop_or_default]
90    /// Reference to the DOM node.
91    pub node_ref: NodeRef,
92
93    #[prop_or_default]
94    /// Indicates the current state of the image in a navigation menu. Valid values: "page", "step", "location", "date", "time", "true", "false".
95    pub aria_current: &'static str,
96
97    #[prop_or_default]
98    /// Describes the image using the ID of the element that provides a description.
99    pub aria_describedby: &'static str,
100
101    #[prop_or_default]
102    /// Indicates whether the content associated with the image is currently expanded or collapsed. Valid values: "true", "false".
103    pub aria_expanded: &'static str,
104
105    #[prop_or_default]
106    /// Indicates whether the image is currently hidden from the user. Valid values: "true", "false".
107    pub aria_hidden: &'static str,
108
109    #[prop_or_default]
110    /// Indicates whether the content associated with the image is live and dynamic. Valid values: "off", "assertive", "polite".
111    pub aria_live: &'static str,
112
113    #[prop_or_default]
114    /// Indicates whether the image is currently pressed or selected. Valid values: "true", "false", "mixed", "undefined".
115    pub aria_pressed: &'static str,
116
117    #[prop_or_default]
118    /// ID of the element that the image controls or owns.
119    pub aria_controls: &'static str,
120
121    #[prop_or_default]
122    /// ID of the element that labels the image.
123    pub aria_labelledby: &'static str,
124}
125
126impl Default for ImageProps {
127    fn default() -> Self {
128        ImageProps {
129            src: "",
130            alt: "Image",
131            width: "300",
132            height: "200",
133            style: "",
134            class: "",
135            sizes: "",
136            quality: "",
137            priority: false,
138            placeholder: "empty",
139            on_loading_complete: Callback::noop(),
140            object_fit: "cover",
141            object_position: "center",
142            on_error: Callback::noop(),
143            decoding: "",
144            blur_data_url: "",
145            lazy_boundary: "100px",
146            unoptimized: false,
147            layout: "responsive",
148            node_ref: NodeRef::default(),
149            aria_current: "",
150            aria_describedby: "",
151            aria_expanded: "",
152            aria_hidden: "",
153            aria_live: "",
154            aria_pressed: "",
155            aria_controls: "",
156            aria_labelledby: "",
157        }
158    }
159}
160
161/// The Image component for displaying images with various options.
162///
163/// # Arguments
164/// * `props` - The properties of the component.
165///
166/// # Returns
167/// (Html): An HTML representation of the image component.
168///
169/// # Examples
170/// ```
171/// use next_rs::prelude::*;
172/// use next_rs::{Image, ImageProps, log};
173///
174/// #[func]
175/// pub fn MyComponent() -> Html {
176///     let image_props = ImageProps {
177///         src: "images/logo.png",
178///         alt: "Example Image",
179///         width: "200",
180///         height: "300",
181///         style: "border: 1px solid #ddd;",
182///         class: "image-class",
183///         sizes: "(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw",
184///         quality: "80",
185///         priority: true,
186///         placeholder: "blur",
187///         on_loading_complete: Callback::from(|_| {
188///             log(&format!("Image loading is complete!").into());
189///         }),
190///         object_fit: "cover",
191///         object_position: "center",
192///         on_error: Callback::from(|err| {
193///             log(&format!("Error loading image 1: {:#?}", err).into());
194///         }),
195///         decoding: "async",
196///         blur_data_url: "data:image/png;base64,....",
197///         lazy_boundary: "200px",
198///         unoptimized: false,
199///         node_ref: NodeRef::default(),
200///         ..ImageProps::default()
201///     };
202///
203///     rsx! {
204///         <Image ..image_props />
205///     }
206/// }
207/// ```
208#[func]
209pub fn Image(props: &ImageProps) -> Html {
210    let props = props.clone();
211    let img_ref = props.node_ref.clone();
212
213    use_effect_with(JsValue::from(props.src), move |deps| {
214        // Define the callback function for the IntersectionObserver
215        let callback = Function::new_no_args(
216            r###"
217            {
218                let img_ref = img_ref.clone();
219                let on_loading_complete = props.on_loading_complete.clone();
220                let on_error = props.on_error.clone();
221                
222                move || {
223                    let entries: Vec<web_sys::IntersectionObserverEntry> = js_sys::try_iter(deps)
224                        .unwrap()
225                        .unwrap()
226                        .map(|v| v.unwrap().unchecked_into())
227                        .collect();
228
229                    // Check if the image is intersecting with the viewport
230                    if let Some(entry) = entries.get(0) {
231                        if entry.is_intersecting() {
232                            // Load the image when it becomes visible
233                            let img: HtmlImageElement = img_ref.cast().unwrap();
234                            img.set_src(&props.src);
235
236                            // Call the loading complete callback
237                            on_loading_complete.emit(());
238                        }
239                    }
240                }
241            }
242            "###,
243        );
244
245        // Create IntersectionObserver configuration
246        let mut options = IntersectionObserverInit::new();
247        options.threshold(deps);
248        options.root(Some(
249            &web_sys::window()
250                .and_then(|win| win.document())
251                .unwrap()
252                .body()
253                .unwrap(),
254        ));
255
256        // Create IntersectionObserver instance
257        let observer = IntersectionObserver::new_with_options(&callback, &options)
258            .expect("Failed to create IntersectionObserver");
259
260        // Observe the image element
261        if let Some(img) = img_ref.cast::<web_sys::HtmlElement>() {
262            observer.observe(&img);
263        }
264
265        // Cleanup: Disconnect the IntersectionObserver when the component unmounts
266        return move || {
267            observer.disconnect();
268        };
269    });
270
271    let fetch_data = {
272        Callback::from(move |_| {
273            let loading_complete_callback = props.on_loading_complete.clone();
274            let on_error_callback = props.on_error.clone();
275            spawn_local(async move {
276                match Request::get(props.src)
277                    .cache(RequestCache::Reload)
278                    .send()
279                    .await
280                {
281                    Ok(response) => {
282                        if response.status() == 200 {
283                            let json_result = response.json::<serde_json::Value>();
284                            match json_result.await {
285                                Ok(_data) => {
286                                    loading_complete_callback.emit(());
287                                }
288                                Err(_err) => {
289                                    on_error_callback.emit(format!("Image Not Found!"));
290                                }
291                            }
292                        } else {
293                            let status = response.status();
294                            let body = response.text().await.unwrap_or_else(|_| {
295                                String::from("Failed to retrieve response body")
296                            });
297                            on_error_callback.emit(format!(
298                                "Failed to load image. Status: {}, Body: {:?}",
299                                status, body
300                            ));
301                        }
302                    }
303
304                    Err(err) => {
305                        // Handle network errors
306                        on_error_callback.emit(format!("Network error: {}", err.to_string()));
307                    }
308                }
309            });
310        })
311    };
312
313    let img_style = {
314        let mut style = String::new();
315        if !props.object_fit.is_empty() {
316            style.push_str(&format!("object-fit: {};", props.object_fit));
317        }
318        if !props.object_position.is_empty() {
319            style.push_str(&format!("object-position: {};", props.object_position));
320        }
321        if !props.style.is_empty() {
322            style.push_str(props.style);
323        }
324        style
325    };
326
327    let blur_style = if props.placeholder == "blur" {
328        format!(
329            "background-size: {}; background-position: {}; filter: blur(20px); background-image: url(\"{}\")",
330            props.sizes,
331            props.object_position,
332            props.blur_data_url
333        )
334    } else {
335        String::new()
336    };
337
338    let layout = if props.layout == "fill" {
339        rsx! {
340            <span style={String::from("display: block; position: absolute; top: 0; left: 0; bottom: 0; right: 0;")}>
341                <img
342                    src={props.src}
343                    alt={props.alt}
344                    width={props.width}
345                    height={props.height}
346                    style={img_style}
347                    class={props.class}
348                    loading={if props.priority { "eager" } else { "lazy" }}
349                    sizes={props.sizes}
350                    quality={props.quality}
351                    placeholder={props.placeholder}
352                    decoding={props.decoding}
353                    ref={props.node_ref}
354                    role="img"
355                    aria-label={props.alt}
356                    aria-labelledby={props.aria_labelledby}
357                    aria-describedby={props.aria_describedby}
358                    aria-hidden={props.aria_hidden}
359                    aria-current={props.aria_current}
360                    aria-expanded={props.aria_expanded}
361                    aria-live={props.aria_live}
362                    aria-pressed={props.aria_pressed}
363                    aria-controls={props.aria_controls}
364                    onerror={fetch_data}
365                    style={blur_style}
366                />
367            </span>
368        }
369    } else if !props.width.is_empty() && !props.height.is_empty() {
370        let quotient: f64 =
371            props.height.parse::<f64>().unwrap() / props.width.parse::<f64>().unwrap();
372        let padding_top: String = if quotient.is_nan() {
373            "100%".to_string()
374        } else {
375            format!("{}%", quotient * 100.0)
376        };
377
378        if props.layout == "responsive" {
379            rsx! {
380                <span style={String::from("display: block; position: relative;")}>
381                    <span style={String::from("padding-top: ") + &padding_top}>
382                        <img
383                            src={props.src}
384                            alt={props.alt}
385                            width={props.width}
386                            height={props.height}
387                            style={img_style}
388                            class={props.class}
389                            loading={if props.priority { "eager" } else { "lazy" }}
390                            sizes={props.sizes}
391                            quality={props.quality}
392                            placeholder={props.placeholder}
393                            decoding={props.decoding}
394                            ref={props.node_ref}
395                            role="img"
396                            aria-label={props.alt}
397                            aria-labelledby={props.aria_labelledby}
398                            aria-describedby={props.aria_describedby}
399                            aria-hidden={props.aria_hidden}
400                            aria-current={props.aria_current}
401                            aria-expanded={props.aria_expanded}
402                            aria-live={props.aria_live}
403                            aria-pressed={props.aria_pressed}
404                            aria-controls={props.aria_controls}
405                            onerror={fetch_data}
406                            style={blur_style}
407                        />
408                    </span>
409                </span>
410            }
411        } else if props.layout == "intrinsic" {
412            rsx! {
413                <span style={String::from("display: inline-block; position: relative; max-width: 100%;")}>
414                    <span style={String::from("max-width: 100%;")}>
415                        <img
416                            src={props.src}
417                            alt={props.alt}
418                            width={props.width}
419                            height={props.height}
420                            style={img_style}
421                            class={props.class}
422                            loading={if props.priority { "eager" } else { "lazy" }}
423                            sizes={props.sizes}
424                            quality={props.quality}
425                            placeholder={props.placeholder}
426                            decoding={props.decoding}
427                            ref={props.node_ref}
428                            role="img"
429                            aria-label={props.alt}
430                            aria-labelledby={props.aria_labelledby}
431                            aria-describedby={props.aria_describedby}
432                            aria-hidden={props.aria_hidden}
433                            aria-current={props.aria_current}
434                            aria-expanded={props.aria_expanded}
435                            aria-live={props.aria_live}
436                            aria-pressed={props.aria_pressed}
437                            aria-controls={props.aria_controls}
438                            onerror={fetch_data}
439                            style={blur_style}
440                        />
441                    </span>
442                    <img
443                        src={props.blur_data_url}
444                        style={String::from("display: none;")}
445                        alt={props.alt}
446                        aria-hidden="true"
447                    />
448                </span>
449            }
450        } else if props.layout == "fixed" {
451            rsx! {
452                <span style={String::from("display: inline-block; position: relative;")}>
453                    <img
454                        src={props.src}
455                        alt={props.alt}
456                        width={props.width}
457                        height={props.height}
458                        style={img_style}
459                        class={props.class}
460                        loading={if props.priority { "eager" } else { "lazy" }}
461                        sizes={props.sizes}
462                        quality={props.quality}
463                        placeholder={props.placeholder}
464                        decoding={props.decoding}
465                        ref={props.node_ref}
466                        role="img"
467                        aria-label={props.alt}
468                        aria-labelledby={props.aria_labelledby}
469                        aria-describedby={props.aria_describedby}
470                        aria-hidden={props.aria_hidden}
471                        aria-current={props.aria_current}
472                        aria-expanded={props.aria_expanded}
473                        aria-live={props.aria_live}
474                        aria-pressed={props.aria_pressed}
475                        aria-controls={props.aria_controls}
476                        onerror={fetch_data}
477                        style={blur_style}
478                    />
479                </span>
480            }
481        } else {
482            rsx! {}
483        }
484    } else {
485        rsx! {
486            <span style={String::from("display: block;")}>
487                <img
488                    src={props.src}
489                    alt={props.alt}
490                    style={img_style}
491                    class={props.class}
492                    loading={if props.priority { "eager" } else { "lazy" }}
493                    sizes={props.sizes}
494                    quality={props.quality}
495                    placeholder={props.placeholder}
496                    decoding={props.decoding}
497                    ref={props.node_ref}
498                    role="img"
499                    aria-label={props.alt}
500                    aria-labelledby={props.aria_labelledby}
501                    aria-describedby={props.aria_describedby}
502                    aria-hidden={props.aria_hidden}
503                    aria-current={props.aria_current}
504                    aria-expanded={props.aria_expanded}
505                    aria-live={props.aria_live}
506                    aria-pressed={props.aria_pressed}
507                    aria-controls={props.aria_controls}
508                    onerror={fetch_data}
509                    style={blur_style}
510                />
511            </span>
512        }
513    };
514    rsx! {
515            {layout}
516    }
517}