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 06:23:01 -04:00
committed by GitHub
co-authored by Brandon Thomas Claude Opus 5.5
parent 3985d768e6
commit 7f1e152870
10 changed files with 474 additions and 30 deletions
+17 -7
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());
+62 -17
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> {