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);
+}