diff --git a/Cargo.lock b/Cargo.lock index 43d9ece..6347c97 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1204,6 +1204,17 @@ dependencies = [ "objc2 0.6.5", ] +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + [[package]] name = "dlib" version = "0.5.3" @@ -1337,6 +1348,7 @@ dependencies = [ "raw-window-handle", "smithay-clipboard", "web-time", + "webbrowser", "winit", ] @@ -1635,6 +1647,15 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + [[package]] name = "futures-core" version = "0.3.34" @@ -2049,6 +2070,110 @@ version = "0.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e9d4a992a9fe83130f41ceacceac3bb116a4355dfc9c8d6ecea4f1e4b4c6caf" +[[package]] +name = "icu_collections" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" + +[[package]] +name = "icu_properties" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" + +[[package]] +name = "icu_provider" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + [[package]] name = "image" version = "0.25.10" @@ -2350,6 +2475,12 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" +[[package]] +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + [[package]] name = "litrs" version = "1.0.0" @@ -3174,6 +3305,15 @@ dependencies = [ "portable-atomic", ] +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "zerovec", +] + [[package]] name = "ppv-lite86" version = "0.2.21" @@ -4198,6 +4338,16 @@ dependencies = [ "strict-num", ] +[[package]] +name = "tinystr" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +dependencies = [ + "displaydoc", + "zerovec", +] + [[package]] name = "tokio" version = "1.53.2" @@ -4385,6 +4535,24 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + [[package]] name = "uuid" version = "1.27.0" @@ -4701,6 +4869,22 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "webbrowser" +version = "1.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62c35be770821a214dbc362fc26908c853e776c0004294d0b10b8a6bad582f94" +dependencies = [ + "jni 0.22.4", + "log", + "ndk-context", + "objc2 0.6.5", + "objc2-app-kit 0.3.2", + "objc2-foundation 0.3.2", + "url", + "web-sys", +] + [[package]] name = "weezl" version = "0.1.12" @@ -5358,6 +5542,12 @@ dependencies = [ "read-fonts 0.39.2", ] +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + [[package]] name = "x11-dl" version = "2.21.0" @@ -5604,6 +5794,39 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + [[package]] name = "zip" version = "3.0.0" diff --git a/ROADMAP.md b/ROADMAP.md index 1c75ffa..d3a31fb 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -27,7 +27,8 @@ format plus PPTX), scriptable by agents (CLI, JSON control channel, MCP), and av - Transitions (fade, push, wipe, split, cover, uncover, zoom, morph…) and animations (entrance, emphasis, exit, motion paths, triggers, by-paragraph builds) on a shared timeline engine. - Slide show: full screen, keyboard/mouse navigation, blank screens, go-to-slide, pen, presenter - window, rehearse timings, custom shows, Set Up Show; Reading View. + window, rehearse timings, custom shows, Set Up Show; Reading View; hyperlinks and shape actions + are followed on click (slides, web pages, email, End Show, Last Slide Viewed). - Views: Normal (thumbnails with sections, slide, notes), Outline, Slide Sorter, Notes Page, Slide Master; zoom; grayscale. - Review: comments, accessibility checker, spelling. diff --git a/apps/deckcraft/Cargo.toml b/apps/deckcraft/Cargo.toml index fb49562..26ea308 100644 --- a/apps/deckcraft/Cargo.toml +++ b/apps/deckcraft/Cargo.toml @@ -14,7 +14,7 @@ workspace = true deckcraft-engine = { workspace = true } deckcraft-ui-egui = { workspace = true } deckcraft-media = { workspace = true } -eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "wayland", "x11"] } +eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "links", "wayland", "x11"] } egui = { workspace = true } serde_json = { workspace = true } log = { workspace = true } diff --git a/crates/anim/src/lib.rs b/crates/anim/src/lib.rs index 5d9f6cf..fae7e4d 100644 --- a/crates/anim/src/lib.rs +++ b/crates/anim/src/lib.rs @@ -29,7 +29,7 @@ use serde::{Deserialize, Serialize}; pub use easing::ease; pub use morph::{MorphFrame, MorphPath, MorphText, morph_frame, morph_pairs, morph_xfrm}; pub use path::{MotionPath, default_path}; -pub use show::{ShowAction, ShowState, show_order}; +pub use show::{LinkJump, ShowAction, ShowState, safe_url, show_order}; pub use timeline::{EffectTiming, Sequence, Timeline, TriggerPlay}; pub use transition::{Layer, Source, transition_layers}; diff --git a/crates/anim/src/show.rs b/crates/anim/src/show.rs index a964e87..dbc0a8a 100644 --- a/crates/anim/src/show.rs +++ b/crates/anim/src/show.rs @@ -2,6 +2,7 @@ //! and automatic advance. use deckcraft_model::Presentation; +use deckcraft_model::text::Action; use crate::Timeline; @@ -25,6 +26,32 @@ pub enum ShowAction { 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 { @@ -211,6 +238,27 @@ impl ShowState { 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) -> 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 { diff --git a/crates/anim/src/tests.rs b/crates/anim/src/tests.rs index a360a25..1fd82d7 100644 --- a/crates/anim/src/tests.rs +++ b/crates/anim/src/tests.rs @@ -761,3 +761,31 @@ fn compose_and_sanitize() { let bad = AnimState { opacity: f64::NAN, rotate: f64::INFINITY, clip: Some([f64::NAN, 2.0, -1.0, 0.5]), ..Default::default() }.sanitized(); assert!(finite(&bad)); } + +#[test] +fn show_follows_links() { + use deckcraft_model::text::Action; + let mut p = pres(4); + Arc::make_mut(&mut p.slides[3]).hidden = true; + let mut s = ShowState::new(&p); + assert_eq!(s.follow(&p, &Action::Slide { slide: SlideId(1002) }, None), LinkJump::Show(ShowAction::Slide(2))); + assert_eq!(s.follow(&p, &Action::PreviousSlide, None), LinkJump::Show(ShowAction::Slide(1))); + assert_eq!(s.follow(&p, &Action::NextSlide, None), LinkJump::Show(ShowAction::Slide(2))); + // Last skips the hidden slide; a link to the hidden slide shows it. + assert_eq!(s.follow(&p, &Action::LastSlide, None), LinkJump::Show(ShowAction::Slide(2))); + assert_eq!(s.follow(&p, &Action::Slide { slide: SlideId(1003) }, None), LinkJump::Show(ShowAction::Slide(3))); + assert_eq!(s.follow(&p, &Action::FirstSlide, None), LinkJump::Show(ShowAction::Slide(0))); + assert_eq!(s.follow(&p, &Action::LastViewed, Some(3)), LinkJump::Show(ShowAction::Slide(3))); + assert_eq!(s.slide, 3); + assert_eq!(s.follow(&p, &Action::Url { url: "https://example.org".into() }, None), LinkJump::Open("https://example.org".into())); + for bad in + ["file:///etc/passwd", "javascript:alert(1)", "ms-settings:", "smb://host/share", "C:\\Windows\\calc.exe", "../notes.txt", "https:", ""] + { + assert_eq!(s.follow(&p, &Action::Url { url: bad.into() }, None), LinkJump::None, "{bad} must not open"); + } + assert_eq!(s.follow(&p, &Action::Url { url: " MAILTO:a@example.org".into() }, None), LinkJump::Open("MAILTO:a@example.org".into())); + assert_eq!(s.follow(&p, &Action::EndShow, None), LinkJump::Exit); + assert_eq!(s.follow(&p, &Action::Slide { slide: SlideId(9) }, None), LinkJump::None, "a link to a deleted slide does nothing"); + assert_eq!(s.follow(&p, &Action::LastViewed, None), LinkJump::None); + assert_eq!(s.slide, 3); +} diff --git a/crates/engine/src/cmd/insert.rs b/crates/engine/src/cmd/insert.rs index f2be6b8..db5aaf4 100644 --- a/crates/engine/src/cmd/insert.rs +++ b/crates/engine/src/cmd/insert.rs @@ -49,7 +49,15 @@ pub fn specs() -> Vec { cmd!("insert.slideNumber", "Slide Number", ["Insert", "Text"], None, "{}", has_slide, slide_number), cmd!("insert.dateTime", "Date & Time", ["Insert", "Text"], None, "{format?: datetime1..}", has_slide, date_time), cmd!("insert.symbol", "Symbol", ["Insert", "Symbols"], None, "{text: character(s)}", has_slide, symbol), - cmd!("insert.hyperlink", "Link", ["Insert", "Links"], Some("Cmd+K"), "{url? | slide?: index, tooltip?, ids?}", has_text_or_shapes, hyperlink), + cmd!( + "insert.hyperlink", + "Link", + ["Insert", "Links"], + Some("Cmd+K"), + "{url? | slide?: index, tooltip?, text?: shown when nothing is selected, ids?}", + has_text_or_shapes, + hyperlink + ), cmd!("chart.type", "Change Chart Type", ["Chart Design", "Type"], None, "{type, id?}", has_selection, chart_type_cmd), cmd!( "chart.data", @@ -569,18 +577,20 @@ fn symbol(s: &mut Session, p: &Value) -> Result { fn hyperlink(s: &mut Session, p: &Value) -> Result { use deckcraft_model::text::{Action, Hyperlink}; - let action = if let Some(u) = str_param(p, "url") { - Action::Url { url: u.to_string() } + // `display`: what a caret with nothing to link inserts, the address or the slide's title. + let (action, display) = if let Some(u) = str_param(p, "url") { + (Action::Url { url: u.to_string() }, u.strip_prefix("mailto:").unwrap_or(u).to_string()) } else if let Some(i) = usize_param(p, "slide") { - let id = s.doc()?.doc.slides.get(i).map(|x| x.id).ok_or_else(|| bad("insert.hyperlink", "no such slide"))?; - Action::Slide { slide: id } + let slide = s.doc()?.doc.slides.get(i).ok_or_else(|| bad("insert.hyperlink", "no such slide"))?; + let title = slide.title(); + (Action::Slide { slide: slide.id }, if title.is_empty() { format!("Slide {}", i + 1) } else { title }) } else { 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 }; if s.doc()?.selection.text.is_some() { - let l = link.clone(); - return super::text::format_run(s, &move |rp| rp.link = Some(l.clone())); + return super::text::link_text(s, link, &display); } edit_shapes(s, p, "insert.hyperlink", |sh| { sh.click = Some(link.clone()); diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index a35f55e..48be3a5 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -4,7 +4,7 @@ use std::sync::Arc; use deckcraft_model::edit::{self as ed, Pos}; use deckcraft_model::resolve::{self, Ctx}; -use deckcraft_model::text::{AutoFit, Run, RunKind, RunProps, TextBody}; +use deckcraft_model::text::{AutoFit, Hyperlink, Run, RunKind, RunProps, TextBody}; use deckcraft_model::{Presentation, ShapeId, ShapeKind}; use serde_json::{Value, json}; @@ -228,7 +228,7 @@ pub(crate) fn insert_text(s: &mut Session, text: &str, props: Option) let pos = edit_text(s, |body, t| { let (a, b) = t.ordered(); let at = ed::delete(body, a, b); - let props = props.clone().or_else(|| if a != b { Some(ed::props_at(body, at)) } else { None }); + let props = props.clone().or_else(|| if a != b { Some(ed::props_at(body, at)) } else { unlinked_at_link_end(body, at) }); let c = ed::insert(body, at, &text, props); t.anchor = c; t.caret = c; @@ -238,6 +238,20 @@ pub(crate) fn insert_text(s: &mut Session, text: &str, props: Option) Ok(json!({"caret": [pos.0, pos.1]})) } +/// Typing right after a hyperlink doesn't extend it (as in PowerPoint): at the end of a linked +/// run, the props to type with are the run's without the link. `None` elsewhere. +fn unlinked_at_link_end(body: &TextBody, at: Pos) -> Option { + let mut props = ed::props_at(body, at); + props.link.as_ref()?; + let len = body.paragraphs.get(at.0).map_or(0, |p| p.text().chars().count()); + // props_at(c + 1) is the props of the character at c. + if at.1 < len && ed::props_at(body, (at.0, at.1 + 1)).link == props.link { + return None; + } + props.link = None; + Some(props) +} + const AUTOCORRECT: &[(&str, &str)] = &[ ("teh", "the"), ("adn", "and"), @@ -535,21 +549,7 @@ fn get(s: &mut Session, p: &Value) -> Result { pub(crate) fn format_run(s: &mut Session, f: &(dyn Fn(&mut RunProps) + Send + Sync)) -> Result { let st = s.doc()?; if let Some(t) = st.selection.text.clone() { - let body = body_of(st, &t).cloned().unwrap_or_default(); - let (mut a, mut b) = t.ordered(); - if a == b { - let (wa, wb) = ed::word_at(&body, a); - let inside = wa.1 < a.1 && a.1 < wb.1; - if inside { - a = wa; - b = wb; - // Don't include the trailing space. - let line: Vec = body.paragraphs.get(b.0).map(|p| p.text().chars().collect()).unwrap_or_default(); - while b.1 > a.1 && line.get(b.1 - 1) == Some(&' ') { - b.1 -= 1; - } - } - } + let (a, b) = format_range(st, &t); let collapsed = a == b; edit_text(s, |body, _| { if collapsed { @@ -591,6 +591,51 @@ pub(crate) fn format_run(s: &mut Session, f: &(dyn Fn(&mut RunProps) + Send + Sy ok() } +/// What `format_run` formats while editing: the selection, or for a bare caret the word it's in +/// (without its trailing space). Empty when the caret is at a word boundary. +fn format_range(st: &DocState, t: &TextSel) -> (Pos, Pos) { + let body = body_of(st, t).cloned().unwrap_or_default(); + let (mut a, mut b) = t.ordered(); + if a == b { + let (wa, wb) = ed::word_at(&body, a); + let inside = wa.1 < a.1 && a.1 < wb.1; + if inside { + a = wa; + b = wb; + // Don't include the trailing space. + let line: Vec = body.paragraphs.get(b.0).map(|p| p.text().chars().collect()).unwrap_or_default(); + while b.1 > a.1 && line.get(b.1 - 1) == Some(&' ') { + b.1 -= 1; + } + } + } + (a, b) +} + +/// Link the text being edited: the selection or the word at the caret, as `format_run` would. +/// With nothing to link (caret at a word boundary, empty paragraph) `display` is inserted at the +/// caret as linked text in the surrounding formatting, as PowerPoint's Insert Hyperlink does. +pub(crate) fn link_text(s: &mut Session, link: Hyperlink, display: &str) -> Result { + let st = s.doc()?; + let Some(t) = st.selection.text.clone() else { return Err(bad("insert.hyperlink", "not editing text")) }; + let (a, b) = format_range(st, &t); + if a != b { + return format_run(s, &move |rp| rp.link = Some(link.clone())); + } + if display.is_empty() { + return Err(bad("insert.hyperlink", "nothing to link: select text or give `text`")); + } + let pos = edit_text(s, |body, t| { + let mut props = ed::props_at(body, a); + props.link = Some(link); + let c = ed::insert(body, a, display, Some(props)); + t.anchor = c; + t.caret = c; + c + })?; + Ok(json!({"caret": [pos.0, pos.1]})) +} + /// Apply paragraph changes to the paragraphs touched by the selection (or all paragraphs of the /// selected shapes). pub(crate) fn format_para(s: &mut Session, f: &(dyn Fn(&mut deckcraft_model::text::Paragraph) + Send + Sync)) -> Result { diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index e5d0e1f..8a8e37e 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -426,6 +426,56 @@ fn right_to_left_paragraphs() { assert_eq!(para(&s).rtl, Some(true)); } +/// (text, linked?) of each run in the first paragraph of the current slide's title. +fn title_runs(s: &Session) -> Vec<(String, bool)> { + let sh = &s.doc().unwrap().current_slide().unwrap().shapes[0]; + let p = &sh.text.as_ref().unwrap().paragraphs[0]; + p.runs.iter().map(|r| (r.text.clone(), r.props.link.is_some())).collect() +} + +#[test] +fn hyperlink_with_a_bare_caret_inserts_the_address() { + // #31: with the caret at the end of the text, Insert Hyperlink used to format an empty range + // and change nothing. + let mut s = session(); + let title = s.doc().unwrap().current_slide().unwrap().shapes[0].id.0; + s.execute("text.edit", &json!({"id": title})).unwrap(); + s.execute("text.insert", &json!({"text": "See "})).unwrap(); + s.execute("insert.hyperlink", &json!({"url": "https://example.org"})).unwrap(); + s.execute("text.insert", &json!({"text": " now"})).unwrap(); + assert_eq!( + title_runs(&s), + vec![("See ".into(), false), ("https://example.org".into(), true), (" now".into(), false)], + "the address is inserted as a link, and typing after it doesn't extend it" + ); +} + +#[test] +fn hyperlink_display_text_and_targets() { + let mut s = session(); + let title = s.doc().unwrap().current_slide().unwrap().shapes[0].id.0; + s.execute("text.edit", &json!({"id": title})).unwrap(); + s.execute("insert.hyperlink", &json!({"url": "mailto:a@b.org"})).unwrap(); + assert_eq!(title_runs(&s), vec![("a@b.org".into(), true)], "email links show the address without mailto:"); + s.execute("text.insert", &json!({"text": " "})).unwrap(); + s.execute("insert.hyperlink", &json!({"slide": 0, "text": "start"})).unwrap(); + assert_eq!(title_runs(&s), vec![("a@b.org".into(), true), (" ".into(), false), ("start".into(), true)]); +} + +#[test] +fn hyperlink_on_a_word_or_shape_links_it() { + let mut s = session(); + let title = s.doc().unwrap().current_slide().unwrap().shapes[0].id.0; + s.execute("text.edit", &json!({"id": title})).unwrap(); + s.execute("text.insert", &json!({"text": "Hello world"})).unwrap(); + s.execute("text.move", &json!({"to": "left"})).unwrap(); + s.execute("insert.hyperlink", &json!({"url": "https://example.org"})).unwrap(); + assert_eq!(title_runs(&s), vec![("Hello ".into(), false), ("world".into(), true)], "a caret inside a word links the word"); + s.execute("text.exit", &json!({})).unwrap(); + s.execute("insert.hyperlink", &json!({"url": "https://example.org"})).unwrap(); + assert!(s.doc().unwrap().current_slide().unwrap().shapes[0].click.is_some(), "a selected shape gets the link as its click action"); +} + #[test] fn clicks_in_flipped_shapes_hit_the_text_where_it_is_drawn() { // Flips don't mirror text, so a click lands on the character drawn under it. diff --git a/crates/ui-egui/src/show.rs b/crates/ui-egui/src/show.rs index cf13263..1c8e6e1 100644 --- a/crates/ui-egui/src/show.rs +++ b/crates/ui-egui/src/show.rs @@ -1,7 +1,8 @@ //! Slide Show: full-screen playback with transitions and animations (`deckcraft-anim`), //! keyboard/mouse navigation, pen, black/white screens, presenter view and rehearsed timings. -use deckcraft_anim::{Layer, ShowAction, ShowState, Source, Timeline}; +use deckcraft_anim::{Layer, LinkJump, ShowAction, ShowState, Source, Timeline}; +use deckcraft_model::text::Hyperlink; use deckcraft_model::{Presentation, ShapeId}; use deckcraft_render::{ParaState, RenderOpts, ShapeState}; use egui::{Align2, Color32, CornerRadius, Mesh, Pos2, Rect, Sense, Stroke, TextureHandle, Ui, pos2, vec2}; @@ -37,6 +38,10 @@ pub struct Show { pub ended: bool, /// The slide whose media was last started (auto-play runs once per visit). media_slide: Option, + /// The slide shown before the current one (the Last Slide Viewed action). + last_viewed: Option, + /// Click areas of hyperlinks and shape actions on slide `.0`, in slide points. + links: Option<(usize, Vec)>, } #[derive(Clone, PartialEq)] @@ -88,6 +93,8 @@ impl Show { last_move: 0.0, ended: false, media_slide: None, + last_viewed: None, + links: None, } } @@ -127,6 +134,9 @@ impl Show { // otherwise rendered into the same texture, and the transition blends the new // slide with itself (#33). let (from, old) = self.tex.take().map(|(k, t)| (k.slide, t)).unzip(); + if from != Some(i) { + self.last_viewed = from; + } self.trans = if kind != "none" && dur > 0.0 && !doc.show.without_animation { Some(Transition { old, from, morph: None, start: now, kind, option, dur }) } else { @@ -281,12 +291,22 @@ pub fn ui(app: &mut SlideApp, ui: &mut Ui) { _ => {} } } - let gif_click = if clicked && !show.pen && !show.ended && show.blank.is_none() { gif_hit(&ctx, ui, &show, &doc, app) } else { None }; - let media_click = if clicked && !show.pen && !show.ended && show.blank.is_none() { media_hit(&ctx, ui, &show, &doc) } else { None }; + let on_slide = !show.pen && !show.ended && show.blank.is_none() && !show.reading_bar_hit(&ctx); + let gif_click = if clicked && on_slide { gif_hit(&ctx, ui, &show, &doc, app) } else { None }; + let media_click = if clicked && on_slide && gif_click.is_none() { media_hit(&ctx, ui, &show, &doc) } else { None }; + let link = if on_slide && gif_click.is_none() && media_click.is_none() { link_hit(&ctx, ui, &mut show, &doc) } else { None }; if let Some(id) = gif_click { let _ = app.run("media.gifPlay", json!({"id": id})); } else if let Some((id, clip)) = media_click { 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) { + LinkJump::Show(a) => actions.push(a), + LinkJump::Open(url) => ctx.open_url(egui::OpenUrl::new_tab(url)), + LinkJump::Exit => keep = false, + LinkJump::None => {} + } } else if clicked && !show.pen && !show.reading_bar_hit(&ctx) { if show.ended { keep = false; @@ -517,6 +537,8 @@ pub fn ui(app: &mut SlideApp, ui: &mut Ui) { s.push(pos2((p.x - srect.min.x) / srect.width(), (p.y - srect.min.y) / srect.height())); } ctx.set_cursor_icon(egui::CursorIcon::Crosshair); + } else if link.is_some() { + ctx.set_cursor_icon(egui::CursorIcon::PointingHand); } else if now - show.last_move > 2.5 && !show.reading { ctx.set_cursor_icon(egui::CursorIcon::None); ctx.request_repaint_after(std::time::Duration::from_millis(500)); @@ -661,6 +683,23 @@ fn media_hit(ctx: &egui::Context, ui: &Ui, show: &Show, doc: &Presentation) -> O }) } +/// The link under the pointer: the topmost shape action or text hyperlink on the current slide +/// (a text link wins over its shape's action). +fn link_hit(ctx: &egui::Context, ui: &Ui, show: &mut Show, doc: &Presentation) -> Option { + let p = ctx.input(|i| i.pointer.hover_pos())?; + let full = ui.max_rect(); + let avail = if show.reading { Rect::from_min_max(full.min, pos2(full.max.x, full.max.y - 34.0)) } else { full }; + let srect = fit(avail, doc); + let k = (srect.width() / doc.slide_size.width.max(1.0) as f32) as f64; + let (x, y) = ((p.x - srect.min.x) as f64 / k, (p.y - srect.min.y) as f64 / k); + let i = show.state.slide; + if show.links.as_ref().is_none_or(|(s, _)| *s != i) { + show.links = Some((i, deckcraft_render::place_slide(doc, i).links)); + } + let (_, links) = show.links.as_ref()?; + links.iter().rev().find(|l| l.rect.x0 <= x && x <= l.rect.x1 && l.rect.y0 <= y && y <= l.rect.y1).map(|l| l.link.clone()) +} + /// A slide-space box on screen, the slide being drawn in `srect` at `k` pixels per point. fn on_screen(srect: Rect, k: f32, x: &deckcraft_geom::Xfrm) -> Rect { Rect::from_min_size(pos2(srect.min.x + x.x as f32 * k, srect.min.y + x.y as f32 * k), vec2(x.w as f32 * k, x.h as f32 * k))