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}