mirror of
https://github.com/storytold/deckcraft.git
synced 2026-10-24 10:37:03 -06:00
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:
+27
-1
@@ -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> {
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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)?;
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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}))
|
||||
})
|
||||
}
|
||||
@@ -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))));
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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 |
|
||||
|
||||
Reference in New Issue
Block a user