mirror of
https://github.com/storytold/deckcraft.git
synced 2026-10-25 11:07:01 -06:00
Reuse Slides: choose which slides of the other file to insert (#1103)
Reuse Slides… used to insert every slide of the picked presentation. It now opens a list
of that presentation's slides (number and title, all ticked), with Select All / Select None
and a Keep source formatting option, and inserts only the ticked ones after the current
slide. New query slide.reuseList {path | data} gives the list; slide.reuse already took
`slides`.
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -17,15 +17,34 @@ use super::*;
|
||||
use crate::{Result, Session, media_refs};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![cmd!(
|
||||
"slide.reuse",
|
||||
"Reuse Slides…",
|
||||
["Home", "Slides", "New Slide"],
|
||||
None,
|
||||
"{path? | data: base64, name?, slides?: [index] (0-based, default all), keepFormatting?: bool (default false: use the destination theme), at?: index (default after the current slide)}",
|
||||
has_doc,
|
||||
reuse
|
||||
)]
|
||||
vec![
|
||||
cmd!(
|
||||
query "slide.reuseList",
|
||||
"List Slides to Reuse",
|
||||
[],
|
||||
None,
|
||||
"{path | data: base64, name?} → {name, slides: [{index, title, hidden}]}: the slides of another presentation, for choosing which ones `slide.reuse` brings in",
|
||||
has_doc,
|
||||
reuse_list
|
||||
),
|
||||
cmd!(
|
||||
"slide.reuse",
|
||||
"Reuse Slides…",
|
||||
["Home", "Slides", "New Slide"],
|
||||
None,
|
||||
"{path? | data: base64, name?, slides?: [index] (0-based, default all), keepFormatting?: bool (default false: use the destination theme), at?: index (default after the current slide)}",
|
||||
has_doc,
|
||||
reuse
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
fn reuse_list(_s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (name, bytes) = super::insert::media_bytes(p, "slide.reuseList")?;
|
||||
let src = super::file::open_presentation(&name, &bytes)?;
|
||||
let slides: Vec<Value> =
|
||||
src.slides.iter().enumerate().map(|(index, sl)| json!({"index": index, "title": sl.title(), "hidden": sl.hidden})).collect();
|
||||
Ok(json!({"name": name, "slides": slides}))
|
||||
}
|
||||
|
||||
/// Point every media id under `x` at its copy in the destination (`map`: source → destination).
|
||||
@@ -397,4 +416,34 @@ mod tests {
|
||||
assert_eq!(click(first.shapes.get(2)), Some(Action::Slide { slide: left_behind }), "a slide not reused keeps its link");
|
||||
assert_eq!(click(copy(1).shapes.first()), to(0), "a link back to an earlier copy follows too");
|
||||
}
|
||||
|
||||
/// The list that lets a dialog offer the slides of the other file; picking from it brings in
|
||||
/// exactly those.
|
||||
#[test]
|
||||
fn reuse_list_names_the_slides_and_the_picked_ones_come_in() {
|
||||
let mut src = Session::with_new();
|
||||
for t in ["Alpha", "Beta", "Gamma"] {
|
||||
src.execute("slide.new", &json!({"layout": "titleAndContent", "title": t})).expect("new");
|
||||
}
|
||||
let last_slide = src.doc().expect("doc").doc.slides.len() - 1;
|
||||
src.execute("slide.hide", &json!({"index": last_slide})).expect("hide");
|
||||
let bytes = crate::cmd::file::save_bytes(&src.doc().expect("doc").doc, "deckcraft").expect("save");
|
||||
let data = super::super::base64_encode(&bytes);
|
||||
|
||||
let mut s = Session::with_new();
|
||||
let listed = s.execute("slide.reuseList", &json!({"data": data, "name": "src.deckcraft"})).expect("list");
|
||||
let slides = listed["slides"].as_array().expect("slides");
|
||||
let title = |i: usize| slides.iter().find(|x| x["index"] == i).and_then(|x| x["title"].as_str()).unwrap_or_default().to_string();
|
||||
assert!(slides.len() >= 3);
|
||||
let (a, b, g) = (title(slides.len() - 3), title(slides.len() - 2), title(slides.len() - 1));
|
||||
assert_eq!((a.as_str(), b.as_str(), g.as_str()), ("Alpha", "Beta", "Gamma"));
|
||||
assert_eq!(slides.last().map(|x| x["hidden"].clone()), Some(json!(true)));
|
||||
assert_eq!(s.doc().expect("doc").doc.slides.len(), 1, "listing changes nothing");
|
||||
|
||||
let last = slides.len() - 1;
|
||||
s.execute("slide.reuse", &json!({"data": data, "name": "src.deckcraft", "slides": [last - 2, last]})).expect("reuse");
|
||||
let titles: Vec<String> = s.doc().expect("doc").doc.slides.iter().map(|x| x.title()).collect();
|
||||
assert!(titles.iter().any(|t| t == "Alpha") && titles.iter().any(|t| t == "Gamma"));
|
||||
assert!(!titles.iter().any(|t| t == "Beta"), "a slide that was not picked stays out");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,6 +42,7 @@ fn title(id: &str) -> &'static str {
|
||||
"altText" => "Alt Text",
|
||||
"outline" => "Slides from Outline",
|
||||
"renameSection" => "Rename Section",
|
||||
"reuseSlides" => "Reuse Slides",
|
||||
"renameShape" => "Rename",
|
||||
"renameLayout" => "Rename Layout",
|
||||
"paragraph" => "Paragraph",
|
||||
@@ -1522,6 +1523,66 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool {
|
||||
}
|
||||
ok || cancel
|
||||
}
|
||||
"reuseSlides" => {
|
||||
let path = d.params.get("path").and_then(Value::as_str).unwrap_or("").to_string();
|
||||
let slides: Vec<(usize, String)> = d
|
||||
.params
|
||||
.get("slides")
|
||||
.and_then(Value::as_array)
|
||||
.map(|a| {
|
||||
a.iter()
|
||||
.filter_map(|x| {
|
||||
Some((usize::try_from(x.get("index")?.as_u64()?).ok()?, x.get("title").and_then(Value::as_str).unwrap_or("").to_string()))
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let name = std::path::Path::new(&path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or(path.clone());
|
||||
ui.label(format!("Insert slides from {name} after the current slide:"));
|
||||
// Unticked slides are remembered by index; everything starts ticked.
|
||||
let off = |d: &Dialog, i: usize| d.fields.get(&format!("off{i}")).is_some_and(|v| v == "1");
|
||||
ui.horizontal(|ui| {
|
||||
if ui.small_button("Select All").clicked() {
|
||||
for (i, _) in &slides {
|
||||
d.fields.remove(&format!("off{i}"));
|
||||
}
|
||||
}
|
||||
if ui.small_button("Select None").clicked() {
|
||||
for (i, _) in &slides {
|
||||
d.fields.insert(format!("off{i}"), "1".into());
|
||||
}
|
||||
}
|
||||
});
|
||||
egui::ScrollArea::vertical().max_height(260.0).show(ui, |ui| {
|
||||
for (i, title) in &slides {
|
||||
let mut on = !off(d, *i);
|
||||
let label = if title.trim().is_empty() { format!("{} (no title)", i + 1) } else { format!("{} {}", i + 1, title.trim()) };
|
||||
if ui.checkbox(&mut on, label).changed() {
|
||||
if on {
|
||||
d.fields.remove(&format!("off{i}"));
|
||||
} else {
|
||||
d.fields.insert(format!("off{i}"), "1".into());
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
let mut keep = d.params.get("keep").and_then(Value::as_bool).unwrap_or(false);
|
||||
if ui.checkbox(&mut keep, "Keep source formatting").changed()
|
||||
&& let Some(o) = d.params.as_object_mut()
|
||||
{
|
||||
o.insert("keep".into(), json!(keep));
|
||||
}
|
||||
let picked: Vec<usize> = slides.iter().map(|(i, _)| *i).filter(|i| !off(d, *i)).collect();
|
||||
let (ok, cancel) = buttons(ui, "Insert");
|
||||
if ok {
|
||||
if picked.is_empty() {
|
||||
app.set_status("Tick at least one slide to insert".to_string());
|
||||
return false;
|
||||
}
|
||||
app.reuse_slides(&path, keep, &picked);
|
||||
}
|
||||
ok || cancel
|
||||
}
|
||||
"renameSection" | "renameShape" | "renameLayout" => {
|
||||
let cur = d.params.get("name").and_then(Value::as_str).unwrap_or("").to_string();
|
||||
field(ui, d, "Name:", "name", &cur);
|
||||
|
||||
@@ -1233,7 +1233,20 @@ impl SlideApp {
|
||||
return;
|
||||
};
|
||||
let Some(path) = pick("open") else { return };
|
||||
match self.session.execute("slide.reuse", &json!({"path": path, "keepFormatting": keep})) {
|
||||
// Offer the file's slides to choose from; all of them are ticked to begin with.
|
||||
match self.session.execute("slide.reuseList", &json!({"path": path})) {
|
||||
Ok(list) => {
|
||||
let mut d = dialogs::Dialog::new("reuseSlides");
|
||||
d.params = json!({"path": path, "keep": keep, "slides": list["slides"]});
|
||||
self.dialog = Some(d);
|
||||
}
|
||||
Err(e) => self.set_status(e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Insert the slides `picked` (indices) of the presentation at `path` after the current slide.
|
||||
pub fn reuse_slides(&mut self, path: &str, keep: bool, picked: &[usize]) {
|
||||
match self.session.execute("slide.reuse", &json!({"path": path, "keepFormatting": keep, "slides": picked})) {
|
||||
Ok(r) => self.set_status(format!("Inserted {} slides from {path}", r["slides"])),
|
||||
Err(e) => self.set_status(e.to_string()),
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user