Insert: Summary, Section and Slide Zoom with Return to Zoom

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 <cursoragent@cursor.com>
This commit is contained in:
Nikita Rakitin
2026-10-13 22:40:37 +03:00
co-authored by Cursor
parent 457406a7a5
commit 5007c6919e
15 changed files with 398 additions and 12 deletions
+27 -1
View File
@@ -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<usize>) -> 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<f64> {
+30
View File
@@ -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<u32>| 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));
}
+2 -2
View File
@@ -589,7 +589,7 @@ fn hyperlink(s: &mut Session, p: &Value) -> Result<Value> {
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<Value> {
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)?;
+2
View File
@@ -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
})
}
+188
View File
@@ -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<CommandSpec> {
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<Xfrm> {
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<Vec<usize>> {
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<Value> {
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<Target> = 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<u8>, 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<ShapeId> = 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}))
})
}
+7 -2
View File
@@ -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, &para, &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, &para, &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))));
+4
View File
@@ -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<crate::SlideId>,
}
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
+6 -1
View File
@@ -470,7 +470,12 @@ pub fn hyperlink(imp: &Imp, part: &Part, h: &El) -> Option<Hyperlink> {
} 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 {
+1
View File
@@ -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);
+62 -2
View File
@@ -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());
}
+6 -1
View File
@@ -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
+60
View File
@@ -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<usize>) = 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<String> = 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<usize>)>(id));
ui.close();
}
}
+1 -1
View File
@@ -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,
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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 |