mirror of
https://github.com/storytold/deckcraft.git
synced 2026-10-29 04:56:58 -06:00
Three causes, each making links look like they "don't save": - With the caret at a word boundary (end of text, empty paragraph), insert.hyperlink formatted an empty range and changed nothing. It now inserts the address (or the slide's title, or `text`) as linked text, as PowerPoint does. Typing right after a link no longer extends it. - The slide show ignored hyperlinks and shape actions. A click on one now follows it (ShowState::follow): slides, next/previous/first/last, Last Slide Viewed, End Show, and URLs; the pointer turns into a hand over links. - The desktop app was built without eframe's `links` feature, so ctx.open_url did nothing natively (also the ribbon's app page link). Enabled as in DesignCraft, EffectCraft and PrintCraft. Saving was fine: links round-trip through .deckcraft and .pptx. generated with Claude Code Co-authored-by: Brandon Thomas <echelon@gmail.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
281 lines
10 KiB
Rust
281 lines
10 KiB
Rust
//! Slide show navigation: click steps, slide order (hidden slides, custom shows, ranges), loop
|
|
//! and automatic advance.
|
|
|
|
use deckcraft_model::Presentation;
|
|
use deckcraft_model::text::Action;
|
|
|
|
use crate::Timeline;
|
|
|
|
/// What the UI should do after a navigation call.
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
pub enum ShowAction {
|
|
/// Nothing changed (e.g. back on the first slide).
|
|
None,
|
|
/// Start playing main step `n` of the current slide (clock from 0).
|
|
PlayStep(usize),
|
|
/// Step `n` jumped to its end (it was playing); nothing plays now.
|
|
FinishStep(usize),
|
|
/// Went back before step `n` (showing the state before it).
|
|
StepBack(usize),
|
|
/// Moved to slide index `n` (play its transition; `ShowState::playing` tells whether step 0
|
|
/// starts by itself).
|
|
Slide(usize),
|
|
/// Past the last slide: show the end screen.
|
|
End,
|
|
/// Leave the show (next on the end screen).
|
|
Exit,
|
|
}
|
|
|
|
/// Can a link from a (possibly untrusted) deck be opened outside the show? Only web pages and
|
|
/// mail: `file:`, `javascript:`, relative paths and other schemes (OS protocol handlers) are not
|
|
/// opened on a click.
|
|
pub fn safe_url(url: &str) -> bool {
|
|
let u = url.trim();
|
|
let Some((scheme, rest)) = u.split_once(':') else { return false };
|
|
match scheme.to_ascii_lowercase().as_str() {
|
|
"http" | "https" => rest.starts_with("//") && rest.len() > 2,
|
|
"mailto" => !rest.is_empty(),
|
|
_ => false,
|
|
}
|
|
}
|
|
|
|
/// What a clicked link does in the show (`ShowState::follow`).
|
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
|
pub enum LinkJump {
|
|
/// Navigation inside the show (already applied to the state).
|
|
Show(ShowAction),
|
|
/// Open this URL (`http`, `https` or `mailto`, see [`safe_url`]) outside the show.
|
|
Open(String),
|
|
/// End the show.
|
|
Exit,
|
|
/// Nothing to follow.
|
|
None,
|
|
}
|
|
|
|
/// Where a slide show is.
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
|
|
pub struct ShowState {
|
|
/// Index into `presentation.slides`.
|
|
pub slide: usize,
|
|
/// Main step that plays next or is playing; `== steps` when the slide is fully built.
|
|
pub step: usize,
|
|
/// Step `step` is playing (its clock runs). With `step`, gives `Timeline::state(.., step, t)`.
|
|
pub playing: bool,
|
|
/// On the end screen.
|
|
pub ended: bool,
|
|
}
|
|
|
|
/// Slide indices the show visits in order: the custom show or range from Set Up Slide Show, minus
|
|
/// hidden slides.
|
|
pub fn show_order(pres: &Presentation) -> Vec<usize> {
|
|
let n = pres.slides.len();
|
|
let base: Vec<usize> = match pres.show.custom_show.as_ref().and_then(|name| pres.custom_shows.iter().find(|c| &c.name == name)) {
|
|
Some(cs) => cs.slides.iter().filter_map(|id| pres.slides.iter().position(|s| s.id == *id)).collect(),
|
|
None => match pres.show.range {
|
|
Some((a, b)) => {
|
|
let a = (a.max(1) as usize).saturating_sub(1);
|
|
let b = (b as usize).min(n);
|
|
(a..b).collect()
|
|
}
|
|
None => (0..n).collect(),
|
|
},
|
|
};
|
|
base.into_iter().filter(|i| pres.slides.get(*i).is_some_and(|s| !s.hidden)).collect()
|
|
}
|
|
|
|
fn looping(pres: &Presentation) -> bool {
|
|
pres.show.loop_until_esc || pres.show.show_type == "kiosk"
|
|
}
|
|
|
|
impl ShowState {
|
|
/// Start at the first slide of the show order.
|
|
pub fn new(pres: &Presentation) -> ShowState {
|
|
match show_order(pres).first() {
|
|
Some(&i) => ShowState::start_at(pres, i),
|
|
None => ShowState { ended: true, ..Default::default() },
|
|
}
|
|
}
|
|
|
|
/// Start at slide `i` (hidden slides allowed: "from current slide").
|
|
pub fn start_at(pres: &Presentation, i: usize) -> ShowState {
|
|
let mut s = ShowState::default();
|
|
if i < pres.slides.len() {
|
|
s.enter(pres, i);
|
|
} else {
|
|
s.ended = true;
|
|
}
|
|
s
|
|
}
|
|
|
|
/// The timeline of slide `i`.
|
|
pub fn timeline(pres: &Presentation, i: usize) -> Timeline {
|
|
match pres.slides.get(i) {
|
|
Some(s) => Timeline::new(s, pres.slide_size.width, pres.slide_size.height),
|
|
None => Timeline::default(),
|
|
}
|
|
}
|
|
|
|
/// Main steps of slide `i` (0 with Show without animation).
|
|
pub fn steps(pres: &Presentation, i: usize) -> usize {
|
|
if pres.show.without_animation { 0 } else { ShowState::timeline(pres, i).steps() }
|
|
}
|
|
|
|
fn enter(&mut self, pres: &Presentation, i: usize) {
|
|
self.slide = i;
|
|
self.step = 0;
|
|
self.ended = false;
|
|
self.playing = !pres.show.without_animation && ShowState::timeline(pres, i).step_is_auto(0);
|
|
}
|
|
|
|
fn next_index(&self, pres: &Presentation) -> Option<usize> {
|
|
let order = show_order(pres);
|
|
match order.iter().position(|&i| i == self.slide) {
|
|
Some(p) => order.get(p + 1).copied(),
|
|
None => order.iter().copied().find(|&i| i > self.slide),
|
|
}
|
|
}
|
|
|
|
fn prev_index(&self, pres: &Presentation) -> Option<usize> {
|
|
let order = show_order(pres);
|
|
match order.iter().position(|&i| i == self.slide) {
|
|
Some(p) => p.checked_sub(1).and_then(|q| order.get(q).copied()),
|
|
None => order.iter().copied().rev().find(|&i| i < self.slide),
|
|
}
|
|
}
|
|
|
|
/// Click / → / Space: finish the playing step, else play the next step, else go to the next slide.
|
|
pub fn next(&mut self, pres: &Presentation) -> ShowAction {
|
|
if pres.slides.is_empty() {
|
|
self.ended = true;
|
|
return ShowAction::Exit;
|
|
}
|
|
if self.ended {
|
|
return ShowAction::Exit;
|
|
}
|
|
if self.playing {
|
|
return self.step_done(pres);
|
|
}
|
|
let steps = ShowState::steps(pres, self.slide);
|
|
if self.step < steps {
|
|
self.playing = true;
|
|
return ShowAction::PlayStep(self.step);
|
|
}
|
|
match self.next_index(pres) {
|
|
Some(i) => {
|
|
self.enter(pres, i);
|
|
ShowAction::Slide(i)
|
|
}
|
|
None if looping(pres) => match show_order(pres).first() {
|
|
Some(&i) => {
|
|
self.enter(pres, i);
|
|
ShowAction::Slide(i)
|
|
}
|
|
None => {
|
|
self.ended = true;
|
|
ShowAction::End
|
|
}
|
|
},
|
|
None => {
|
|
self.ended = true;
|
|
self.playing = false;
|
|
ShowAction::End
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The playing step reached its end (the UI's clock passed `step_duration`).
|
|
pub fn step_done(&mut self, pres: &Presentation) -> ShowAction {
|
|
if !self.playing {
|
|
return ShowAction::None;
|
|
}
|
|
let n = self.step;
|
|
self.playing = false;
|
|
self.step = (self.step + 1).min(ShowState::steps(pres, self.slide));
|
|
ShowAction::FinishStep(n)
|
|
}
|
|
|
|
/// ← / Backspace: undo the last step, else go to the previous slide fully built.
|
|
pub fn prev(&mut self, pres: &Presentation) -> ShowAction {
|
|
if self.ended {
|
|
self.ended = false;
|
|
if self.slide >= pres.slides.len() {
|
|
return ShowAction::None;
|
|
}
|
|
self.step = ShowState::steps(pres, self.slide);
|
|
self.playing = false;
|
|
return ShowAction::Slide(self.slide);
|
|
}
|
|
if self.playing {
|
|
self.playing = false;
|
|
return ShowAction::StepBack(self.step);
|
|
}
|
|
if self.step > 0 {
|
|
self.step -= 1;
|
|
return ShowAction::StepBack(self.step);
|
|
}
|
|
let target = match self.prev_index(pres) {
|
|
Some(i) => Some(i),
|
|
None if looping(pres) => show_order(pres).last().copied().filter(|&i| i != self.slide),
|
|
None => None,
|
|
};
|
|
match target {
|
|
Some(i) => {
|
|
self.slide = i;
|
|
self.step = ShowState::steps(pres, i);
|
|
self.playing = false;
|
|
ShowAction::Slide(i)
|
|
}
|
|
None => ShowAction::None,
|
|
}
|
|
}
|
|
|
|
/// Jump to slide `i` (number + Enter, See All Slides); hidden slides are shown when jumped to.
|
|
pub fn goto(&mut self, pres: &Presentation, i: usize) -> ShowAction {
|
|
if i >= pres.slides.len() {
|
|
return ShowAction::None;
|
|
}
|
|
self.enter(pres, i);
|
|
ShowAction::Slide(i)
|
|
}
|
|
|
|
/// Follow a clicked hyperlink or shape action. `last_viewed` is the slide shown before the
|
|
/// current one (Last Slide Viewed). Moves to slide targets; everything else is for the UI.
|
|
pub fn follow(&mut self, pres: &Presentation, action: &Action, last_viewed: Option<usize>) -> LinkJump {
|
|
let target = match action {
|
|
Action::Url { url } => return if safe_url(url) { LinkJump::Open(url.trim().to_string()) } else { LinkJump::None },
|
|
Action::EndShow => return LinkJump::Exit,
|
|
Action::Slide { slide } => pres.slides.iter().position(|s| s.id == *slide),
|
|
Action::NextSlide => self.next_index(pres),
|
|
Action::PreviousSlide => self.prev_index(pres),
|
|
Action::FirstSlide => show_order(pres).first().copied(),
|
|
Action::LastSlide => show_order(pres).last().copied(),
|
|
Action::LastViewed => last_viewed,
|
|
// Custom shows, programs and media actions aren't followed (yet).
|
|
Action::CustomShow { .. } | Action::Program { .. } | Action::PlayMedia => None,
|
|
};
|
|
match target {
|
|
Some(i) => LinkJump::Show(self.goto(pres, i)),
|
|
None => LinkJump::None,
|
|
}
|
|
}
|
|
|
|
/// 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> {
|
|
if !pres.show.use_timings || self.ended {
|
|
return None;
|
|
}
|
|
pres.slides.get(self.slide).and_then(|s| s.transition.as_ref()).and_then(|t| t.advance_after_ms).map(|ms| ms as f64 / 1000.0)
|
|
}
|
|
|
|
/// Should the show advance now, `elapsed` seconds after the slide appeared? Waits for the
|
|
/// slide's animations: auto-advance happens once nothing plays and the time is up; remaining
|
|
/// click steps play automatically with timings.
|
|
pub fn auto_advance_due(&self, pres: &Presentation, elapsed: f64) -> bool {
|
|
match self.advance_after(pres) {
|
|
Some(after) => !self.playing && elapsed.is_finite() && elapsed >= after,
|
|
None => false,
|
|
}
|
|
}
|
|
}
|