From 443fe300d0b167816b90eb7de6fccee18979c5ca Mon Sep 17 00:00:00 2001 From: Nikita Rakitin Date: Wed, 14 Oct 2026 00:31:14 +0300 Subject: [PATCH] Add the Notes Master view MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit View ▸ Notes Master edits the notes master on a notes-size page (creating PowerPoint's default placeholders if the deck has none); its tab toggles header, date, slide image, body, footer and page number, sets the background and closes the view. The PPTX writer now writes an edited notes master as it is, even in a deck without notes. Stacked on #572. --- crates/engine/src/cmd/design.rs | 128 +++++++++++++++++++++++++++++++- crates/engine/src/cmd/mod.rs | 6 +- crates/engine/src/cmd/text.rs | 2 +- crates/engine/src/lib.rs | 42 +++++++++-- crates/engine/src/tests.rs | 32 +++++++- crates/pptx/src/write/mod.rs | 16 +++- crates/ui-egui/src/canvas.rs | 4 +- crates/ui-egui/src/menus.rs | 1 + crates/ui-egui/src/ribbon.rs | 39 +++++++++- crates/ui-egui/src/thumbs.rs | 2 +- docs/gaps.md | 10 ++- 11 files changed, 256 insertions(+), 26 deletions(-) diff --git a/crates/engine/src/cmd/design.rs b/crates/engine/src/cmd/design.rs index be051b18..b258a5f9 100644 --- a/crates/engine/src/cmd/design.rs +++ b/crates/engine/src/cmd/design.rs @@ -65,6 +65,24 @@ pub fn specs() -> Vec { in_handout, handout_placeholders ), + cmd!( + "view.notesMaster", + "Notes Master", + ["View", "Master Views"], + None, + "{} (creates the notes master with header, date, slide image, notes body, footer and page number if the deck has none)", + has_doc, + notes_view + ), + cmd!( + "notes.placeholders", + "Notes Placeholders", + ["Notes Master", "Placeholders"], + None, + "{header?, date?, slideImage?, body?, footer?, pageNumber?: bool} (show or hide each)", + in_notes_master, + notes_placeholders + ), cmd!(noundo "view.closeMaster", "Close Master View", ["Slide Master", "Close"], None, "{}", has_doc, close_master), cmd!("master.insertLayout", "Insert Layout", ["Slide Master", "Edit Master"], None, "{name?}", has_doc, insert_layout), cmd!("master.renameLayout", "Rename Layout", ["Slide Master", "Edit Master"], None, "{name, master?, layout?}", has_doc, rename_layout), @@ -100,7 +118,7 @@ fn theme(s: &mut Session, p: &Value) -> Result { fn master_index(doc: &Presentation, sel: &crate::Selection) -> usize { match sel.target { Target::Master { master } | Target::Layout { master, .. } => master, - Target::Slides | Target::Handout => { + Target::Slides | Target::Handout | Target::NotesMaster => { doc.slides.get(sel.slide).and_then(|s| doc.masters.iter().position(|m| m.layout(s.layout).is_some())).unwrap_or(0) } } @@ -216,8 +234,8 @@ fn background(s: &mut Session, p: &Value) -> Result { l.background = bg.clone(); } } - Target::Handout => { - if let Some(m) = doc.handout_master.as_mut() { + t @ (Target::Handout | Target::NotesMaster) => { + if let Some(m) = crate::page_master_mut(doc, t) { Arc::make_mut(m).background = bg.clone(); } } @@ -554,6 +572,110 @@ fn handout_placeholders(s: &mut Session, p: &Value) -> Result { Ok(json!({"header": shown(PhType::Header), "date": shown(PhType::Date), "footer": shown(PhType::Footer), "pageNumber": shown(PhType::SlideNum)})) } +fn in_notes_master(s: &Session) -> std::result::Result<(), String> { + if s.active().is_some_and(|d| d.selection.target == Target::NotesMaster) { Ok(()) } else { Err("open View ▸ Notes Master first".into()) } +} + +const NOTES_KINDS: [(&str, PhType); 6] = [ + ("header", PhType::Header), + ("date", PhType::Date), + ("slideImage", PhType::SlideImage), + ("body", PhType::Body), + ("footer", PhType::Footer), + ("pageNumber", PhType::SlideNum), +]; + +/// A notes-master placeholder in a page of `size`, laid out as PowerPoint's default notes master. +pub fn notes_placeholder(kind: PhType, size: Size, id: ShapeId) -> Shape { + let (w, h) = (size.width, size.height); + match kind { + PhType::SlideImage => Shape { + id, + name: "Slide Image Placeholder 3".into(), + xfrm: Some(deckcraft_geom::Xfrm::new(w * 0.125, h * 0.125, w * 0.75, h * 0.375)), + ph: Some(deckcraft_model::Placeholder { kind, idx: 2, ..Default::default() }), + fill: Some(Fill::None), + line: Some(deckcraft_model::Line::solid(deckcraft_model::ColorRef::rgb(deckcraft_color::Rgba::BLACK), 1.0)), + ..Default::default() + }, + PhType::Body => { + let mut text = TextBody::default(); + text.paragraphs = vec![deckcraft_model::text::Paragraph::new("Click to edit Master text styles")]; + Shape { + id, + name: "Notes Placeholder 4".into(), + xfrm: Some(deckcraft_geom::Xfrm::new(w * 0.1, h * 0.53, w * 0.8, h * 0.36)), + ph: Some(deckcraft_model::Placeholder { kind, idx: 3, size: Some("quarter".into()), ..Default::default() }), + text: Some(text), + ..Default::default() + } + } + _ => { + let mut s = handout_placeholder(kind, size, id); + if let Some(ph) = s.ph.as_mut() { + ph.idx = match kind { + PhType::Header => 0, + PhType::Date => 1, + PhType::Footer => 4, + _ => 5, + }; + } + s + } + } +} + +fn notes_view(s: &mut Session, _p: &Value) -> Result { + let created = s.doc()?.doc.notes_master.as_ref().is_none_or(|m| m.shapes.is_empty()); + if created { + s.edit(|doc, _| { + let mut m = doc.notes_master.as_deref().cloned().unwrap_or_else(|| deckcraft_model::Master { + name: "Notes Master".into(), + theme: doc.masters.first().map(|m| m.theme.clone()).unwrap_or_default(), + color_map: doc.masters.first().map(|m| m.color_map.clone()).unwrap_or_default(), + ..Default::default() + }); + let size = doc.notes_size; + for (_, k) in NOTES_KINDS { + let id = ShapeId(doc.alloc_id()); + m.shapes.push(notes_placeholder(k, size, id)); + } + doc.notes_master = Some(Arc::new(m)); + Ok(()) + })?; + } + s.select(|_, sel| { + sel.target = Target::NotesMaster; + sel.shapes.clear(); + sel.text = None; + })?; + Ok(json!({"created": created})) +} + +fn notes_placeholders(s: &mut Session, p: &Value) -> Result { + s.edit(|doc, _| { + let size = doc.notes_size; + let ids: Vec = NOTES_KINDS.iter().map(|_| ShapeId(doc.alloc_id())).collect(); + let m = Arc::make_mut(doc.notes_master.as_mut().ok_or_else(|| bad("notes.placeholders", "no notes master"))?); + for ((key, kind), id) in NOTES_KINDS.into_iter().zip(ids) { + let Some(on) = p.get(key).and_then(Value::as_bool) else { continue }; + let has = m.shapes.iter().any(|x| x.ph_type() == Some(kind)); + if on && !has { + m.shapes.push(notes_placeholder(kind, size, id)); + } else if !on { + m.shapes.retain(|x| x.ph_type() != Some(kind)); + } + } + Ok(()) + })?; + let m = s.doc()?.doc.notes_master.clone(); + let mut out = serde_json::Map::new(); + for (key, kind) in NOTES_KINDS { + out.insert(key.into(), json!(m.as_ref().is_some_and(|m| m.shapes.iter().any(|x| x.ph_type() == Some(kind))))); + } + Ok(Value::Object(out)) +} + fn close_master(s: &mut Session, _p: &Value) -> Result { s.select(|_, sel| { sel.target = Target::Slides; diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 7864f0fa..ce482ddf 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -274,7 +274,9 @@ fn ctx_xfrm(doc: &deckcraft_model::Presentation, sel: &crate::Selection, shape: match sel.target { crate::Target::Slides => doc.slides.get(sel.slide).and_then(|s| Ctx::for_slide(doc, s)).map(|c| resolve::xfrm(&c, shape)), crate::Target::Master { master } => doc.masters.get(master).map(|m| resolve::xfrm(&Ctx::for_master(doc, m), shape)), - crate::Target::Handout => doc.handout_master.as_ref().map(|m| resolve::xfrm(&Ctx::for_master(doc, m), shape)), + t @ (crate::Target::Handout | crate::Target::NotesMaster) => { + crate::page_master(doc, t).map(|m| resolve::xfrm(&Ctx::for_master(doc, m), shape)) + } crate::Target::Layout { master, layout } => { doc.masters.get(master).and_then(|m| m.layouts.get(layout).map(|l| resolve::xfrm(&Ctx::for_layout(doc, m, l), shape))) } @@ -328,7 +330,7 @@ pub(crate) fn current_shapes<'a>(doc: &'a deckcraft_model::Presentation, sel: &c match sel.target { crate::Target::Slides => doc.slides.get(sel.slide).map(|s| &s.shapes), crate::Target::Master { master } => doc.masters.get(master).map(|m| &m.shapes), - crate::Target::Handout => doc.handout_master.as_ref().map(|m| &m.shapes), + t @ (crate::Target::Handout | crate::Target::NotesMaster) => crate::page_master(doc, t).map(|m| &m.shapes), crate::Target::Layout { master, layout } => doc.masters.get(master).and_then(|m| m.layouts.get(layout)).map(|l| &l.shapes), } } diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index 2d642f1d..f644481a 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -153,7 +153,7 @@ pub(crate) fn ctx_for<'a>(doc: &'a Presentation, sel: &Selection) -> Option doc.slides.get(sel.slide).and_then(|s| Ctx::for_slide(doc, s)), Target::Master { master } => doc.masters.get(master).map(|m| Ctx::for_master(doc, m)), - Target::Handout => doc.handout_master.as_ref().map(|m| Ctx::for_master(doc, m)), + t @ (Target::Handout | Target::NotesMaster) => crate::page_master(doc, t).map(|m| Ctx::for_master(doc, m)), Target::Layout { master, layout } => doc.masters.get(master).and_then(|m| m.layouts.get(layout).map(|l| Ctx::for_layout(doc, m, l))), } } diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 51d0460a..63b435e7 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -103,6 +103,32 @@ pub enum Target { }, /// The handout master (View ▸ Handout Master), a page at the notes size. Handout, + /// The notes master (View ▸ Notes Master), a page at the notes size. + NotesMaster, +} + +impl Target { + /// A notes-size master page (Handout or Notes Master view). + pub fn is_page_master(self) -> bool { + matches!(self, Target::Handout | Target::NotesMaster) + } +} + +/// The handout or notes master edited in that view. +pub fn page_master(doc: &Presentation, t: Target) -> Option<&Arc> { + match t { + Target::Handout => doc.handout_master.as_ref(), + Target::NotesMaster => doc.notes_master.as_ref(), + _ => None, + } +} + +pub fn page_master_mut(doc: &mut Presentation, t: Target) -> Option<&mut Arc> { + match t { + Target::Handout => doc.handout_master.as_mut(), + Target::NotesMaster => doc.notes_master.as_mut(), + _ => None, + } } #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] @@ -215,7 +241,7 @@ impl DocState { match self.selection.target { Target::Slides => self.current_slide().map(|s| s.shapes.as_slice()).unwrap_or(&[]), Target::Master { master } => self.doc.masters.get(master).map(|m| m.shapes.as_slice()).unwrap_or(&[]), - Target::Handout => self.doc.handout_master.as_ref().map(|m| m.shapes.as_slice()).unwrap_or(&[]), + Target::Handout | Target::NotesMaster => page_master(&self.doc, self.selection.target).map(|m| m.shapes.as_slice()).unwrap_or(&[]), Target::Layout { master, layout } => { self.doc.masters.get(master).and_then(|m| m.layouts.get(layout)).map(|l| l.shapes.as_slice()).unwrap_or(&[]) } @@ -231,13 +257,13 @@ impl DocState { /// The page being edited: the slide size, or the notes size in Handout Master view. pub fn page_size(st: &DocState) -> deckcraft_geom::Size { - if st.selection.target == Target::Handout { st.doc.notes_size } else { st.doc.slide_size } + if st.selection.target.is_page_master() { st.doc.notes_size } else { st.doc.slide_size } } -/// The handout master as a one-master presentation at the handout page size, for drawing it with -/// the layout renderer. -pub fn handout_page(doc: &Presentation) -> Option { - let m = doc.handout_master.clone()?; +/// The handout or notes master as a one-master presentation at the notes page size, for drawing +/// it with the layout renderer. +pub fn master_page(doc: &Presentation, t: Target) -> Option { + let m = page_master(doc, t)?.clone(); let mut p = doc.clone(); p.slide_size = doc.notes_size; p.masters = vec![m]; @@ -250,7 +276,7 @@ pub fn shapes_mut<'a>(doc: &'a mut Presentation, sel: &Selection) -> Option<&'a match sel.target { Target::Slides => doc.slides.get_mut(sel.slide).map(|s| &mut Arc::make_mut(s).shapes), Target::Master { master } => doc.masters.get_mut(master).map(|m| &mut Arc::make_mut(m).shapes), - Target::Handout => doc.handout_master.as_mut().map(|m| &mut Arc::make_mut(m).shapes), + t @ (Target::Handout | Target::NotesMaster) => page_master_mut(doc, t).map(|m| &mut Arc::make_mut(m).shapes), Target::Layout { master, layout } => { doc.masters.get_mut(master).and_then(|m| Arc::make_mut(m).layouts.get_mut(layout)).map(|l| &mut l.shapes) } @@ -557,7 +583,7 @@ fn clamp_selection(st: &mut DocState) { st.selection.slides = slides; match st.selection.target { Target::Master { master } if master >= st.doc.masters.len() => st.selection.target = Target::Slides, - Target::Handout if st.doc.handout_master.is_none() => st.selection.target = Target::Slides, + t @ (Target::Handout | Target::NotesMaster) if page_master(&st.doc, t).is_none() => st.selection.target = Target::Slides, Target::Layout { master, layout } if st.doc.masters.get(master).is_none_or(|m| layout >= m.layouts.len()) => { st.selection.target = Target::Slides } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index e5c06297..d76d4be1 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -862,7 +862,7 @@ fn handout_master_view_edits_and_round_trips() { s.execute("shape.insert", &json!({"preset": "rect", "rect": [20, 300, 100, 40]})).unwrap(); assert_eq!(s.doc().unwrap().doc.handout_master.as_ref().unwrap().shapes.len(), 4); assert_eq!(s.doc().unwrap().doc.slides[0].shapes.len(), slide_shapes); - assert!(crate::handout_page(&s.doc().unwrap().doc).is_some_and(|p| p.masters.len() == 1 && p.slide_size == p.notes_size)); + assert!(crate::master_page(&s.doc().unwrap().doc, Target::Handout).is_some_and(|p| p.masters.len() == 1 && p.slide_size == p.notes_size)); s.execute("view.closeMaster", &json!({})).unwrap(); assert_eq!(s.doc().unwrap().selection.target, Target::Slides); // Reopening doesn't make a second master, and it survives PPTX. @@ -873,3 +873,33 @@ fn handout_master_view_edits_and_round_trips() { assert_eq!(hm.shapes.len(), 4); assert!(hm.shapes.iter().any(|x| x.ph_type() == Some(PhType::SlideNum))); } + +#[test] +fn notes_master_view_edits_and_saves() { + use deckcraft_model::PhType; + let mut s = Session::with_new(); + assert!(s.execute("notes.placeholders", &json!({"header": false})).is_err(), "only in the view"); + assert_eq!(s.execute("view.notesMaster", &json!({})).unwrap()["created"], true); + let st = s.doc().unwrap(); + assert_eq!(st.selection.target, Target::NotesMaster); + assert_eq!(crate::page_size(st), st.doc.notes_size); + let kinds: Vec<_> = st.shapes().iter().filter_map(|x| x.ph_type()).collect(); + assert_eq!(kinds, [PhType::Header, PhType::Date, PhType::SlideImage, PhType::Body, PhType::Footer, PhType::SlideNum]); + let r = s.execute("notes.placeholders", &json!({"header": false, "date": false})).unwrap(); + assert_eq!((r["header"].as_bool(), r["body"].as_bool()), (Some(false), Some(true))); + assert_eq!(s.doc().unwrap().shapes().len(), 4); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().shapes().len(), 6); + s.execute("notes.placeholders", &json!({"header": false})).unwrap(); + // A shape added in the view is on the notes master and is saved, even with no notes in the deck. + s.execute("shape.insert", &json!({"preset": "ellipse", "rect": [20, 20, 60, 60]})).unwrap(); + assert!(s.doc().unwrap().doc.slides.iter().all(|sl| sl.notes.is_empty())); + assert!(crate::master_page(&s.doc().unwrap().doc, Target::NotesMaster).is_some()); + let bytes = deckcraft_pptx::export(&s.doc().unwrap().doc).unwrap(); + let back = deckcraft_pptx::import(&bytes).unwrap(); + let nm = back.notes_master.expect("notes master written"); + assert_eq!(nm.shapes.len(), 6, "{:?}", nm.shapes.iter().map(|x| &x.name).collect::>()); + assert!(!nm.shapes.iter().any(|x| x.ph_type() == Some(PhType::Header))); + s.execute("view.closeMaster", &json!({})).unwrap(); + assert_eq!(s.execute("view.notesMaster", &json!({})).unwrap()["created"], false); +} diff --git a/crates/pptx/src/write/mod.rs b/crates/pptx/src/write/mod.rs index f6e6c282..4c13113f 100644 --- a/crates/pptx/src/write/mod.rs +++ b/crates/pptx/src/write/mod.rs @@ -439,7 +439,9 @@ pub fn export(p: &Presentation) -> Result, PptxError> { let has_notes = p.slides.iter().any(|s| !s.notes.is_empty()); let notes_master = "ppt/notesMasters/notesMaster1.xml"; let mut notes_master_rid = None; - if has_notes { + // A notes master edited in Notes Master view is written as it is; otherwise the default one. + let kept_notes_master = p.notes_master.as_ref().filter(|m| !m.shapes.is_empty()); + if has_notes || kept_notes_master.is_some() { let tname = format!("ppt/theme/theme{}.xml", p.masters.len() + 1); let mut no = Out::new(notes_master); no.rels.add("theme", &opc::relative(notes_master, &tname), false); @@ -451,8 +453,16 @@ pub fn export(p: &Presentation) -> Result, PptxError> { let mut w = W::new(); root_open(&mut w, "p:notesMaster", A::new()); let bg = Background::Ref { idx: 1001, color: deckcraft_model::ColorRef::scheme(deckcraft_color::SchemeSlot::Bg1) }; - csld(&mut w, &mut x, &mut no, "", Some(&bg), ¬es_master_shapes(p.notes_size)); - clr_map(&mut w, &[]); + match kept_notes_master { + Some(m) => { + csld(&mut w, &mut x, &mut no, "", Some(m.background.as_ref().unwrap_or(&bg)), &m.shapes); + clr_map(&mut w, &m.color_map); + } + None => { + csld(&mut w, &mut x, &mut no, "", Some(&bg), ¬es_master_shapes(p.notes_size)); + clr_map(&mut w, &[]); + } + } dml::list_style(&mut w, &mut x, &mut no, "p:notesStyle", ¬es_style()); w.close("p:notesMaster"); x.pkg.xml_part(notes_master, CT_NOTES_MASTER, w.finish()); diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 5e9a7aa8..0d5166e1 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -124,7 +124,9 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) { } Some((Target::Master { master }, d)) => master_tex(ui.ctx(), &d.doc, master, None, px, visible), Some((Target::Layout { master, layout }, d)) => master_tex(ui.ctx(), &d.doc, master, Some(layout), px, visible), - Some((Target::Handout, d)) => deckcraft_engine::handout_page(&d.doc).and_then(|p| master_tex(ui.ctx(), &p, 0, None, px, visible)), + Some((t @ (Target::Handout | Target::NotesMaster), d)) => { + deckcraft_engine::master_page(&d.doc, t).and_then(|p| master_tex(ui.ctx(), &p, 0, None, px, visible)) + } None => None, }; if let Some((tex, region)) = tex { diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index b251a2f1..c1432b01 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -191,6 +191,7 @@ pub fn menu_tree() -> Vec<(&'static str, Vec<(&'static str, &'static str)>)> { ("-", ""), ("Slide Master", "view.slideMaster"), ("Handout Master", "view.handoutMaster"), + ("Notes Master", "view.notesMaster"), ("-", ""), ("Ruler", "view.ruler"), ("Gridlines", "view.gridlines"), diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index ee5c3391..74cf3d24 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -108,6 +108,8 @@ fn contextual(app: &SlideApp) -> Vec<&'static str> { let Some(st) = app.session.active() else { return v }; if st.selection.target == Target::Handout { v.push("Handout Master"); + } else if st.selection.target == Target::NotesMaster { + v.push("Notes Master"); } else if st.selection.target != Target::Slides { v.push("Slide Master"); } @@ -225,6 +227,7 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) { "Playback" => playback(app, ui), "Slide Master" => slide_master(app, ui), "Handout Master" => handout_master(app, ui), + "Notes Master" => notes_master(app, ui), _ => {} } }); @@ -1829,8 +1832,10 @@ fn view(app: &mut SlideApp, ui: &mut Ui) { if big_button_ex(ui, Icon::HandoutMaster, "Handout\nMaster", true, false, in_handout).0.clicked() { let _ = app.run(if in_handout { "view.closeMaster" } else { "view.handoutMaster" }, json!({})); } - let r = big_button(ui, Icon::NotesMaster, "Notes\nMaster", false); - let _ = r.on_hover_text("Notes Master (coming soon)"); + let in_notes = app.session.active().is_some_and(|d| d.selection.target == Target::NotesMaster); + if big_button_ex(ui, Icon::NotesMaster, "Notes\nMaster", true, false, in_notes).0.clicked() { + let _ = app.run(if in_notes { "view.closeMaster" } else { "view.notesMaster" }, json!({})); + } }); group(ui, |ui| { rows(ui, |ui| { @@ -2413,6 +2418,36 @@ fn slide_master(app: &mut SlideApp, ui: &mut Ui) { }); } +fn notes_master(app: &mut SlideApp, ui: &mut Ui) { + group(ui, |ui| { + use deckcraft_model::PhType; + let shown = |app: &SlideApp, k: PhType| { + app.session.active().and_then(|d| d.doc.notes_master.as_ref()).is_some_and(|m| m.shapes.iter().any(|s| s.ph_type() == Some(k))) + }; + for col in [ + [("Header", "header", PhType::Header), ("Slide Image", "slideImage", PhType::SlideImage), ("Footer", "footer", PhType::Footer)], + [("Date", "date", PhType::Date), ("Body", "body", PhType::Body), ("Page Number", "pageNumber", PhType::SlideNum)], + ] { + rows(ui, |ui| { + for (label, key, k) in col { + let mut on = shown(app, k); + if ui.checkbox(&mut on, label).changed() { + run(app, "notes.placeholders", json!({ key: on })); + } + } + }); + } + }); + group(ui, |ui| { + big(app, ui, Icon::FormatBackground, "Background\nStyles", "view.pane", json!({"pane": "background"})); + }); + group(ui, |ui| { + if big_button(ui, Icon::Close, "Close\nMaster View", true).clicked() { + run(app, "view.closeMaster", json!({})); + } + }); +} + fn handout_master(app: &mut SlideApp, ui: &mut Ui) { group(ui, |ui| { let r = big_button(ui, Icon::Layout, &format!("Slides Per\nPage: {}", app.ui.handout_per_page), true); diff --git a/crates/ui-egui/src/thumbs.rs b/crates/ui-egui/src/thumbs.rs index 2fbc43db..067a00c6 100644 --- a/crates/ui-egui/src/thumbs.rs +++ b/crates/ui-egui/src/thumbs.rs @@ -12,7 +12,7 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); let in_master = app.session.active().is_some_and(|d| d.selection.target != Target::Slides); // Handout Master view has one page and no thumbnails, as in PowerPoint. - if app.session.active().is_some_and(|d| d.selection.target == Target::Handout) { + if app.session.active().is_some_and(|d| d.selection.target.is_page_master()) { return; } egui::Panel::left("thumbs") diff --git a/docs/gaps.md b/docs/gaps.md index e440b77a..f9a216ac 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -161,10 +161,11 @@ Detail in [file-format-parity.md](file-format-parity.md). Detail in [animation-parity.md](animation-parity.md). 10–16 h; the pane is a **beta blocker**. ### Notes and Handout masters -- **Evidence:** View ▸ Handout Master edits the handout master (header, date, footer and page - number placeholders, background, slides-per-page preview) and PPTX keeps it. There's no Notes - Master view yet, and PDF handouts don't use the handout master's text. -- **Estimate:** 2–3 h (Notes Master view), 1 h (handout text in PDF). +- **Evidence:** View ▸ Handout Master and View ▸ Notes Master edit both masters (placeholders, + background, a slides-per-page preview for handouts) and PPTX keeps them. The notes master's slide + image is an empty frame rather than a slide preview, and notes pages and PDF handouts don't take + their layout and text from the masters yet. +- **Estimate:** 2–3 h (notes pages and PDF handouts follow the masters). ### Edit points, text warp, 3-D - **Evidence:** checklist rows "Edit points" (P1 missing), "Text warp transforms" (partial: all 40 @@ -258,6 +259,7 @@ Detail in [localization-parity.md](localization-parity.md). | Date | Change | Summary | |---|---|---| +| 2026-10-14 | minor | Notes Master view: the masters gap is now notes pages and PDF handouts following the masters | | 2026-10-14 | minor | Handout Master view: the masters gap is now the Notes Master view and handout text in PDF | | 2026-10-13 | minor | Owned PPTX corpus for tables, charts, animations, media, sections and masters. Handout-master text is kept; the corpus gap is now a larger set and PowerPoint reference renders | | 2026-10-13 | minor | Shape, picture and table rendering checked against PowerPoint on ~150 synthetic edge-case slides; 3-D bevels, cameras and face lighting now render approximately (gap narrowed to extrusion, rigs and the 3-D pane) |