image_rs/
leptos.rs

1#![doc = include_str!("../LEPTOS.md")]
2
3use crate::common::{
4    CrossOrigin, Decoding, FetchPriority, Layout, Loading, ObjectFit, Position, ReferrerPolicy,
5};
6use gloo_net::http::Request;
7use leptos::callback::Callback;
8use leptos::task::spawn_local;
9use leptos::{html::*, prelude::*, *};
10use web_sys::IntersectionObserverEntry;
11use web_sys::js_sys;
12use web_sys::wasm_bindgen::JsCast;
13use web_sys::wasm_bindgen::closure::Closure;
14use web_sys::{IntersectionObserver, IntersectionObserverInit, RequestCache};
15
16// Comment out aria attrs cause of: tachys-0.2.0/src/html/attribute/mod.rs:593:1:
17// not yet implemented: adding more than 26 attributes is not supported
18#[component]
19pub fn Image(
20    /// The source URL of the image.
21    ///
22    /// This is the primary image that will be rendered.
23    #[prop(optional)]
24    src: &'static str,
25
26    /// The alternative text for the image.
27    ///
28    /// Used for accessibility and shown if the image cannot be displayed.
29    #[prop(optional, default = "Image")]
30    alt: &'static str,
31
32    /// A fallback image URL if the main image fails to load.
33    #[prop(optional)]
34    fallback_src: &'static str,
35
36    /// Width of the image (e.g., "100px", "auto").
37    #[prop(optional)]
38    width: &'static str,
39
40    /// Height of the image (e.g., "100px", "auto").
41    #[prop(optional)]
42    height: &'static str,
43
44    /// Inline styles applied to the image.
45    #[prop(optional)]
46    style: &'static str,
47
48    /// CSS class name(s) to apply to the image.
49    #[prop(optional)]
50    class: &'static str,
51
52    /// Image `sizes` attribute for responsive loading.
53    #[prop(optional)]
54    sizes: &'static str,
55
56    // #[prop(optional)] quality: &'static str,
57    /// Defines how the image is loaded. Defaults to lazy loading.
58    #[prop(optional, default = Loading::Lazy)]
59    loading: Loading,
60
61    /// Placeholder content shown while the image loads.
62    #[prop(optional, default = "empty")]
63    placeholder: &'static str,
64
65    /// Callback function fired when the image is successfully loaded.
66    #[prop(optional)]
67    on_load: Option<Callback<()>>,
68
69    /// Specifies how the image should be resized to fit its container.
70    #[prop(optional, default = ObjectFit::Fill)]
71    object_fit: ObjectFit,
72
73    /// Specifies the position of the image within its container.
74    #[prop(optional, default = Position::Center)]
75    object_position: Position,
76
77    /// Callback function fired when the image fails to load.
78    #[prop(optional)]
79    on_error: Option<Callback<String>>,
80
81    /// Specifies how the image should be decoded (auto, sync, async).
82    #[prop(optional, default = Decoding::Auto)]
83    decoding: Decoding,
84
85    /// Base64-encoded blurred image shown before the main image loads.
86    #[prop(optional)]
87    blur_data_url: &'static str,
88
89    // #[prop(optional, default = "100px")] lazy_boundary: &'static str,
90    // #[prop(optional, default = false)] unoptimized: bool,
91    /// Controls how the image is laid out inside its container.
92    #[prop(optional, default = Layout::Responsive)]
93    layout: Layout,
94
95    /// Reference to the image DOM element.
96    #[prop(optional)]
97    node_ref: NodeRef<Img>,
98
99    /// One or more image sources with descriptors (e.g., "img-1x.jpg 1x, img-2x.jpg 2x").
100    #[prop(optional)]
101    srcset: &'static str,
102
103    /// CORS policy for fetching the image (none, anonymous, use-credentials).
104    #[prop(optional, default = CrossOrigin::None)]
105    crossorigin: CrossOrigin,
106
107    /// Referrer policy when fetching the image.
108    #[prop(optional, default = ReferrerPolicy::NoReferrer)]
109    referrerpolicy: ReferrerPolicy,
110
111    /// Associates the image with an image map.
112    #[prop(optional)]
113    usemap: &'static str,
114
115    /// Indicates the image is part of a server-side image map.
116    #[prop(optional, default = false)]
117    ismap: bool,
118
119    /// Fetch priority hint for the browser (auto, high, low).
120    #[prop(optional, default = FetchPriority::Auto)]
121    fetchpriority: FetchPriority,
122
123    /// Identifier for performance element timing.
124    #[prop(optional)]
125    elementtiming: &'static str,
126    /// Indicates the current item in a set for accessibility.
127    // #[prop(optional)] aria_current: &'static str,
128    /// ID reference to the element describing this image.
129    // #[prop(optional)] aria_describedby: &'static str,
130    /// Whether the associated content is expanded or collapsed.
131    // #[prop(optional)] aria_expanded: &'static str,
132    /// Whether the image is hidden from assistive technologies.
133    /// #[prop(optional)] aria_hidden: &'static str,
134    /// Indicates the pressed state of the image if it's used as a toggle.
135    // #[prop(optional, default = AriaPressed::Undefined)] aria_pressed: AriaPressed,
136    /// ID reference to the element this image controls.
137    // #[prop(optional)] aria_controls: &'static str,
138    /// ID reference to the element that labels this image.
139    // #[prop(optional)] aria_labelledby: &'static str,
140    /// Indicates whether updates to the image are live.
141    // #[prop(optional, default = AriaLive::Off)] aria_live: AriaLive,
142    /// URLs for Attribution Reporting (experimental feature).
143    #[prop(optional)]
144    attributionsrc: &'static str,
145) -> impl IntoView {
146    let (img_src, set_img_src) = signal(src);
147
148    Effect::new(move || {
149        let callback = Closure::wrap(Box::new(
150            move |entries: js_sys::Array, _observer: IntersectionObserver| {
151                if let Some(entry) = entries.get(0).dyn_ref::<IntersectionObserverEntry>() {
152                    if entry.is_intersecting() {
153                        if let Some(node) = node_ref.get() {
154                            if let Some(img) = node.dyn_ref::<web_sys::HtmlImageElement>() {
155                                img.set_src(src);
156                                if let Some(cb) = on_load {
157                                    cb.run(());
158                                }
159                            }
160                        }
161                    }
162                }
163            },
164        )
165            as Box<dyn FnMut(js_sys::Array, IntersectionObserver)>);
166
167        let options = IntersectionObserverInit::new();
168        options.set_threshold(&js_sys::Array::of1(&0.1.into()));
169
170        let observer =
171            IntersectionObserver::new_with_options(callback.as_ref().unchecked_ref(), &options)
172                .expect("Failed to create IntersectionObserver");
173
174        if let Some(element) = node_ref.get() {
175            if let Ok(img) = element.clone().dyn_into::<web_sys::HtmlElement>() {
176                observer.observe(&img);
177            }
178        }
179
180        let observer_clone = observer.clone();
181        let _cleanup = move || {
182            observer_clone.disconnect();
183        };
184
185        callback.forget();
186    });
187
188    let onload = move |_| {
189        if let Some(cb) = on_load {
190            cb.run(());
191        }
192    };
193
194    let onerror = {
195        move |_| {
196            spawn_local(async move {
197                match Request::get(fallback_src)
198                    .cache(RequestCache::Reload)
199                    .send()
200                    .await
201                {
202                    Ok(res) if res.status() == 200 => match res.json::<serde_json::Value>().await {
203                        Ok(_) => {
204                            set_img_src.set(fallback_src);
205                            if let Some(cb) = on_load {
206                                cb.run(());
207                            }
208                        }
209                        Err(_) => {
210                            if let Some(cb) = on_error {
211                                cb.run("Image not found!".to_string());
212                            }
213                        }
214                    },
215                    Ok(res) => {
216                        let body = res.text().await.unwrap_or_default();
217                        if let Some(cb) = on_error {
218                            cb.run(format!(
219                                "Failed to load image. Status: {}, Body: {}",
220                                res.status(),
221                                body
222                            ));
223                        }
224                    }
225                    Err(e) => {
226                        if let Some(cb) = on_error {
227                            cb.run(format!("Network error: {e}"));
228                        }
229                    }
230                }
231            });
232        }
233    };
234
235    let img_style = format!(
236        "{} object-fit: {}; object-position: {};",
237        style,
238        object_fit.as_str(),
239        object_position.as_str()
240    );
241
242    let blur_style = if placeholder == "blur" && !blur_data_url.is_empty() {
243        format!(
244            "background-size: {}; background-position: {}; filter: blur(20px); background-image: url('{}');",
245            sizes,
246            object_position.as_str(),
247            blur_data_url
248        )
249    } else {
250        "".into()
251    };
252
253    let full_style = format!("{blur_style} {img_style}");
254
255    let layout_view = match layout {
256        Layout::Fill => view! {
257            <span style="display:block; position:absolute; top:0; left:0; right:0; bottom:0;">
258                <img
259                    node_ref=node_ref
260                    src=move || img_src.get()
261                    alt=alt
262                    class=class
263                    width=width
264                    height=height
265                    style=full_style.clone()
266                    sizes=sizes
267                    srcset=srcset
268                    decoding=decoding.as_str()
269                    crossorigin=crossorigin.as_str()
270                    referrerpolicy=referrerpolicy.as_str()
271                    loading=loading.as_str()
272                    fetchpriority=fetchpriority.as_str()
273                    aria_placeholder=placeholder
274                    on:load=onload
275                    on:error=onerror
276                    role="img"
277                    // aria-label=alt
278                    // aria-labelledby=aria_labelledby
279                    // aria-describedby=aria_describedby
280                    // aria-hidden=aria_hidden
281                    // aria-current=aria_current
282                    // aria-expanded=aria_expanded
283                    // aria-live=aria_live.as_str()
284                    // aria-pressed=aria_pressed.as_str()
285                    // aria-controls=aria_controls
286                    usemap=usemap
287                    ismap=ismap
288                    elementtiming=elementtiming
289                    attributionsrc=attributionsrc
290                />
291            </span>
292        }
293        .into_any(),
294
295        Layout::Responsive => {
296            let ratio = height.parse::<f64>().unwrap_or(1.0) / width.parse::<f64>().unwrap_or(1.0);
297            let padding = format!("{}%", ratio * 100.0);
298            view! {
299                <span style="display:block; position:relative;">
300                    <span style=format!("padding-top: {padding}")>
301                        <img
302                            node_ref=node_ref
303                            src=move || img_src.get()
304                            alt=alt
305                            class=class
306                            width=width
307                            height=height
308                            style=full_style.clone()
309                            sizes=sizes
310                            srcset=srcset
311                            decoding=decoding.as_str()
312                            crossorigin=crossorigin.as_str()
313                            referrerpolicy=referrerpolicy.as_str()
314                            loading=loading.as_str()
315                            fetchpriority=fetchpriority.as_str()
316                            aria_placeholder=placeholder
317                            on:load=onload
318                            on:error=onerror
319                            role="img"
320                            // aria-label=alt
321                            // aria-labelledby=aria_labelledby
322                            // aria-describedby=aria_describedby
323                            // aria-hidden=aria_hidden
324                            // aria-current=aria_current
325                            // aria-expanded=aria_expanded
326                            // aria-live=aria_live.as_str()
327                            // aria-pressed=aria_pressed.as_str()
328                            // aria-controls=aria_controls
329                            usemap=usemap
330                            ismap=ismap
331                            elementtiming=elementtiming
332                            attributionsrc=attributionsrc
333                        />
334                    </span>
335                </span>
336            }
337            .into_any()
338        }
339
340        Layout::Intrinsic => view! {
341            <span style="display:inline-block; position:relative; max-width:100%;">
342                <span style="max-width:100%;">
343                    <img
344                        node_ref=node_ref
345                        src=move || img_src.get()
346                        alt=alt
347                        class=class
348                        width=width
349                        height=height
350                        style=full_style.clone()
351                        sizes=sizes
352                        srcset=srcset
353                        decoding=decoding.as_str()
354                        crossorigin=crossorigin.as_str()
355                        referrerpolicy=referrerpolicy.as_str()
356                        loading=loading.as_str()
357                        fetchpriority=fetchpriority.as_str()
358                        aria_placeholder=placeholder
359                        on:load=onload
360                        on:error=onerror
361                        role="img"
362                        // aria-label=alt
363                        // aria-labelledby=aria_labelledby
364                        // aria-describedby=aria_describedby
365                        // aria-hidden=aria_hidden
366                        // aria-current=aria_current
367                        // aria-expanded=aria_expanded
368                        // aria-live=aria_live.as_str()
369                        // aria-pressed=aria_pressed.as_str()
370                        // aria-controls=aria_controls
371                        usemap=usemap
372                        ismap=ismap
373                        elementtiming=elementtiming
374                        attributionsrc=attributionsrc
375                    />
376                </span>
377                <img
378                    src=blur_data_url
379                    style="display:none;"
380                    alt=alt
381                    aria-hidden="true"
382                />
383            </span>
384        }
385        .into_any(),
386
387        Layout::Fixed => view! {
388            <span style="display:inline-block; position:relative;">
389                <img
390                    node_ref=node_ref
391                    src=move || img_src.get()
392                    alt=alt
393                    class=class
394                    width=width
395                    height=height
396                    style=full_style.clone()
397                    sizes=sizes
398                    srcset=srcset
399                    decoding=decoding.as_str()
400                    crossorigin=crossorigin.as_str()
401                    referrerpolicy=referrerpolicy.as_str()
402                    loading=loading.as_str()
403                    fetchpriority=fetchpriority.as_str()
404                    aria_placeholder=placeholder
405                    on:load=onload
406                    on:error=onerror
407                    role="img"
408                    // aria-label=alt
409                    // aria-labelledby=aria_labelledby
410                    // aria-describedby=aria_describedby
411                    // aria-hidden=aria_hidden
412                    // aria-current=aria_current
413                    // aria-expanded=aria_expanded
414                    // aria-live=aria_live.as_str()
415                    // aria-pressed=aria_pressed.as_str()
416                    // aria-controls=aria_controls
417                    usemap=usemap
418                    ismap=ismap
419                    elementtiming=elementtiming
420                    attributionsrc=attributionsrc
421                />
422            </span>
423        }
424        .into_any(),
425
426        Layout::Auto => view! {
427            <span style="display:inline-block; position:relative;">
428                <img
429                    node_ref=node_ref
430                    src=move || img_src.get()
431                    alt=alt
432                    class=class
433                    width=width
434                    height=height
435                    style=full_style.clone()
436                    sizes=sizes
437                    srcset=srcset
438                    decoding=decoding.as_str()
439                    crossorigin=crossorigin.as_str()
440                    referrerpolicy=referrerpolicy.as_str()
441                    loading=loading.as_str()
442                    fetchpriority=fetchpriority.as_str()
443                    aria_placeholder=placeholder
444                    on:load=onload
445                    on:error=onerror
446                    role="img"
447                    // aria-label=alt
448                    // aria-labelledby=aria_labelledby
449                    // aria-describedby=aria_describedby
450                    // aria-hidden=aria_hidden
451                    // aria-current=aria_current
452                    // aria-expanded=aria_expanded
453                    // aria-live=aria_live.as_str()
454                    // aria-pressed=aria_pressed.as_str()
455                    // aria-controls=aria_controls
456                    usemap=usemap
457                    ismap=ismap
458                    elementtiming=elementtiming
459                    attributionsrc=attributionsrc
460                />
461            </span>
462        }
463        .into_any(),
464
465        Layout::Stretch => view! {
466            <span style="display:block; width:100%; height:100%; position:relative;">
467                <img
468                    node_ref=node_ref
469                    src=move || img_src.get()
470                    alt=alt
471                    class=class
472                    width="100%"
473                    height="100%"
474                    style=full_style.clone()
475                    sizes=sizes
476                    srcset=srcset
477                    decoding=decoding.as_str()
478                    crossorigin=crossorigin.as_str()
479                    referrerpolicy=referrerpolicy.as_str()
480                    loading=loading.as_str()
481                    fetchpriority=fetchpriority.as_str()
482                    aria_placeholder=placeholder
483                    on:load=onload
484                    on:error=onerror
485                    role="img"
486                    // aria-label=alt
487                    // aria-labelledby=aria_labelledby
488                    // aria-describedby=aria_describedby
489                    // aria-hidden=aria_hidden
490                    // aria-current=aria_current
491                    // aria-expanded=aria_expanded
492                    // aria-live=aria_live.as_str()
493                    // aria-pressed=aria_pressed.as_str()
494                    // aria-controls=aria_controls
495                    usemap=usemap
496                    ismap=ismap
497                    elementtiming=elementtiming
498                    attributionsrc=attributionsrc
499                />
500            </span>
501        }
502        .into_any(),
503
504        Layout::ScaleDown => view! {
505            <span style="display:inline-block; position:relative; max-width:100%; max-height:100%;">
506                <img
507                    node_ref=node_ref
508                    src=move || img_src.get()
509                    alt=alt
510                    class=class
511                    width=width
512                    height=height
513                    style=full_style.clone()
514                    sizes=sizes
515                    srcset=srcset
516                    decoding=decoding.as_str()
517                    crossorigin=crossorigin.as_str()
518                    referrerpolicy=referrerpolicy.as_str()
519                    loading=loading.as_str()
520                    fetchpriority=fetchpriority.as_str()
521                    aria_placeholder=placeholder
522                    on:load=onload
523                    on:error=onerror
524                    role="img"
525                    // aria-label=alt
526                    // aria-labelledby=aria_labelledby
527                    // aria-describedby=aria_describedby
528                    // aria-hidden=aria_hidden
529                    // aria-current=aria_current
530                    // aria-expanded=aria_expanded
531                    // aria-live=aria_live.as_str()
532                    // aria-pressed=aria_pressed.as_str()
533                    // aria-controls=aria_controls
534                    usemap=usemap
535                    ismap=ismap
536                    elementtiming=elementtiming
537                    attributionsrc=attributionsrc
538                />
539            </span>
540        }
541        .into_any(),
542    };
543
544    view! {
545        {layout_view}
546    }
547}