diff --git a/crates/anim/src/show.rs b/crates/anim/src/show.rs index dbc0a8ae..2d18affd 100644 --- a/crates/anim/src/show.rs +++ b/crates/anim/src/show.rs @@ -2,7 +2,7 @@ //! and automatic advance. use deckcraft_model::Presentation; -use deckcraft_model::text::Action; +use deckcraft_model::text::{Action, Hyperlink}; use crate::Timeline; @@ -63,6 +63,9 @@ pub struct ShowState { pub playing: bool, /// On the end screen. pub ended: bool, + /// Return to Zoom: (zoom slide, last zoomed slide). Advancing past the last zoomed slide goes + /// back to the zoom slide, fully built. + pub zoom: Option<(usize, usize)>, } /// Slide indices the show visits in order: the custom show or range from Set Up Slide Show, minus @@ -160,6 +163,16 @@ impl ShowState { self.playing = true; return ShowAction::PlayStep(self.step); } + if let Some((back, until)) = self.zoom + && until == self.slide + && back < pres.slides.len() + { + self.zoom = None; + self.enter(pres, back); + self.step = ShowState::steps(pres, back); + self.playing = false; + return ShowAction::Slide(back); + } match self.next_index(pres) { Some(i) => { self.enter(pres, i); @@ -234,6 +247,7 @@ impl ShowState { if i >= pres.slides.len() { return ShowAction::None; } + self.zoom = None; self.enter(pres, i); ShowAction::Slide(i) } @@ -259,6 +273,18 @@ impl ShowState { } } + /// Follow a clicked link, honouring a zoom's Return to Zoom (see [`Hyperlink::zoom_return`]). + pub fn follow_link(&mut self, pres: &Presentation, link: &Hyperlink, last_viewed: Option) -> LinkJump { + let from = self.slide; + let jump = self.follow(pres, &link.action, last_viewed); + if let Some(until) = link.zoom_return.and_then(|id| pres.slides.iter().position(|s| s.id == id)) + && matches!(jump, LinkJump::Show(ShowAction::Slide(_))) + { + self.zoom = Some((from, until)); + } + jump + } + /// Seconds after which the current slide advances by itself (Advance Slide ▸ After), when the /// show uses timings. pub fn advance_after(&self, pres: &Presentation) -> Option { diff --git a/crates/anim/src/tests.rs b/crates/anim/src/tests.rs index 1fd82d78..b62f77d5 100644 --- a/crates/anim/src/tests.rs +++ b/crates/anim/src/tests.rs @@ -789,3 +789,33 @@ fn show_follows_links() { assert_eq!(s.follow(&p, &Action::LastViewed, None), LinkJump::None); assert_eq!(s.slide, 3); } + +/// Zoom with Return to Zoom: after the zoomed slides the show goes back to the zoom slide, fully +/// built; without it, or after jumping elsewhere, the show carries on in order. +#[test] +fn show_returns_to_the_zoom_slide() { + use deckcraft_model::text::{Action, Hyperlink}; + let p = pres(6); + let link = |to: u32, last: Option| Hyperlink { + action: Action::Slide { slide: SlideId(1000 + to) }, + tooltip: String::new(), + highlight_click: false, + zoom_return: last.map(|l| SlideId(1000 + l)), + }; + let mut s = ShowState::new(&p); + // A section zoom on slide 0 for slides 2–3. + assert_eq!(s.follow_link(&p, &link(2, Some(3)), None), LinkJump::Show(ShowAction::Slide(2))); + assert_eq!(s.next(&p), ShowAction::Slide(3)); + assert_eq!(s.next(&p), ShowAction::Slide(0), "back to the zoom"); + assert_eq!(s.step, ShowState::steps(&p, 0)); + assert_eq!(s.next(&p), ShowAction::Slide(1), "then on in order"); + // Without Return to Zoom the show carries on. + let mut s = ShowState::new(&p); + s.follow_link(&p, &link(4, None), None); + assert_eq!(s.next(&p), ShowAction::Slide(5)); + // Jumping elsewhere (See All Slides, a number) forgets the zoom. + let mut s = ShowState::new(&p); + s.follow_link(&p, &link(2, Some(2)), None); + s.goto(&p, 4); + assert_eq!(s.next(&p), ShowAction::Slide(5)); +} diff --git a/crates/engine/src/cmd/insert.rs b/crates/engine/src/cmd/insert.rs index 2643ab11..432131bb 100644 --- a/crates/engine/src/cmd/insert.rs +++ b/crates/engine/src/cmd/insert.rs @@ -589,7 +589,7 @@ fn hyperlink(s: &mut Session, p: &Value) -> Result { return Err(bad("insert.hyperlink", "missing `url` or `slide`")); }; let display = str_param(p, "text").map_or(display, str::to_string); - let link = Hyperlink { action, tooltip: str_param(p, "tooltip").unwrap_or("").to_string(), highlight_click: false }; + let link = Hyperlink { action, tooltip: str_param(p, "tooltip").unwrap_or("").to_string(), highlight_click: false, zoom_return: None }; if s.doc()?.selection.text.is_some() { return super::text::link_text(s, link, &display); } @@ -621,7 +621,7 @@ fn action_button(s: &mut Session, p: &Value) -> Result { xfrm: Some(rect), geom: Geom::preset(preset), style: Some(ShapeStyle::accent(SchemeSlot::Accent1)), - click: action.map(|a| Hyperlink { action: a, tooltip: String::new(), highlight_click: true }), + click: action.map(|a| Hyperlink { action: a, tooltip: String::new(), highlight_click: true, zoom_return: None }), ..Default::default() }; let id = add_shape(s, shape, true)?; diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index ce909b02..337ccea4 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -17,6 +17,7 @@ pub mod slide; pub mod table; pub mod text; pub mod transition; +pub mod zoom; use deckcraft_geom::Xfrm; use deckcraft_model::ShapeId; @@ -142,6 +143,7 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(review::specs()); v.extend(inspect::specs()); v.extend(media::specs()); + v.extend(zoom::specs()); v }) } diff --git a/crates/engine/src/cmd/zoom.rs b/crates/engine/src/cmd/zoom.rs new file mode 100644 index 00000000..0b7abf9d --- /dev/null +++ b/crates/engine/src/cmd/zoom.rs @@ -0,0 +1,188 @@ +//! Insert ▸ Zoom: Summary Zoom, Section Zoom and Slide Zoom. +//! +//! A zoom is a picture of the target slide that jumps to it in the slide show. With Return to +//! Zoom (on for summary and section zooms, as in PowerPoint) the show comes back to the zoom +//! slide after the target slide, or the target section's last slide. In .pptx a zoom is saved as +//! the linked picture PowerPoint itself writes as the fallback for older versions. + +use std::sync::Arc; + +use deckcraft_geom::Xfrm; +use deckcraft_model::style::PictureFill; +use deckcraft_model::text::{Action, Hyperlink, TextBody}; +use deckcraft_model::{LayoutType, Shape, ShapeId, ShapeKind, SlideId, defaults}; +use serde_json::{Value, json}; + +use super::*; +use crate::{Result, Session}; + +pub fn specs() -> Vec { + vec![cmd!( + "insert.zoom", + "Zoom", + ["Insert", "Links"], + None, + "{kind?: slide|section|summary (default slide); slides?: [index] (slide and summary zooms); sections?: [index] (section zoom); returnToZoom?: bool (default: on for section and summary, off for slide)}", + has_slide, + zoom + )] +} + +/// One zoom tile: the slide it shows and jumps to, and the slide after which the show returns. +struct Target { + slide: SlideId, + index: usize, + last: SlideId, +} + +/// `n` tiles of `aspect` (w / h) laid out in a centred grid inside `area`. +fn grid(n: usize, area: Xfrm, aspect: f64) -> Vec { + let cols = (n as f64).sqrt().ceil().max(1.0) as usize; + let rows = n.div_ceil(cols).max(1); + let gap = 12.0; + let cw = (area.w - gap * (cols as f64 - 1.0)) / cols as f64; + let ch = (area.h - gap * (rows as f64 - 1.0)) / rows as f64; + let (w, h) = if cw / ch > aspect { (ch * aspect, ch) } else { (cw, cw / aspect) }; + // A single slide zoom is a quarter of the slide wide, as PowerPoint inserts it. + let (w, h) = if n == 1 { (w.min(area.w / 2.0), h.min(area.w / 2.0 / aspect)) } else { (w, h) }; + let total_w = cols.min(n) as f64 * w + gap * (cols.min(n) as f64 - 1.0); + let total_h = rows as f64 * h + gap * (rows as f64 - 1.0); + let (x0, y0) = (area.x + (area.w - total_w) / 2.0, area.y + (area.h - total_h) / 2.0); + (0..n).map(|k| Xfrm::new(x0 + (k % cols) as f64 * (w + gap), y0 + (k / cols) as f64 * (h + gap), w, h)).collect() +} + +fn indices(p: &Value, key: &str, n: usize, what: &str) -> Result> { + let Some(Value::Array(a)) = p.get(key) else { return Ok(vec![]) }; + let mut v = vec![]; + for x in a { + let i = x + .as_u64() + .map(|i| i as usize) + .filter(|i| *i < n) + .ok_or_else(|| bad("insert.zoom", format!("there is no {what} {x} (indices count from 0)")))?; + if !v.contains(&i) { + v.push(i); + } + } + Ok(v) +} + +fn zoom(s: &mut Session, p: &Value) -> Result { + let kind = str_param(p, "kind").unwrap_or("slide"); + let st = s.doc()?; + let doc = &st.doc; + let n = doc.slides.len(); + let targets: Vec = match kind { + "slide" => { + indices(p, "slides", n, "slide")?.into_iter().map(|i| Target { slide: doc.slides[i].id, index: i, last: doc.slides[i].id }).collect() + } + "section" => { + let secs = indices(p, "sections", doc.sections.len(), "section")?; + secs.into_iter() + .filter_map(|k| { + let sec = &doc.sections[k]; + let first = *sec.slides.first()?; + let last = *sec.slides.last()?; + Some(Target { slide: first, index: doc.slide_index(first)?, last }) + }) + .collect() + } + "summary" => { + let mut starts = indices(p, "slides", n, "slide")?; + if starts.is_empty() { + // Default: the first slide of every section, or every slide's start when there are none. + starts = doc.sections.iter().filter_map(|sec| sec.slides.first().and_then(|id| doc.slide_index(*id))).collect(); + } + starts.sort_unstable(); + starts + .iter() + .enumerate() + .map(|(k, &i)| { + // Each part runs to the slide before the next part (or the end). + let end = starts.get(k + 1).map(|j| j.saturating_sub(1)).unwrap_or(n.saturating_sub(1)).max(i); + Target { slide: doc.slides[i].id, index: i, last: doc.slides[end].id } + }) + .collect() + } + other => return Err(bad("insert.zoom", format!("unknown zoom kind `{other}` (slide, section or summary)"))), + }; + if targets.is_empty() { + return Err(bad("insert.zoom", "choose the slides (or sections) to zoom to")); + } + let back = bool_or(p, "returnToZoom", kind != "slide"); + let size = doc.slide_size; + // Pictures of the targets, about 480 px wide. + let scale = (480.0 / size.width.max(1.0)).clamp(0.05, 4.0); + let pictures: Vec<(Vec, String)> = targets + .iter() + .map(|t| { + let (png, _, _) = super::file::render_png(doc, t.index, scale, false); + let title = doc.slides[t.index].title(); + let label = + if title.trim().is_empty() { format!("Slide {}", t.index + 1) } else { title.lines().next().unwrap_or("").trim().to_string() }; + (png, label) + }) + .collect(); + let summary = kind == "summary"; + let first_target = targets.iter().map(|t| t.index).min().unwrap_or(0); + s.edit(|doc, sel| { + let aspect = size.width / size.height.max(1.0); + let area = if summary { + Xfrm::new(size.width * 0.06, size.height * 0.26, size.width * 0.88, size.height * 0.66) + } else if targets.len() == 1 { + Xfrm::new(size.width * 0.5, size.height * 0.5, size.width * 0.45, size.height * 0.45) + } else { + Xfrm::new(size.width * 0.08, size.height * 0.12, size.width * 0.84, size.height * 0.8) + }; + let boxes = grid(targets.len(), area, aspect); + let mut shapes = vec![]; + for ((t, (png, label)), b) in targets.iter().zip(pictures).zip(boxes) { + let media = doc.add_media(&format!("zoom-{}.png", t.index + 1), "image/png", png); + shapes.push(Shape { + id: ShapeId(doc.alloc_id()), + name: format!( + "{} Zoom {}", + if summary { + "Summary" + } else if kind == "section" { + "Section" + } else { + "Slide" + }, + t.index + 1 + ), + descr: label.clone(), + xfrm: Some(b), + kind: ShapeKind::Picture { fill: PictureFill { media, ..Default::default() } }, + click: Some(Hyperlink { + action: Action::Slide { slide: t.slide }, + tooltip: label, + highlight_click: false, + zoom_return: back.then_some(t.last), + }), + ..Default::default() + }); + } + let ids: Vec = shapes.iter().map(|x| x.id).collect(); + if summary { + // A new Title Only slide before the first part, as PowerPoint makes it. + let layout = + defaults::layout_of_kind(doc, LayoutType::TitleOnly).or_else(|| doc.slides.get(first_target).map(|x| x.layout)).unwrap_or_default(); + let mut slide = defaults::new_slide(doc, layout); + if let Some(sh) = slide.shapes.iter_mut().find(|x| x.ph_type().is_some_and(|k| k.is_title())) { + sh.text = Some(TextBody::from_text("Summary")); + } + slide.shapes.extend(shapes); + let at = first_target.min(doc.slides.len()); + doc.slides.insert(at, Arc::new(slide)); + sel.slide = at; + sel.slides.clear(); + } else { + let list = crate::shapes_mut(doc, sel).ok_or_else(|| bad("insert.zoom", "no slide"))?; + list.extend(shapes); + } + sel.shapes = ids.clone(); + sel.text = None; + Ok(json!({"kind": kind, "ids": ids, "returnToZoom": back, "slide": sel.slide})) + }) +} diff --git a/crates/model/src/tests.rs b/crates/model/src/tests.rs index 2c3b4ced..3dd062c2 100644 --- a/crates/model/src/tests.rs +++ b/crates/model/src/tests.rs @@ -165,7 +165,12 @@ fn hyperlinked_runs_take_the_theme_hyperlink_look() { let ctx = Ctx::for_slide(&p, s).unwrap(); let title = &s.shapes[0]; let para = Paragraph::default(); - let link = Some(Hyperlink { action: Action::Url { url: "https://example.org".into() }, tooltip: String::new(), highlight_click: false }); + let link = Some(Hyperlink { + action: Action::Url { url: "https://example.org".into() }, + tooltip: String::new(), + highlight_click: false, + zoom_return: None, + }); let hlink = Some(Fill::solid(ColorRef::scheme(SchemeSlot::Hlink))); // The title's master style colours its text tx1; a link is hlink and underlined instead. assert_eq!(resolve::run(&ctx, title, ¶, &RunProps::default()).fill, Some(Fill::solid(ColorRef::scheme(SchemeSlot::Tx1)))); @@ -173,7 +178,7 @@ fn hyperlinked_runs_take_the_theme_hyperlink_look() { assert_eq!((r.fill, r.underline.as_deref()), (hlink.clone(), Some("sng"))); assert_eq!(ctx.color(&ColorRef::scheme(SchemeSlot::Hlink), None), p.masters[0].theme.colors.get(SchemeSlot::Hlink)); // A slide link looks the same. - let slide = Some(Hyperlink { action: Action::Slide { slide: s.id }, tooltip: String::new(), highlight_click: false }); + let slide = Some(Hyperlink { action: Action::Slide { slide: s.id }, tooltip: String::new(), highlight_click: false, zoom_return: None }); assert_eq!(resolve::run(&ctx, title, ¶, &RunProps { link: slide, ..Default::default() }).fill, hlink); // The run's own colour and underline win; u="none" removes the underline. let red = Some(Fill::solid(ColorRef::rgb(deckcraft_color::Rgba::rgb(255, 0, 0)))); diff --git a/crates/model/src/text.rs b/crates/model/src/text.rs index 6a1a7527..01e8ba4f 100644 --- a/crates/model/src/text.rs +++ b/crates/model/src/text.rs @@ -316,6 +316,10 @@ pub struct Hyperlink { pub tooltip: String, #[serde(default)] pub highlight_click: bool, + /// Zoom (Insert ▸ Zoom) with Return to Zoom: in the show, after this slide is shown and + /// advanced past, go back to the slide with the zoom. Saved to .pptx as a plain slide link. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub zoom_return: Option, } #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] diff --git a/crates/pptx/src/read/dml.rs b/crates/pptx/src/read/dml.rs index 0aee935c..1b57a246 100644 --- a/crates/pptx/src/read/dml.rs +++ b/crates/pptx/src/read/dml.rs @@ -470,7 +470,12 @@ pub fn hyperlink(imp: &Imp, part: &Part, h: &El) -> Option { } else { return None; }; - Some(Hyperlink { action: a, tooltip: h.attr("tooltip").unwrap_or("").to_string(), highlight_click: h.bool("highlightClick").unwrap_or(false) }) + Some(Hyperlink { + action: a, + tooltip: h.attr("tooltip").unwrap_or("").to_string(), + highlight_click: h.bool("highlightClick").unwrap_or(false), + zoom_return: None, + }) } pub fn rpr(imp: &mut Imp, part: &Part, r: &El) -> RunProps { diff --git a/crates/pptx/src/write/shapes.rs b/crates/pptx/src/write/shapes.rs index 8b2ea35d..820360ee 100644 --- a/crates/pptx/src/write/shapes.rs +++ b/crates/pptx/src/write/shapes.rs @@ -582,6 +582,7 @@ fn media(w: &mut W, x: &mut Exp, o: &mut Out, id: u32, s: &Shape, m: &deckcraft_ action: deckcraft_model::text::Action::PlayMedia, tooltip: String::new(), highlight_click: false, + zoom_return: None, }); } cnvpr(w, x, o, id, &s2); diff --git a/crates/pptx/tests/roundtrip.rs b/crates/pptx/tests/roundtrip.rs index db6d0beb..0b7fc420 100644 --- a/crates/pptx/tests/roundtrip.rs +++ b/crates/pptx/tests/roundtrip.rs @@ -82,6 +82,7 @@ pub fn rich_deck() -> Presentation { action: Action::Url { url: "https://example.org/a?b=1&c=2".into() }, tooltip: "tip".into(), highlight_click: false, + zoom_return: None, }), ..Default::default() }, @@ -153,7 +154,7 @@ pub fn rich_deck() -> Presentation { }], ..Default::default() }); - b.click = Some(Hyperlink { action: Action::NextSlide, tooltip: String::new(), highlight_click: true }); + b.click = Some(Hyperlink { action: Action::NextSlide, tooltip: String::new(), highlight_click: true, zoom_return: None }); let mut c = shape(&mut p, "Freeform", 450.0, 120.0, 100.0, 100.0); c.geom = Geom::Custom { paths: vec![CustomPath { @@ -336,7 +337,7 @@ pub fn rich_deck() -> Presentation { let target = p.slides[1].id; let mut link = shape(&mut p, "Go to shapes", 600.0, 450.0, 200.0, 40.0); link.text = Some(TextBody::from_text("Back to shapes")); - link.click = Some(Hyperlink { action: Action::Slide { slide: target }, tooltip: String::new(), highlight_click: false }); + link.click = Some(Hyperlink { action: Action::Slide { slide: target }, tooltip: String::new(), highlight_click: false, zoom_return: None }); let mut eq = shape(&mut p, "Equation", 100.0, 450.0, 300.0, 40.0); eq.text = Some(TextBody { paragraphs: vec![Paragraph { @@ -991,3 +992,62 @@ fn transition_defaults_read_like_powerpoint() { assert_eq!((t.kind.as_str(), t.option.as_str(), t.duration_ms), (*kind, *option, 2000), "{el}"); } } + +/// Insert › Zoom: slide, section and summary zooms are linked pictures of their targets, with +/// Return to Zoom on section and summary zooms; a summary zoom makes its own slide; all of it is +/// one undo step and saves as linked pictures. +#[test] +fn zoom_inserts_linked_slide_pictures() { + use deckcraft_engine::Session; + use deckcraft_model::text::Action; + use serde_json::json; + let mut s = Session::with_new(); + for t in ["Intro", "Plan", "Budget", "Risks"] { + s.execute("slide.new", &json!({"title": t})).unwrap(); + } + // Slides: 0 title, 1 Intro, 2 Plan, 3 Budget, 4 Risks. + s.execute("slide.go", &json!({"index": 0})).unwrap(); + let r = s.execute("insert.zoom", &json!({"slides": [2]})).unwrap(); + assert_eq!(r["returnToZoom"], false); + let doc = s.doc().unwrap().doc.clone(); + let zoom = doc.slides[0].shapes.iter().find(|sh| sh.name.starts_with("Slide Zoom")).expect("slide zoom"); + let link = zoom.click.as_ref().unwrap(); + assert_eq!(link.action, Action::Slide { slide: doc.slides[2].id }); + assert_eq!(link.zoom_return, None); + assert_eq!(link.tooltip, "Plan"); + let ShapeKind::Picture { fill } = &zoom.kind else { panic!() }; + assert!(doc.media(fill.media).is_some_and(|m| m.data.starts_with(b"\x89PNG"))); + + // Section zoom: back after the section's last slide. + s.execute("section.add", &json!({"name": "Start", "at": 0})).unwrap(); + s.execute("section.add", &json!({"name": "Money", "at": 3})).unwrap(); + let doc = s.doc().unwrap().doc.clone(); + let money = doc.sections.iter().position(|x| x.name == "Money").unwrap(); + s.execute("slide.go", &json!({"index": 0})).unwrap(); + s.execute("insert.zoom", &json!({"kind": "section", "sections": [money]})).unwrap(); + let doc = s.doc().unwrap().doc.clone(); + let sec = doc.slides[0].shapes.iter().find(|sh| sh.name.starts_with("Section Zoom")).unwrap(); + let last = *doc.sections[money].slides.last().unwrap(); + assert_eq!(sec.click.as_ref().unwrap().zoom_return, Some(last)); + + // Summary zoom on slides 1 and 3: a new slide in front, parts 1–2 and 3–4. + let before = doc.slides.len(); + s.execute("insert.zoom", &json!({"kind": "summary", "slides": [1, 3]})).unwrap(); + let doc = s.doc().unwrap().doc.clone(); + assert_eq!(doc.slides.len(), before + 1); + let sum = &doc.slides[1]; + assert!(sum.title().contains("Summary")); + let tiles: Vec<_> = sum.shapes.iter().filter_map(|sh| sh.click.as_ref()).collect(); + assert_eq!(tiles.len(), 2); + assert_eq!(tiles[0].zoom_return, Some(doc.slides[3].id), "part one runs to the slide before part two"); + assert_eq!(tiles[1].zoom_return, Some(doc.slides.last().unwrap().id)); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().doc.slides.len(), before, "one undo step"); + + // Saved as linked pictures. + let back = deckcraft_pptx::import(&deckcraft_pptx::export(&s.doc().unwrap().doc).unwrap()).unwrap(); + let z = back.slides[0].shapes.iter().find(|sh| matches!(sh.kind, ShapeKind::Picture { .. }) && sh.click.is_some()).unwrap(); + assert!(matches!(z.click.as_ref().unwrap().action, Action::Slide { .. })); + assert!(s.execute("insert.zoom", &json!({"slides": [42]})).is_err()); + assert!(s.execute("insert.zoom", &json!({"kind": "slide"})).is_err()); +} diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index 0de3d695..f812ecd5 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -539,7 +539,12 @@ fn hyperlinks_draw_in_the_hyperlink_colour_underlined() { let o = RenderOpts { scale: 0.5, ..Default::default() }; let px = |p: &Presentation| render_slide(p, 0, &o); let diff = |a: &Image, b: &Image| a.pixels.iter().zip(&b.pixels).filter(|(x, y)| x.abs_diff(**y) > 64).count(); - let link = Some(Hyperlink { action: Action::Url { url: "https://example.org".into() }, tooltip: String::new(), highlight_click: false }); + let link = Some(Hyperlink { + action: Action::Url { url: "https://example.org".into() }, + tooltip: String::new(), + highlight_click: false, + zoom_return: None, + }); let hlink = Some(Fill::solid(ColorRef::scheme(deckcraft_color::SchemeSlot::Hlink))); let red = Some(Fill::solid(ColorRef::rgb(Rgba::rgb(200, 0, 0)))); let _ = px(&run_deck(RunProps::default())); // faces load lazily diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index 428eff0b..731e7289 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -1054,6 +1054,8 @@ fn insert(app: &mut SlideApp, ui: &mut Ui) { }); }); group(ui, |ui| { + let r = big_button(ui, Icon::Zoom, "Zoom", enabled(app, "insert.zoom")); + egui::Popup::menu(&r).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).width(300.0).show(|ui| zoom_menu(app, ui)); big(app, ui, Icon::Link, "Link", "app.dialog", json!({"id": "hyperlink"})); let r = big_button(ui, Icon::Action, "Action", enabled(app, "insert.actionButton")); egui::Popup::menu(&r).show(|ui| { @@ -2420,3 +2422,61 @@ fn paint_freeform_icon(p: &egui::Painter, r: Rect, name: &str, ink: Color32) { }; p.add(egui::Shape::line(pts, egui::Stroke::new(1.3, ink))); } + +/// Insert › Zoom: pick Summary, Section or Slide Zoom and what it zooms to, as PowerPoint's +/// Insert Zoom dialogs do (a checklist of slides or sections). +fn zoom_menu(app: &mut SlideApp, ui: &mut Ui) { + let Some(doc) = app.session.active().map(|d| d.doc.clone()) else { return }; + let id = egui::Id::new("insert_zoom_menu"); + let (mut kind, mut picked): (u8, Vec) = ui.data_mut(|d| d.get_temp(id).unwrap_or((0, vec![]))); + ui.horizontal(|ui| { + for (k, label) in [(0u8, "Slide Zoom"), (1, "Section Zoom"), (2, "Summary Zoom")] { + if ui.selectable_label(kind == k, label).clicked() && kind != k { + kind = k; + picked.clear(); + } + } + }); + ui.separator(); + let items: Vec = if kind == 1 { + doc.sections.iter().map(|s| s.name.clone()).collect() + } else { + doc.slides + .iter() + .enumerate() + .map(|(i, s)| { + let t = s.title(); + format!("{}. {}", i + 1, t.lines().next().unwrap_or("").trim()) + }) + .collect() + }; + if items.is_empty() { + ui.label(if kind == 1 { "The presentation has no sections. Add one with Home › Section." } else { "No slides." }); + } + egui::ScrollArea::vertical().max_height(260.0).show(ui, |ui| { + for (i, label) in items.iter().enumerate() { + let mut on = picked.contains(&i); + if ui.checkbox(&mut on, label).changed() { + if on { + picked.push(i); + } else { + picked.retain(|x| *x != i); + } + } + } + }); + ui.data_mut(|d| d.insert_temp(id, (kind, picked.clone()))); + ui.separator(); + if ui.add_enabled(!picked.is_empty(), egui::Button::new("Insert")).clicked() { + let mut v = picked.clone(); + v.sort_unstable(); + let p = match kind { + 1 => json!({"kind": "section", "sections": v}), + 2 => json!({"kind": "summary", "slides": v}), + _ => json!({"kind": "slide", "slides": v}), + }; + run(app, "insert.zoom", p); + ui.data_mut(|d| d.remove::<(u8, Vec)>(id)); + ui.close(); + } +} diff --git a/crates/ui-egui/src/show.rs b/crates/ui-egui/src/show.rs index 17ec527e..6a03ee89 100644 --- a/crates/ui-egui/src/show.rs +++ b/crates/ui-egui/src/show.rs @@ -324,7 +324,7 @@ pub fn ui(app: &mut SlideApp, ui: &mut Ui) { app.media.toggle(&doc, id, &clip, crate::media::Owner::Show); } else if clicked && let Some(l) = &link { let last = show.last_viewed; - match show.state.follow(&doc, &l.action, last) { + match show.state.follow_link(&doc, l, last) { LinkJump::Show(a) => actions.push(a), LinkJump::Open(url) => ctx.open_url(egui::OpenUrl::new_tab(url)), LinkJump::Exit => keep = false, diff --git a/docs/gaps.md b/docs/gaps.md index a2894b42..69a1de6e 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -184,7 +184,7 @@ Detail in [animation-parity.md](animation-parity.md). 10–16 h; the pane is a * - **Estimate:** 3–6 h to review, land and round-trip. ### Smaller missing rows -Zoom (summary/section/slide, 6–10 h), reuse slides (2–4 h), laser pointer (1–2 h), transition +Zoom: live thumbnails, the zoom-in animation and the native `p166:sldZm` element (3–5 h; `insert.zoom` makes linked pictures with Return to Zoom), reuse slides (2–4 h), laser pointer (1–2 h), transition sounds (1–2 h), artistic effects (6–10 h), remove background (6–10 h; needs a model or a classical matte), compress pictures (2–3 h), insert screenshot (2–3 h), record audio/screen and record slide show (8–12 h), compare presentations (6–10 h), password protection (4–6 h, ECMA-376 agile diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 2a3d450c..1d974036 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -79,7 +79,7 @@ Open P0 items: Contextual tabs (Shape Format, Picture Format, Table Design/Layou | Slides | Reset slide | P1 | D | | Slides | Slides from outline | P2 | D | | Slides | Copy/paste slides between decks | P1 | P | -| Slides | Zoom (summary, section and slide zoom) | P2 | M | +| Slides | Zoom (summary, section and slide zoom) | P2 | P | | Views | Normal view (thumbnails, slide, notes) | P0 | D | | Views | Outline view | P1 | D | | Views | Slide Sorter | P0 | D |