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#[derive(Properties, Clone, PartialEq)]
10pub struct ImageProps {
11 #[prop_or_default]
13 pub src: &'static str,
14
15 #[prop_or_default]
17 pub alt: &'static str,
18
19 #[prop_or_default]
21 pub width: &'static str,
22
23 #[prop_or_default]
25 pub height: &'static str,
26
27 #[prop_or_default]
29 pub style: &'static str,
31
32 #[prop_or_default]
33 pub class: &'static str,
35
36 #[prop_or_default]
37 pub sizes: &'static str,
39
40 #[prop_or_default]
41 pub quality: &'static str,
43
44 #[prop_or_default]
45 pub priority: bool,
47
48 #[prop_or_default]
49 pub placeholder: &'static str,
51
52 #[prop_or_default]
53 pub on_loading_complete: Callback<()>,
55
56 #[prop_or_default]
58 pub object_fit: &'static str,
60
61 #[prop_or_default]
62 pub object_position: &'static str,
64
65 #[prop_or_default]
66 pub on_error: Callback<String>,
68
69 #[prop_or_default]
70 pub decoding: &'static str,
72
73 #[prop_or_default]
74 pub blur_data_url: &'static str,
76
77 #[prop_or_default]
78 pub lazy_boundary: &'static str,
80
81 #[prop_or_default]
82 pub unoptimized: bool,
84
85 #[prop_or_default]
86 pub layout: &'static str,
88
89 #[prop_or_default]
90 pub node_ref: NodeRef,
92
93 #[prop_or_default]
94 pub aria_current: &'static str,
96
97 #[prop_or_default]
98 pub aria_describedby: &'static str,
100
101 #[prop_or_default]
102 pub aria_expanded: &'static str,
104
105 #[prop_or_default]
106 pub aria_hidden: &'static str,
108
109 #[prop_or_default]
110 pub aria_live: &'static str,
112
113 #[prop_or_default]
114 pub aria_pressed: &'static str,
116
117 #[prop_or_default]
118 pub aria_controls: &'static str,
120
121 #[prop_or_default]
122 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#[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 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 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 let observer = IntersectionObserver::new_with_options(&callback, &options)
258 .expect("Failed to create IntersectionObserver");
259
260 if let Some(img) = img_ref.cast::<web_sys::HtmlElement>() {
262 observer.observe(&img);
263 }
264
265 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 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}