diff --git a/Cargo.toml b/Cargo.toml index 99f61bd..c115968 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -44,8 +44,10 @@ web-sys = { version = "0.3", features = [ "WheelEvent", "Element", "HtmlElement", + "HtmlBodyElement", "CssStyleDeclaration", "DomRect", + "Node", "TouchEvent", "Touch", "TouchList", diff --git a/index.html b/index.html index 1916e4b..c30a11f 100644 --- a/index.html +++ b/index.html @@ -54,6 +54,26 @@ font-synthesis: none; margin: 0px; } + #post-images { + position: fixed; + overflow: hidden; + pointer-events: none; + z-index: 5; + } + #post-images a { + position: absolute; + display: block; + pointer-events: auto; + cursor: pointer; + } + #post-images img { + width: 100%; + height: 100%; + object-fit: contain; + object-position: center; + display: block; + background-color: #1c1916; + } diff --git a/src/app.rs b/src/app.rs index b974c51..82f4c8c 100644 --- a/src/app.rs +++ b/src/app.rs @@ -120,6 +120,7 @@ impl App { } fn paint(&self, frame: &mut Frame<'_>) -> bool { + crate::ui::overlay::begin_frame(); let router = self.router.borrow(); let mouse = self.mouse.borrow(); let mut hits = self.hits.borrow_mut(); @@ -143,6 +144,7 @@ impl App { copied, }; ui::render(&mut ctx, frame); + crate::ui::overlay::sync(); *self.content_height.borrow_mut() = metrics.0; *self.viewport_height.borrow_mut() = metrics.1; let max = metrics.0.saturating_sub(metrics.1); diff --git a/src/module/notion.rs b/src/module/notion.rs index 491829d..01e75d9 100644 --- a/src/module/notion.rs +++ b/src/module/notion.rs @@ -36,6 +36,19 @@ pub struct TagSection { pub enum PostSegment { Text(String), Code(String), + Image(PostImage), +} + +/// A Notion image (or image file) to overlay on the post body. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct PostImage { + pub src: String, + #[serde(default)] + pub alt: String, + #[serde(default)] + pub width: Option, + #[serde(default)] + pub height: Option, } pub fn tag_slug(tag: &str) -> String { @@ -96,6 +109,14 @@ pub fn segments_plain_text(segments: &[PostSegment]) -> String { } match segment { PostSegment::Text(text) | PostSegment::Code(text) => out.push_str(text), + PostSegment::Image(image) => { + if image.alt.is_empty() { + out.push_str("[image]"); + } else { + out.push_str("[image] "); + out.push_str(&image.alt); + } + } } } out @@ -219,6 +240,8 @@ fn collect_segments(blocks: &Value, ids: &[String], root_id: &str, out: &mut Vec } if kind == "code" { out.push(PostSegment::Code(block_source_text(value))); + } else if let Some(image) = extract_image(kind, id, value) { + out.push(PostSegment::Image(image)); } else if let Some(text) = block_display_text(kind, value, numbered) { out.push(PostSegment::Text(text)); } @@ -250,7 +273,6 @@ fn block_display_text(kind: &str, value: &Value, numbered: u32) -> Option Some("────────".to_string()), - "image" => Some(format!("[image] {}", first_link(value)).trim().to_string()), "bookmark" | "link_preview" | "embed" => { let link = first_link(value); if link.is_empty() && text.is_empty() { @@ -285,6 +307,148 @@ fn is_checked(value: &Value) -> bool { .is_some_and(|flag| flag.eq_ignore_ascii_case("Yes") || flag == "true") } +fn extract_image(kind: &str, block_id: &str, value: &Value) -> Option { + if kind != "image" && kind != "file" { + return None; + } + let raw = image_raw_source(value); + if raw.is_empty() { + return None; + } + let caption = block_raw_text(&value["properties"]["caption"]); + let title = block_raw_text(&value["properties"]["title"]); + let alt = if caption.is_empty() { title } else { caption }; + if kind == "file" && !looks_like_image(&raw) && !looks_like_image(&alt) { + return None; + } + let src = map_image_url(&raw, block_id, value["space_id"].as_str()); + if src.is_empty() { + return None; + } + let width = json_u32(&value["format"]["block_width"]); + let height = json_u32(&value["format"]["block_height"]).or_else(|| { + let aspect = value["format"]["block_aspect_ratio"].as_f64()?; + let w = width.unwrap_or(1000); + Some((f64::from(w) * aspect).round().max(1.0) as u32) + }); + Some(PostImage { + src, + alt, + width, + height, + }) +} + +fn image_raw_source(value: &Value) -> String { + if let Some(source) = value["format"]["display_source"].as_str() { + if !source.is_empty() { + return source.to_string(); + } + } + for key in ["source", "url"] { + let text = block_raw_text(&value["properties"][key]); + if !text.is_empty() { + return text; + } + } + String::new() +} + +fn looks_like_image(name_or_url: &str) -> bool { + let path = name_or_url + .split(['?', '#']) + .next() + .unwrap_or(name_or_url) + .rsplit(['/', ':']) + .next() + .unwrap_or(name_or_url); + let ext = path.rsplit('.').next().unwrap_or("").to_ascii_lowercase(); + matches!( + ext.as_str(), + "png" | "jpg" | "jpeg" | "gif" | "webp" | "svg" | "bmp" | "avif" | "ico" + ) +} + +fn map_image_url(raw: &str, block_id: &str, space_id: Option<&str>) -> String { + let raw = raw.trim(); + if raw.is_empty() { + return String::new(); + } + if raw.starts_with("data:") || raw.starts_with("https://images.unsplash.com") { + return raw.to_string(); + } + if is_direct_http_image(raw) { + return raw.to_string(); + } + let source = if raw.starts_with("/images") { + format!("https://www.notion.so{raw}") + } else { + raw.to_string() + }; + let mut url = format!( + "https://www.notion.so/image/{}?table=block&id={}&cache=v2", + encode_uri_component(&source), + super::config::dashed_id(block_id) + ); + if let Some(space) = space_id.filter(|id| !id.is_empty()) { + url.push_str("&spaceId="); + url.push_str(space); + } + url +} + +fn is_direct_http_image(raw: &str) -> bool { + let lower = raw.to_ascii_lowercase(); + (lower.starts_with("https://") || lower.starts_with("http://")) + && !lower.contains("amazonaws.com") + && !lower.contains("notion-static") + && !lower.contains("prod-files-secure") + && !lower.contains("notionusercontent.com") + && !lower.contains("notion.so/") + && !lower.contains("notion.site/") +} + +fn encode_uri_component(input: &str) -> String { + let mut out = String::with_capacity(input.len()); + for byte in input.bytes() { + match byte { + b'A'..=b'Z' + | b'a'..=b'z' + | b'0'..=b'9' + | b'-' + | b'_' + | b'.' + | b'!' + | b'~' + | b'*' + | b'\'' + | b'(' + | b')' => out.push(byte as char), + _ => { + const HEX: &[u8; 16] = b"0123456789ABCDEF"; + out.push('%'); + out.push(HEX[(byte >> 4) as usize] as char); + out.push(HEX[(byte & 0xf) as usize] as char); + } + } + } + out +} + +fn json_u32(value: &Value) -> Option { + value + .as_u64() + .and_then(|n| u32::try_from(n).ok()) + .or_else(|| { + let n = value.as_f64()?; + if n.is_finite() && n > 0.0 { + Some(n.round() as u32) + } else { + None + } + }) +} + fn first_link(value: &Value) -> String { for key in ["source", "link", "url", "title"] { let text = block_raw_text(&value["properties"][key]); @@ -449,7 +613,10 @@ pub fn extract_catalog(json: &str, page_id: &str) -> Vec { #[cfg(test)] mod tests { - use super::{extract_catalog, extract_h2_titles, extract_segments, PostSegment}; + use super::{ + encode_uri_component, extract_catalog, extract_h2_titles, extract_segments, map_image_url, + PostImage, PostSegment, + }; const PAGE_ID: &str = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaa0"; @@ -668,4 +835,156 @@ mod tests { [PostSegment::Text("1. one\n2. two\n[x] done".into())] ); } + + #[test] + fn extracts_attachment_images_as_segments() { + let json = format!( + r#"{{ + "recordMap": {{ + "block": {{ + "{PAGE_ID}": {{ + "value": {{ + "value": {{ + "type": "page", + "content": [ + "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaa1", + "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaa2", + "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaa3" + ] + }} + }} + }}, + "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaa1": {{ + "value": {{ "value": {{ + "type": "text", + "properties": {{ "title": [["before"]] }} + }} }} + }}, + "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaa2": {{ + "value": {{ "value": {{ + "id": "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaa2", + "type": "image", + "properties": {{ + "title": [["image.png"]], + "source": [["attachment:7a1e5ccd-30d2-4e13-a112-8d48b21b099b:image.png"]] + }}, + "format": {{ + "block_width": 676, + "block_height": 312, + "display_source": "attachment:7a1e5ccd-30d2-4e13-a112-8d48b21b099b:image.png", + "block_aspect_ratio": 0.4508670520231214 + }}, + "space_id": "9b1457a8-0dc4-4a55-a7a6-0d2d40822805" + }} }} + }}, + "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaa3": {{ + "value": {{ "value": {{ + "type": "text", + "properties": {{ "title": [["after"]] }} + }} }} + }} + }} + }} + }}"# + ); + let segments = extract_segments(&serde_json::from_str(&json).unwrap(), PAGE_ID); + assert_eq!( + segments, + [ + PostSegment::Text("before".into()), + PostSegment::Image(PostImage { + src: "https://www.notion.so/image/attachment%3A7a1e5ccd-30d2-4e13-a112-8d48b21b099b%3Aimage.png?table=block&id=aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaa2&cache=v2&spaceId=9b1457a8-0dc4-4a55-a7a6-0d2d40822805".into(), + alt: "image.png".into(), + width: Some(676), + height: Some(312), + }), + PostSegment::Text("after".into()), + ] + ); + } + + #[test] + fn keeps_external_image_urls() { + let json = format!( + r#"{{ + "recordMap": {{ + "block": {{ + "{PAGE_ID}": {{ + "value": {{ + "value": {{ + "type": "page", + "content": ["aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaa1"] + }} + }} + }}, + "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaa1": {{ + "value": {{ "value": {{ + "type": "image", + "properties": {{ + "source": [["https://example.com/pic.jpg"]] + }} + }} }} + }} + }} + }} + }}"# + ); + let segments = extract_segments(&serde_json::from_str(&json).unwrap(), PAGE_ID); + assert_eq!( + segments, + [PostSegment::Image(PostImage { + src: "https://example.com/pic.jpg".into(), + alt: String::new(), + width: None, + height: None, + })] + ); + } + + #[test] + fn skips_non_image_files() { + let json = format!( + r#"{{ + "recordMap": {{ + "block": {{ + "{PAGE_ID}": {{ + "value": {{ + "value": {{ + "type": "page", + "content": ["aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaa1"] + }} + }} + }}, + "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaa1": {{ + "value": {{ "value": {{ + "type": "file", + "properties": {{ + "title": [["notes.pdf"]], + "source": [["https://example.com/notes.pdf"]] + }} + }} }} + }} + }} + }} + }}"# + ); + let segments = extract_segments(&serde_json::from_str(&json).unwrap(), PAGE_ID); + assert!(segments.is_empty()); + } + + #[test] + fn map_image_url_proxies_attachments() { + let url = map_image_url( + "attachment:7a1e5ccd-30d2-4e13-a112-8d48b21b099b:image.png", + "3ceec5eb3d22802d90fccae64a6bd2bd", + Some("9b1457a8-0dc4-4a55-a7a6-0d2d40822805"), + ); + assert!(url.starts_with("https://www.notion.so/image/attachment%3A")); + assert!(url.contains("id=3ceec5eb-3d22-802d-90fc-cae64a6bd2bd")); + assert!(url.contains("spaceId=9b1457a8-0dc4-4a55-a7a6-0d2d40822805")); + assert_eq!( + encode_uri_component("attachment:x:y.png"), + "attachment%3Ax%3Ay.png" + ); + } } diff --git a/src/module/scraper.rs b/src/module/scraper.rs index 8979d44..06ca90d 100644 --- a/src/module/scraper.rs +++ b/src/module/scraper.rs @@ -28,7 +28,7 @@ thread_local! { static INTERVAL_STARTED: RefCell = const { RefCell::new(false) }; } -pub use notion::{same_page_id, tag_slug, ContentPage, PostSegment, TagSection}; +pub use notion::{same_page_id, tag_slug, ContentPage, PostImage, PostSegment, TagSection}; pub fn current_tags() -> Vec { catalog::current_tags() diff --git a/src/module/snapshot.rs b/src/module/snapshot.rs index 740a24a..8c80fd9 100644 --- a/src/module/snapshot.rs +++ b/src/module/snapshot.rs @@ -41,6 +41,29 @@ mod tests { assert!(Snapshot::parse("not json").is_err()); } + #[test] + fn image_segments_round_trip() { + use crate::module::notion::{PostImage, PostSegment}; + use std::collections::HashMap; + + let snapshot = Snapshot { + saved_at: 1, + posts: HashMap::from([( + "aa".to_string(), + vec![PostSegment::Image(PostImage { + src: "https://example.com/a.png".into(), + alt: "a.png".into(), + width: Some(10), + height: Some(5), + })], + )]), + ..Snapshot::default() + }; + let json = snapshot.to_json_compact().unwrap(); + assert!(json.contains("Image")); + assert_eq!(Snapshot::parse(&json).unwrap(), snapshot); + } + #[test] fn has_content_detects_posts_or_about() { let empty = Snapshot::default(); diff --git a/src/mouse.rs b/src/mouse.rs index 5bf5423..cd15ea0 100644 --- a/src/mouse.rs +++ b/src/mouse.rs @@ -43,6 +43,26 @@ pub fn cell_height_px() -> f32 { METRICS.with(|slot| slot.borrow().cell_h) } +pub fn cell_size_px() -> (f32, f32) { + METRICS.with(|slot| { + let metrics = *slot.borrow(); + (metrics.cell_w, metrics.cell_h) + }) +} + +/// Viewport-relative CSS pixels of a cell `Rect`, using the measured grid. +pub fn rect_css_px(area: Rect) -> (f32, f32, f32, f32) { + METRICS.with(|slot| { + let metrics = *slot.borrow(); + ( + metrics.origin_x + f32::from(area.x) * metrics.cell_w, + metrics.origin_y + f32::from(area.y) * metrics.cell_h, + f32::from(area.width) * metrics.cell_w, + f32::from(area.height) * metrics.cell_h, + ) + }) +} + /// Measure the Ratzilla `#grid` so hit-testing tracks zoom, font, and centering. pub fn refresh_cell_metrics() { let Some(metrics) = measure_grid() else { diff --git a/src/ui/blog.rs b/src/ui/blog.rs index 2120baa..2e18715 100644 --- a/src/ui/blog.rs +++ b/src/ui/blog.rs @@ -1,11 +1,12 @@ +use super::overlay::{self, ImageSlot}; use super::FrameCtx; use crate::content::{self, CatalogStatus}; -use crate::module::notion::PostSegment; +use crate::module::notion::{PostImage, PostSegment}; use crate::module::scraper::{same_page_id, tag_slug}; use crate::mouse::{list_row_y, CellSpan}; use crate::router::Router; use crate::theme; -use crate::width::{display_width, wrapped_rows}; +use crate::width::{display_width, truncate_display, wrapped_rows}; use ratatui::layout::{Alignment, Constraint, Layout, Rect}; use ratatui::style::{Style, Stylize}; use ratatui::text::{Line, Span}; @@ -315,6 +316,9 @@ fn render_segments( PostSegment::Code(code) => { render_code_block(ctx, frame, dest, code, skip); } + PostSegment::Image(image) => { + render_image_block(frame, dest, image, content_y, offset, body_area); + } } } @@ -368,9 +372,64 @@ fn segment_height(segment: &PostSegment, width: u16) -> u16 { PostSegment::Code(code) => { wrapped_rows(code, width.saturating_sub(2).max(1)).saturating_add(2) } + PostSegment::Image(image) => image_inner_rows(width, image).saturating_add(2), } } +const IMAGE_MIN_ROWS: u16 = 3; +const IMAGE_MAX_ROWS: u16 = 24; + +fn image_inner_rows(width: u16, image: &PostImage) -> u16 { + let inner_w = width.saturating_sub(2).max(1); + let aspect = match (image.width, image.height) { + (Some(w), Some(h)) if w > 0 => h as f32 / w as f32, + _ => 9.0 / 16.0, + }; + let (cell_w, cell_h) = crate::mouse::cell_size_px(); + let height_px = f32::from(inner_w) * cell_w * aspect; + let rows = (height_px / cell_h.max(1.0)).ceil() as u16; + rows.clamp(IMAGE_MIN_ROWS, IMAGE_MAX_ROWS) +} + +fn render_image_block( + frame: &mut Frame<'_>, + area: Rect, + image: &PostImage, + content_y: u16, + offset: u16, + clip: Rect, +) { + let title = if image.alt.is_empty() { + "image".to_string() + } else { + truncate_display(&image.alt, area.width.saturating_sub(4) as usize) + }; + let style = Style::new().fg(theme::DIM).bg(theme::BG); + frame.render_widget( + Block::bordered() + .border_type(BorderType::Plain) + .border_style(style) + .title(title) + .style(style), + area, + ); + + let inner_h = image_inner_rows(clip.width, image); + let inner_w = clip.width.saturating_sub(2); + if inner_h == 0 || inner_w == 0 { + return; + } + overlay::push(ImageSlot { + src: image.src.clone(), + alt: image.alt.clone(), + clip, + x: 1, + y: i32::from(content_y) - i32::from(offset) + 1, + width: inner_w, + height: inner_h, + }); +} + fn render_code_block( ctx: &mut FrameCtx<'_>, frame: &mut Frame<'_>, @@ -444,9 +503,10 @@ fn format_post_label(title: &str, width: u16) -> String { #[cfg(test)] mod tests { - use super::{format_post_label, segment_height}; + use super::{format_post_label, image_inner_rows, segment_height}; use crate::content::split_trailing_date; - use crate::module::notion::PostSegment; + use crate::module::notion::{PostImage, PostSegment}; + use crate::ui::overlay; use ratatui::layout::Rect; #[test] @@ -481,4 +541,53 @@ mod tests { let segment = PostSegment::Text("abcdefghij".into()); assert_eq!(segment_height(&segment, 5), 2); } + + #[test] + fn image_height_uses_aspect_and_border() { + let image = PostImage { + src: "https://example.com/pic.png".into(), + alt: "pic.png".into(), + width: Some(676), + height: Some(312), + }; + let inner = image_inner_rows(40, &image); + assert!(inner >= super::IMAGE_MIN_ROWS); + assert!(inner <= super::IMAGE_MAX_ROWS); + assert_eq!( + segment_height(&PostSegment::Image(image), 40), + inner.saturating_add(2) + ); + } + + #[test] + fn image_block_registers_overlay_slot() { + overlay::begin_frame(); + let image = PostImage { + src: "https://example.com/pic.png".into(), + alt: "pic.png".into(), + width: Some(100), + height: Some(50), + }; + let backend = ratatui::backend::TestBackend::new(40, 20); + let mut terminal = ratatui::Terminal::new(backend).unwrap(); + terminal + .draw(|frame| { + super::render_image_block( + frame, + Rect::new(0, 2, 30, 8), + &image, + 2, + 0, + Rect::new(0, 0, 30, 20), + ); + }) + .unwrap(); + let slots = overlay::slots(); + assert_eq!(slots.len(), 1); + assert_eq!(slots[0].src, "https://example.com/pic.png"); + assert_eq!(slots[0].x, 1); + assert_eq!(slots[0].y, 3); + assert_eq!(slots[0].width, 28); + overlay::begin_frame(); + } } diff --git a/src/ui/mod.rs b/src/ui/mod.rs index e756089..7320043 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -3,6 +3,7 @@ mod blog; mod header; mod intro; mod not_found; +pub(crate) mod overlay; use crate::mouse::{HitMap, MouseState}; use crate::router::{Route, Router}; diff --git a/src/ui/overlay.rs b/src/ui/overlay.rs new file mode 100644 index 0000000..84f54a0 --- /dev/null +++ b/src/ui/overlay.rs @@ -0,0 +1,135 @@ +//! HTML image overlays positioned over the Ratzilla cell grid. + +use crate::mouse; +use ratatui::layout::Rect; +use std::cell::RefCell; +use web_sys::Element; + +const CONTAINER_ID: &str = "post-images"; +const INNER_BG: &str = "#1c1916"; + +#[derive(Clone, Debug, PartialEq)] +pub struct ImageSlot { + pub src: String, + pub alt: String, + pub clip: Rect, + pub x: i32, + pub y: i32, + pub width: u16, + pub height: u16, +} + +thread_local! { + static SLOTS: RefCell> = const { RefCell::new(Vec::new()) }; +} + +pub fn begin_frame() { + SLOTS.with(|slots| slots.borrow_mut().clear()); +} + +pub fn push(slot: ImageSlot) { + if slot.src.is_empty() || slot.width == 0 || slot.height == 0 { + return; + } + SLOTS.with(|slots| slots.borrow_mut().push(slot)); +} + +pub fn sync() { + SLOTS.with(|slots| apply(&slots.borrow())); +} + +#[cfg(test)] +pub fn slots() -> Vec { + SLOTS.with(|slots| slots.borrow().clone()) +} + +fn apply(slots: &[ImageSlot]) { + let Some(document) = web_sys::window().and_then(|window| window.document()) else { + return; + }; + let Some(container) = ensure_container(&document) else { + return; + }; + if slots.is_empty() { + container.set_inner_html(""); + let _ = container.set_attribute("style", "display:none"); + return; + } + + let clip = slots[0].clip; + let (left, top, width, height) = mouse::rect_css_px(clip); + let _ = container.set_attribute( + "style", + &format!( + "display:block;position:fixed;left:{left}px;top:{top}px;width:{width}px;height:{height}px;overflow:hidden;pointer-events:none;z-index:5;" + ), + ); + + let (cell_w, cell_h) = mouse::cell_size_px(); + let mut child = container.first_element_child(); + for slot in slots { + let node = match child { + Some(existing) => existing, + None => match create_image(&document) { + Some(created) => { + let _ = container.append_child(&created); + created + } + None => continue, + }, + }; + update_image(&node, slot, cell_w, cell_h); + child = node.next_element_sibling(); + } + while let Some(extra) = child { + let next = extra.next_element_sibling(); + extra.remove(); + child = next; + } +} + +fn ensure_container(document: &web_sys::Document) -> Option { + if let Some(existing) = document.get_element_by_id(CONTAINER_ID) { + return Some(existing); + } + let container = document.create_element("div").ok()?; + container.set_id(CONTAINER_ID); + document.body()?.append_child(&container).ok()?; + Some(container) +} + +fn create_image(document: &web_sys::Document) -> Option { + let anchor = document.create_element("a").ok()?; + let _ = anchor.set_attribute("target", "_blank"); + let _ = anchor.set_attribute("rel", "noopener noreferrer"); + let img = document.create_element("img").ok()?; + let _ = img.set_attribute("referrerpolicy", "no-referrer"); + let _ = img.set_attribute("decoding", "async"); + let _ = img.set_attribute( + "style", + &format!("width:100%;height:100%;object-fit:contain;object-position:center;display:block;background:{INNER_BG};"), + ); + anchor.append_child(&img).ok()?; + Some(anchor) +} + +fn update_image(anchor: &Element, slot: &ImageSlot, cell_w: f32, cell_h: f32) { + let left = slot.x as f32 * cell_w; + let top = slot.y as f32 * cell_h; + let width = f32::from(slot.width) * cell_w; + let height = f32::from(slot.height) * cell_h; + let _ = anchor.set_attribute("href", &slot.src); + let _ = anchor.set_attribute( + "style", + &format!( + "position:absolute;left:{left}px;top:{top}px;width:{width}px;height:{height}px;pointer-events:auto;cursor:pointer;" + ), + ); + let Some(img) = anchor.query_selector("img").ok().flatten() else { + return; + }; + if img.get_attribute("src").as_deref() != Some(slot.src.as_str()) { + let _ = img.set_attribute("src", &slot.src); + } + let _ = img.set_attribute("alt", &slot.alt); +}