diff --git a/crates/engine/src/cmd/insert.rs b/crates/engine/src/cmd/insert.rs index 76bc5359..4c20c00e 100644 --- a/crates/engine/src/cmd/insert.rs +++ b/crates/engine/src/cmd/insert.rs @@ -98,7 +98,7 @@ pub fn specs() -> Vec { "Date & Time", ["Insert", "Text"], None, - "{format?: datetime1..datetime13, update?: bool, language?: en-US|en-GB|fr-FR|ja-JP|sv-SE} — no keys opens the dialog", + "{format?: datetime1..datetime13, update?: bool, language?: en-US|en-GB|fr-FR|ja-JP|sv-SE} — no keys opens the dialog; in the Handout or Notes Master view it fills the selected date placeholder (else the master's own) and answers where: \"master\"", has_slide, date_time ), @@ -473,6 +473,70 @@ mod tests { } } + /// ZZ1: in the handout and notes master views the date goes into the master's date placeholder + /// (not the current slide), so the exported handout and notes pages show it. + #[test] + fn on_a_page_master_the_date_fills_its_date_placeholder() { + for view in ["view.handoutMaster", "view.notesMaster"] { + let mut s = session(); + s.execute(view, &json!({})).unwrap(); + let (id, master) = { + let st = s.doc().unwrap(); + let m = crate::page_master(&st.doc, st.selection.target).unwrap().clone(); + (m.shapes.iter().find(|x| x.ph_type() == Some(deckcraft_model::PhType::Date)).unwrap().id.0, m) + }; + assert!(master.shapes.iter().find(|x| x.id.0 == id).and_then(|x| x.text.as_ref()).is_none_or(|t| t.text().is_empty())); + s.execute("edit.select", &json!({"ids": [id]})).unwrap(); + let v = s.execute("insert.dateTime", &json!({"format": "datetime10", "update": true})).unwrap(); + assert_eq!(v["where"], "master", "{view}: {v}"); + assert!(date_run(&s).is_none(), "the slide keeps no date"); + let st = s.doc().unwrap(); + let m = crate::page_master(&st.doc, st.selection.target).unwrap(); + let run = m + .shapes + .iter() + .find(|x| x.id.0 == id) + .and_then(|x| x.text.as_ref()) + .and_then(|t| t.paragraphs.first()) + .and_then(|p| p.runs.first()) + .cloned() + .unwrap(); + assert!(matches!(run.kind, RunKind::Field { ref field } if field == "datetime10"), "{view}: {run:?}"); + let placed = deckcraft_render::place_master_texts(&st.doc, m, 1); + assert!(placed.iter().any(|p| p.body.text() == run.text && run.text.contains(':')), "{view}: the page shows the date"); + s.execute("edit.undo", &json!({})).unwrap(); + let st = s.doc().unwrap(); + let m = crate::page_master(&st.doc, st.selection.target).unwrap(); + assert!(m.shapes.iter().find(|x| x.id.0 == id).and_then(|x| x.text.as_ref()).is_none_or(|t| t.text().is_empty()), "one undo"); + // A fixed date in a placeholder whose run is en-GB reads day first, and the answer + // reports the text that was written, not the en-US sample. + s.execute("insert.dateTime", &json!({"format": "datetime1", "update": false, "language": "en-GB"})).unwrap(); + let v = s.execute("insert.dateTime", &json!({"format": "datetime1", "update": false})).unwrap(); + let st = s.doc().unwrap(); + let m = crate::page_master(&st.doc, st.selection.target).unwrap(); + let run = m + .shapes + .iter() + .find(|x| x.id.0 == id) + .and_then(|x| x.text.as_ref()) + .and_then(|t| t.paragraphs.first()) + .and_then(|p| p.runs.first()) + .cloned() + .unwrap(); + assert_eq!(run.props.lang.as_deref(), Some("en-GB"), "{view}: {run:?}"); + assert_eq!(v["text"].as_str(), Some(run.text.as_str()), "{view}: the answer reports the written text: {v}"); + let now = deckcraft_text::datetime::DateTime::now_local().unwrap(); + let gb = deckcraft_text::datetime::field_text("datetime1", Some("en-GB"), now); + assert_eq!(Some(run.text.as_str()), gb.as_deref(), "{view}: the date in en-GB order (dd/mm/yyyy)"); + // No date placeholder: refused, nothing changed. + let key = if view == "view.handoutMaster" { "handout.placeholders" } else { "notes.placeholders" }; + s.execute(key, &json!({"date": false})).unwrap(); + let rev = s.doc().unwrap().revision; + assert!(s.execute("insert.dateTime", &json!({"format": "datetime1"})).is_err()); + assert_eq!(s.doc().unwrap().revision, rev); + } + } + #[test] fn without_a_caret_the_date_lands_on_this_slide_only() { let mut s = session(); diff --git a/crates/engine/src/cmd/insert/text_fields.rs b/crates/engine/src/cmd/insert/text_fields.rs index 2a04f30b..e90c7c89 100644 --- a/crates/engine/src/cmd/insert/text_fields.rs +++ b/crates/engine/src/cmd/insert/text_fields.rs @@ -152,6 +152,11 @@ fn apply_date(s: &mut Session, o: &DateOpts) -> Result { } return Ok(json!({"where": "caret", "format": o.format, "update": o.update, "text": o.sample})); } + // Handout or Notes Master view: the master's date placeholder (the selected one, else the + // master's own), which every handout or notes page then shows. + if s.doc()?.selection.target.is_page_master() { + return write_master_date(s, o); + } // Not editing: the slide's date placeholder (this slide only). A layout with no date // placeholder gets a text box instead, so the chosen format is still inserted. s.execute("design.headerFooter", &json!({"date": true, "all": false}))?; @@ -161,6 +166,49 @@ fn apply_date(s: &mut Session, o: &DateOpts) -> Result { Ok(json!({"where": "footer", "format": o.format, "update": o.update, "text": o.sample})) } +/// Put the date in the date placeholder of the handout or notes master being edited. +fn write_master_date(s: &mut Session, o: &DateOpts) -> Result { + let (format, sample, lang, update) = (o.format.clone(), o.sample.clone(), o.lang.clone(), o.update); + let has_date = { + let st = s.doc()?; + crate::page_master(&st.doc, st.selection.target).is_some_and(|m| m.shapes.iter().any(|x| x.ph_type() == Some(deckcraft_model::PhType::Date))) + }; + if !has_date { + return Err(bad( + "insert.dateTime", + "this master has no date placeholder; show it with handout.placeholders or notes.placeholders {date: true}", + )); + } + // The text the master holds afterwards, which the answer reports (it can differ from the + // caller's sample when the placeholder's own language formats a fixed date). + let mut written = o.sample.clone(); + s.edit(|doc, sel| { + let Some(m) = crate::page_master_mut(doc, sel.target) else { return Ok(false) }; + let m = std::sync::Arc::make_mut(m); + let date = |x: &deckcraft_model::Shape| x.ph_type() == Some(deckcraft_model::PhType::Date); + let chosen = sel.shapes.iter().find_map(|id| m.shapes.iter().position(|x| x.id == *id && date(x))); + let Some(i) = chosen.or_else(|| m.shapes.iter().position(date)) else { return Ok(false) }; + let Some(sh) = m.shapes.get_mut(i) else { return Ok(false) }; + let mut props = sh.text.as_ref().and_then(|t| t.paragraphs.first()).and_then(|p| p.runs.first()).map(|r| r.props.clone()).unwrap_or_default(); + // A fixed date reads in the placeholder's own language when the caller named none. + let text = match (&lang, &props.lang) { + (None, Some(l)) if !update => date_sample(&format, Some(l)).unwrap_or_else(|_| sample.clone()), + _ => sample.clone(), + }; + if let Some(lang) = lang { + props.lang = Some(lang); + } + let kind = if update { RunKind::Field { field: format } } else { RunKind::Text }; + let body = sh.text.clone().unwrap_or_default(); + let mut para = body.paragraphs.first().cloned().unwrap_or_default(); + written.clone_from(&text); + para.runs = vec![deckcraft_model::Run { text, props, kind }]; + sh.text = Some(TextBody { paragraphs: vec![para], ..body }); + Ok(true) + })?; + Ok(json!({"where": "master", "format": o.format, "update": o.update, "text": written})) +} + /// Point the current slide's date placeholder at this format (a field, or the sample as plain text). fn write_date_placeholder(s: &mut Session, o: &DateOpts) -> Result { let format = o.format.clone(); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index c3f645cc..d96717a1 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -682,7 +682,28 @@ impl Session { self.journal.drain(..1000); } } - Ok(r) + Ok(if outer { self.with_editing(id, r) } else { r }) + } + + /// In Slide Master / layout (or handout / notes master) view the insert commands edit the + /// master or layout, not a slide; their answer says so with `editing: {kind, name}`, as + /// `slide.inspect` does. Nothing is added on slides, so those answers stay as they were. + fn with_editing(&self, id: &str, mut r: Value) -> Value { + let inserts = id.starts_with("insert.") || matches!(id, "shape.insert" | "shape.freeform" | "shape.connect" | "slide.new"); + let (true, Some(st), Some(o)) = (inserts, self.active(), r.as_object_mut()) else { return r }; + let doc = &st.doc; + let edited = match st.selection.target { + Target::Master { master } => doc.masters.get(master).map(|m| ("master", m.name.clone())), + Target::Layout { master, layout } => doc.masters.get(master).and_then(|m| m.layouts.get(layout)).map(|l| ("layout", l.name.clone())), + t @ (Target::Handout | Target::NotesMaster) => { + page_master(doc, t).map(|m| (if matches!(t, Target::Handout) { "handout" } else { "notesMaster" }, m.name.clone())) + } + Target::Slides => None, + }; + if let Some((kind, name)) = edited { + o.entry("editing").or_insert_with(|| serde_json::json!({"kind": kind, "name": name})); + } + r } pub fn commands(&self) -> Vec { diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 7bba9ebe..22b866fa 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -5669,3 +5669,23 @@ fn table_cell_align_sets_the_horizontal_alignment() { assert!(s.execute("table.cellAlign", &json!({"id": id.0, "align": "left", "anchor": true})).is_err()); assert_eq!(s.doc().unwrap().revision, rev); } + +/// In Slide Master / layout view the insert commands edit the master or layout; their answers say +/// so with `editing`, and on slides carry no such key. +#[test] +fn insert_answers_carry_editing_in_master_and_layout_view() { + let mut s = session(); + let ins = json!({"preset": "rect", "rect": [10.0, 10.0, 100.0, 100.0]}); + s.execute("view.slideMaster", &json!({"layout": 0})).unwrap(); + let r = s.execute("shape.insert", &ins).unwrap(); + assert_eq!(r["editing"]["kind"], "layout", "{r}"); + assert!(r["editing"]["name"].is_string(), "{r}"); + let r = s.execute("insert.textBox", &json!({"rect": [10.0, 10.0, 100.0, 50.0], "text": "x"})).unwrap(); + assert_eq!(r["editing"]["kind"], "layout", "{r}"); + s.execute("view.slideMaster", &json!({"master": 0})).unwrap(); + let r = s.execute("shape.insert", &ins).unwrap(); + assert_eq!(r["editing"]["kind"], "master", "{r}"); + s.execute("view.closeMaster", &json!({})).unwrap(); + let r = s.execute("shape.insert", &ins).unwrap(); + assert!(r.get("editing").is_none(), "{r}"); +}