From 5007c6919e6f8702cdc60d2b684960787c0f7bcd Mon Sep 17 00:00:00 2001 From: Nikita Rakitin Date: Tue, 13 Oct 2026 22:40:37 +0300 Subject: [PATCH] Insert: Summary, Section and Slide Zoom with Return to Zoom MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Insert › Links gains Zoom, with PowerPoint's three kinds: Slide Zoom (pictures of chosen slides on the current slide), Section Zoom (one per chosen section) and Summary Zoom (a new Title Only slide in front of the chosen parts). Each zoom is a picture of its target that jumps to it in the show. With Return to Zoom, on by default for section and summary zooms as in PowerPoint, the show comes back to the zoom slide, fully built, after the target slide or the section's last slide; going elsewhere (a slide number, See All Slides) forgets it. Hyperlink gains zoom_return for this; .pptx saves zooms as the linked pictures PowerPoint writes as their fallback, so the return flag is DeckCraft- only for now. Live thumbnails, the zoom-in animation and the native p166:sldZm element are still missing; gaps.md says so. Co-authored-by: Cursor --- crates/anim/src/show.rs | 28 ++++- crates/anim/src/tests.rs | 30 +++++ crates/engine/src/cmd/insert.rs | 4 +- crates/engine/src/cmd/mod.rs | 2 + crates/engine/src/cmd/zoom.rs | 188 ++++++++++++++++++++++++++++++++ crates/model/src/tests.rs | 9 +- crates/model/src/text.rs | 4 + crates/pptx/src/read/dml.rs | 7 +- crates/pptx/src/write/shapes.rs | 1 + crates/pptx/tests/roundtrip.rs | 64 ++++++++++- crates/render/src/tests.rs | 7 +- crates/ui-egui/src/ribbon.rs | 60 ++++++++++ crates/ui-egui/src/show.rs | 2 +- docs/gaps.md | 2 +- docs/parity-checklist.md | 2 +- 15 files changed, 398 insertions(+), 12 deletions(-) create mode 100644 crates/engine/src/cmd/zoom.rs 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 |