mirror of
https://github.com/storytold/deckcraft.git
synced 2026-10-24 10:37:03 -06:00
Merge PR #520 from NikitkaRa: Insert: Summary, Section and Slide Zoom with Return to Zoom
Fix-ups in the merge (review): zoom targets, sections and titles are looked up with get() instead of indexing; Hyperlink literals carry both main's sound fields and zoom_return; duplicated test statements left by the test-file union are removed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+27
-1
@@ -4,7 +4,7 @@
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use deckcraft_model::Presentation;
|
||||
use deckcraft_model::text::Action;
|
||||
use deckcraft_model::text::{Action, Hyperlink};
|
||||
|
||||
use crate::Timeline;
|
||||
use crate::interact::SlideRun;
|
||||
@@ -78,6 +78,9 @@ pub struct ShowState {
|
||||
order: Option<Vec<usize>>,
|
||||
/// Name of the custom show being played, when there is one.
|
||||
custom: Option<String>,
|
||||
/// 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
|
||||
@@ -252,6 +255,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);
|
||||
@@ -331,6 +344,7 @@ impl ShowState {
|
||||
if i >= pres.slides.len() {
|
||||
return ShowAction::None;
|
||||
}
|
||||
self.zoom = None;
|
||||
self.enter(pres, i);
|
||||
ShowAction::Slide(i)
|
||||
}
|
||||
@@ -363,6 +377,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> {
|
||||
|
||||
@@ -996,3 +996,35 @@ fn media_in_the_click_sequence_cues_and_does_not_hold_the_click() {
|
||||
let tl = Timeline::new(&slide_with(vec![play, anim(2, AnimClass::Entrance, "fade", AnimStart::AfterPrevious)]), W, H);
|
||||
assert!((tl.step_duration(0) - 6.0).abs() < 1e-9, "{}", tl.step_duration(0));
|
||||
}
|
||||
|
||||
/// 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)),
|
||||
sound: None,
|
||||
stop_sound: false,
|
||||
};
|
||||
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));
|
||||
}
|
||||
|
||||
@@ -1584,6 +1584,7 @@ fn hyperlink(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
highlight_click: bool_or(p, "highlightClick", false),
|
||||
sound: None,
|
||||
stop_sound: false,
|
||||
zoom_return: None,
|
||||
};
|
||||
if s.doc()?.selection.text.is_some() {
|
||||
return super::text::link_text(s, link, &display);
|
||||
@@ -1632,7 +1633,7 @@ fn shape_action(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
};
|
||||
let highlight = bool_param(p, "highlight").unwrap_or(true);
|
||||
let tooltip = str_param(p, "tooltip").unwrap_or("").to_string();
|
||||
let link = action.map(|action| Hyperlink { action, tooltip, highlight_click: highlight, sound: None, stop_sound: false });
|
||||
let link = action.map(|action| Hyperlink { action, tooltip, highlight_click: highlight, sound: None, stop_sound: false, zoom_return: None });
|
||||
edit_shapes(s, p, CMD, |sh| {
|
||||
if hover {
|
||||
sh.hover = link.clone();
|
||||
@@ -1665,7 +1666,14 @@ 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, sound: None, stop_sound: false }),
|
||||
click: action.map(|a| Hyperlink {
|
||||
action: a,
|
||||
tooltip: String::new(),
|
||||
highlight_click: true,
|
||||
sound: None,
|
||||
stop_sound: false,
|
||||
zoom_return: None,
|
||||
}),
|
||||
..Default::default()
|
||||
};
|
||||
let id = add_shape(s, shape, true)?;
|
||||
|
||||
@@ -26,6 +26,7 @@ pub mod smartart;
|
||||
pub mod table;
|
||||
pub mod text;
|
||||
pub mod transition;
|
||||
pub mod zoom;
|
||||
|
||||
use deckcraft_geom::Xfrm;
|
||||
use deckcraft_model::ShapeId;
|
||||
@@ -183,6 +184,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
|
||||
v.extend(inspect::specs());
|
||||
v.extend(media::specs());
|
||||
v.extend(artistic::specs());
|
||||
v.extend(zoom::specs());
|
||||
v
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
//! 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()
|
||||
.filter_map(|i| doc.slides.get(i).map(|sl| Target { slide: sl.id, index: i, last: sl.id }))
|
||||
.collect(),
|
||||
"section" => {
|
||||
let secs = indices(p, "sections", doc.sections.len(), "section")?;
|
||||
secs.into_iter()
|
||||
.filter_map(|k| {
|
||||
let sec = doc.sections.get(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()
|
||||
.filter_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);
|
||||
Some(Target { slide: doc.slides.get(i)?.id, index: i, last: doc.slides.get(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.get(t.index).map(|sl| sl.title()).unwrap_or_default();
|
||||
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),
|
||||
sound: None,
|
||||
stop_sound: false,
|
||||
}),
|
||||
..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}))
|
||||
})
|
||||
}
|
||||
@@ -20,7 +20,14 @@ fn shape(p: &mut Presentation, name: &str, text: &str, x: f64, y: f64, w: f64, h
|
||||
}
|
||||
|
||||
fn link_to(slide: SlideId) -> Option<Hyperlink> {
|
||||
Some(Hyperlink { action: Action::Slide { slide }, tooltip: String::new(), highlight_click: false, sound: None, stop_sound: false })
|
||||
Some(Hyperlink {
|
||||
action: Action::Slide { slide },
|
||||
tooltip: String::new(),
|
||||
highlight_click: false,
|
||||
sound: None,
|
||||
stop_sound: false,
|
||||
zoom_return: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Clicking `trigger` makes `target` disappear.
|
||||
|
||||
@@ -171,6 +171,7 @@ fn hyperlinked_runs_take_the_theme_hyperlink_look() {
|
||||
highlight_click: false,
|
||||
sound: None,
|
||||
stop_sound: 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.
|
||||
@@ -179,8 +180,14 @@ 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, sound: None, stop_sound: false });
|
||||
let slide = Some(Hyperlink {
|
||||
action: Action::Slide { slide: s.id },
|
||||
tooltip: String::new(),
|
||||
highlight_click: false,
|
||||
sound: None,
|
||||
stop_sound: 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))));
|
||||
|
||||
@@ -344,6 +344,10 @@ pub struct Hyperlink {
|
||||
/// `endSnd`: stop the sound that is already playing.
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
pub stop_sound: 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)]
|
||||
|
||||
@@ -489,6 +489,7 @@ pub fn hyperlink(imp: &mut Imp, part: &Part, h: &El) -> Option<Hyperlink> {
|
||||
highlight_click: h.bool("highlightClick").unwrap_or(false),
|
||||
stop_sound: h.bool("endSnd").unwrap_or(false),
|
||||
sound: h.child("snd").and_then(|s| media_ref(imp, part, s)),
|
||||
zoom_return: None,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -783,6 +783,7 @@ fn media(w: &mut W, x: &mut Exp, o: &mut Out, id: u32, s: &Shape, m: &deckcraft_
|
||||
highlight_click: false,
|
||||
sound: None,
|
||||
stop_sound: false,
|
||||
zoom_return: None,
|
||||
});
|
||||
}
|
||||
cnvpr(w, x, o, id, &s2);
|
||||
|
||||
@@ -64,6 +64,7 @@ fn talk() -> Presentation {
|
||||
highlight_click: false,
|
||||
sound: None,
|
||||
stop_sound: false,
|
||||
zoom_return: None,
|
||||
}),
|
||||
..Default::default()
|
||||
},
|
||||
|
||||
@@ -14,7 +14,7 @@ use deckcraft_model::{Comment, CustomShow, Geom, LayoutType, PhType, Presentatio
|
||||
const CHIME: &[u8] = b"RIFFdeckcraft-chime-WAVEfmt ";
|
||||
|
||||
fn link(action: Action) -> Hyperlink {
|
||||
Hyperlink { action, tooltip: String::new(), highlight_click: false, sound: None, stop_sound: false }
|
||||
Hyperlink { action, tooltip: String::new(), highlight_click: false, sound: None, stop_sound: false, zoom_return: None }
|
||||
}
|
||||
|
||||
fn box_at(p: &mut Presentation, name: &str, x: f64, y: f64) -> Shape {
|
||||
@@ -72,6 +72,7 @@ fn deck() -> Presentation {
|
||||
highlight_click: false,
|
||||
sound: None,
|
||||
stop_sound: false,
|
||||
zoom_return: None,
|
||||
}),
|
||||
..Default::default()
|
||||
},
|
||||
@@ -97,6 +98,7 @@ fn deck() -> Presentation {
|
||||
highlight_click: true,
|
||||
sound: None,
|
||||
stop_sound: false,
|
||||
zoom_return: None,
|
||||
});
|
||||
a.shapes.push(site);
|
||||
|
||||
@@ -116,13 +118,26 @@ fn deck() -> Presentation {
|
||||
}
|
||||
|
||||
let mut hover = box_at(&mut p, "Hover", 40.0, 500.0);
|
||||
hover.hover =
|
||||
Some(Hyperlink { action: Action::PreviousSlide, tooltip: "Back one".into(), highlight_click: false, sound: None, stop_sound: true });
|
||||
hover.hover = Some(Hyperlink {
|
||||
action: Action::PreviousSlide,
|
||||
tooltip: "Back one".into(),
|
||||
highlight_click: false,
|
||||
sound: None,
|
||||
stop_sound: true,
|
||||
zoom_return: None,
|
||||
});
|
||||
a.shapes.push(hover);
|
||||
|
||||
let mut home = box_at(&mut p, "Home", 800.0, 460.0);
|
||||
home.geom = Geom::preset("actionButtonHome");
|
||||
home.click = Some(Hyperlink { action: Action::FirstSlide, tooltip: String::new(), highlight_click: true, sound: None, stop_sound: false });
|
||||
home.click = Some(Hyperlink {
|
||||
action: Action::FirstSlide,
|
||||
tooltip: String::new(),
|
||||
highlight_click: true,
|
||||
sound: None,
|
||||
stop_sound: false,
|
||||
zoom_return: None,
|
||||
});
|
||||
a.shapes.push(home);
|
||||
|
||||
let mut board = box_at(&mut p, "To board", 140.0, 500.0);
|
||||
@@ -132,6 +147,7 @@ fn deck() -> Presentation {
|
||||
highlight_click: false,
|
||||
sound: Some(chime),
|
||||
stop_sound: false,
|
||||
zoom_return: None,
|
||||
});
|
||||
a.shapes.push(board);
|
||||
|
||||
|
||||
@@ -156,6 +156,7 @@ fn sections() -> Presentation {
|
||||
highlight_click: false,
|
||||
sound: None,
|
||||
stop_sound: false,
|
||||
zoom_return: None,
|
||||
}),
|
||||
..Default::default()
|
||||
},
|
||||
|
||||
@@ -84,6 +84,7 @@ pub fn rich_deck() -> Presentation {
|
||||
highlight_click: false,
|
||||
sound: None,
|
||||
stop_sound: false,
|
||||
zoom_return: None,
|
||||
}),
|
||||
..Default::default()
|
||||
},
|
||||
@@ -155,7 +156,14 @@ pub fn rich_deck() -> Presentation {
|
||||
}],
|
||||
..Default::default()
|
||||
});
|
||||
b.click = Some(Hyperlink { action: Action::NextSlide, tooltip: String::new(), highlight_click: true, sound: None, stop_sound: false });
|
||||
b.click = Some(Hyperlink {
|
||||
action: Action::NextSlide,
|
||||
tooltip: String::new(),
|
||||
highlight_click: true,
|
||||
sound: None,
|
||||
stop_sound: false,
|
||||
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 {
|
||||
@@ -339,8 +347,14 @@ 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, sound: None, stop_sound: false });
|
||||
link.click = Some(Hyperlink {
|
||||
action: Action::Slide { slide: target },
|
||||
tooltip: String::new(),
|
||||
highlight_click: false,
|
||||
sound: None,
|
||||
stop_sound: 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 {
|
||||
@@ -1735,3 +1749,62 @@ fn legacy_ole_object_is_written_in_the_fallback_form() {
|
||||
assert!(preview.is_some(), "the preview picture is kept");
|
||||
assert!(parts.iter().any(|(_, k, _)| k.ends_with("/oleObject")), "the embedding is kept: {parts:?}");
|
||||
}
|
||||
|
||||
/// 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());
|
||||
}
|
||||
|
||||
@@ -863,6 +863,7 @@ fn hyperlinks_draw_in_the_hyperlink_colour_underlined() {
|
||||
highlight_click: false,
|
||||
sound: None,
|
||||
stop_sound: 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))));
|
||||
@@ -2039,7 +2040,8 @@ fn east_asian_vertical_text_stands_ideographs_upright() {
|
||||
#[test]
|
||||
fn placed_shapes_give_the_click_boxes_of_a_slide_in_drawing_order() {
|
||||
use deckcraft_model::text::{Action, Hyperlink};
|
||||
let link = Hyperlink { action: Action::NextSlide, tooltip: String::new(), highlight_click: false, sound: None, stop_sound: false };
|
||||
let link =
|
||||
Hyperlink { action: Action::NextSlide, tooltip: String::new(), highlight_click: false, sound: None, stop_sound: false, zoom_return: None };
|
||||
let rect = |id: u32, x: f64, click: Option<Hyperlink>| Shape {
|
||||
id: ShapeId(id),
|
||||
xfrm: Some(Xfrm::new(x, 50.0, 100.0, 40.0)),
|
||||
@@ -2079,7 +2081,14 @@ fn a_text_hyperlink_belongs_to_the_shape_that_holds_the_text() {
|
||||
use deckcraft_model::text::{Action, Hyperlink};
|
||||
let mut body = TextBody::from_text("click here");
|
||||
for r in &mut body.paragraphs[0].runs {
|
||||
r.props.link = Some(Hyperlink { action: Action::FirstSlide, tooltip: String::new(), highlight_click: false, sound: None, stop_sound: false });
|
||||
r.props.link = Some(Hyperlink {
|
||||
action: Action::FirstSlide,
|
||||
tooltip: String::new(),
|
||||
highlight_click: false,
|
||||
sound: None,
|
||||
stop_sound: false,
|
||||
zoom_return: None,
|
||||
});
|
||||
}
|
||||
let sh = Shape { id: ShapeId(7), xfrm: Some(Xfrm::new(100.0, 100.0, 300.0, 60.0)), text: Some(body), ..Default::default() };
|
||||
let placed = place_slide(&deck_with(vec![sh]), 0);
|
||||
|
||||
@@ -3313,7 +3313,7 @@ mod tests {
|
||||
};
|
||||
|
||||
fn link(action: Action, tip: &str) -> Hyperlink {
|
||||
Hyperlink { action, tooltip: tip.into(), highlight_click: false, sound: None, stop_sound: false }
|
||||
Hyperlink { action, tooltip: tip.into(), highlight_click: false, sound: None, stop_sound: false, zoom_return: None }
|
||||
}
|
||||
|
||||
fn url(address: &str, tip: &str) -> Hyperlink {
|
||||
|
||||
@@ -1238,6 +1238,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| {
|
||||
@@ -3508,3 +3510,61 @@ fn smooth_corners_row(app: &mut SlideApp, ui: &mut egui::Ui) {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// 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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -570,7 +570,7 @@ pub fn ui(app: &mut SlideApp, ui: &mut Ui) {
|
||||
app.media.toggle(doc_uid, &doc, id, &clip, crate::media::Owner::Show);
|
||||
} else if clicked && let Some(Pick::Text(l)) = under.as_ref().and_then(|u| u.pick.clone()) {
|
||||
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,
|
||||
|
||||
@@ -348,6 +348,7 @@ Zoom (summary/section/slide, 6–10 h), reuse slides (2–4 h), laser pointer (1
|
||||
sounds (1–2 h), artistic effects (6–10 h), compress pictures (2–3 h), insert screenshot (2–3 h), record audio/screen and record slide
|
||||
sounds (1–2 h), artistic effects (3–6 h for the other 15 of 22; 7 approximated and round-tripped), remove background (6–10 h; needs a model or a classical
|
||||
Zoom (summary/section/slide, 6–10 h), reuse slides (2–4 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
|
||||
|
||||
@@ -99,7 +99,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