Links: Insert Hyperlink with a bare caret inserts the address; the show follows links (#31) (#45)

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>
This commit is contained in:
alexanderalber 2026-10-10 12:23:01 +02:00 committed by GitHub
parent 3985d768e6
commit 7f1e152870
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 474 additions and 30 deletions

223
Cargo.lock generated
View File

@ -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"

View File

@ -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.

View File

@ -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 }

View File

@ -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};

View File

@ -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<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> {

View File

@ -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);
}

View File

@ -49,7 +49,15 @@ pub fn specs() -> Vec<CommandSpec> {
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<Value> {
fn hyperlink(s: &mut Session, p: &Value) -> Result<Value> {
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());

View File

@ -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<RunProps>)
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<RunProps>)
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<RunProps> {
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<Value> {
pub(crate) fn format_run(s: &mut Session, f: &(dyn Fn(&mut RunProps) + Send + Sync)) -> Result<Value> {
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<char> = 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<char> = 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<Value> {
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<Value> {

View File

@ -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.

View File

@ -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<usize>,
/// The slide shown before the current one (the Last Slide Viewed action).
last_viewed: Option<usize>,
/// Click areas of hyperlinks and shape actions on slide `.0`, in slide points.
links: Option<(usize, Vec<deckcraft_render::PlacedLink>)>,
}
#[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<Hyperlink> {
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))