mirror of
https://github.com/storytold/deckcraft.git
synced 2026-10-26 03:27:03 -06:00
Add the Handout Master view
View ▸ Handout Master opens the handout master (creating one with header, date, footer and page-number placeholders if the deck has none) on a page at the notes size, with a dashed preview of where 1-9 slides go. Its contextual tab toggles the placeholders, sets the background and closes the view. Stacked on #533, which reads and writes the handout master.
This commit is contained in:
@@ -47,6 +47,24 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
header_footer
|
||||
),
|
||||
cmd!(noundo "view.slideMaster", "Slide Master", ["View", "Master Views"], None, "{master?: index, layout?: index}", has_doc, master_view),
|
||||
cmd!(
|
||||
"view.handoutMaster",
|
||||
"Handout Master",
|
||||
["View", "Master Views"],
|
||||
None,
|
||||
"{} (creates the handout master with header, date, footer and page number if the deck has none)",
|
||||
has_doc,
|
||||
handout_view
|
||||
),
|
||||
cmd!(
|
||||
"handout.placeholders",
|
||||
"Handout Placeholders",
|
||||
["Handout Master", "Placeholders"],
|
||||
None,
|
||||
"{header?: bool, date?: bool, footer?: bool, pageNumber?: bool} (show or hide each)",
|
||||
in_handout,
|
||||
handout_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),
|
||||
@@ -82,7 +100,9 @@ fn theme(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
fn master_index(doc: &Presentation, sel: &crate::Selection) -> usize {
|
||||
match sel.target {
|
||||
Target::Master { master } | Target::Layout { master, .. } => master,
|
||||
Target::Slides => doc.slides.get(sel.slide).and_then(|s| doc.masters.iter().position(|m| m.layout(s.layout).is_some())).unwrap_or(0),
|
||||
Target::Slides | Target::Handout => {
|
||||
doc.slides.get(sel.slide).and_then(|s| doc.masters.iter().position(|m| m.layout(s.layout).is_some())).unwrap_or(0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -196,6 +216,11 @@ fn background(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
l.background = bg.clone();
|
||||
}
|
||||
}
|
||||
Target::Handout => {
|
||||
if let Some(m) = doc.handout_master.as_mut() {
|
||||
Arc::make_mut(m).background = bg.clone();
|
||||
}
|
||||
}
|
||||
Target::Slides => {
|
||||
let list: Vec<usize> = if all { (0..doc.slides.len()).collect() } else { vec![index.unwrap_or(sel.slide)] };
|
||||
for i in list {
|
||||
@@ -446,6 +471,89 @@ fn master_view(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(serde_json::to_value(target).unwrap_or_default())
|
||||
}
|
||||
|
||||
fn in_handout(s: &Session) -> std::result::Result<(), String> {
|
||||
if s.active().is_some_and(|d| d.selection.target == Target::Handout) { Ok(()) } else { Err("open View ▸ Handout Master first".into()) }
|
||||
}
|
||||
|
||||
/// The four handout-master placeholders PowerPoint offers, in a page of `size`.
|
||||
pub fn handout_placeholder(kind: PhType, size: Size, id: ShapeId) -> Shape {
|
||||
let (w, h) = (size.width, size.height);
|
||||
let (name, idx, x, y) = match kind {
|
||||
PhType::Header => ("Header Placeholder", 0, 0.0, 0.0),
|
||||
PhType::Date => ("Date Placeholder", 1, w * 0.57, 0.0),
|
||||
PhType::Footer => ("Footer Placeholder", 2, 0.0, h * 0.95),
|
||||
_ => ("Slide Number Placeholder", 3, w * 0.57, h * 0.95),
|
||||
};
|
||||
let mut text = TextBody::default();
|
||||
if let Some(p) = text.paragraphs.first_mut()
|
||||
&& matches!(kind, PhType::Date | PhType::SlideNum)
|
||||
{
|
||||
p.props.align = Some(deckcraft_model::text::Align::Right);
|
||||
}
|
||||
Shape {
|
||||
id,
|
||||
name: format!("{name} {}", idx + 1),
|
||||
xfrm: Some(deckcraft_geom::Xfrm::new(x, y, w * 0.43, h * 0.05)),
|
||||
ph: Some(deckcraft_model::Placeholder { kind, idx, size: Some("quarter".into()), ..Default::default() }),
|
||||
text: Some(text),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
const HANDOUT_KINDS: [(&str, PhType); 4] =
|
||||
[("header", PhType::Header), ("date", PhType::Date), ("footer", PhType::Footer), ("pageNumber", PhType::SlideNum)];
|
||||
|
||||
fn handout_view(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
let created = s.doc()?.doc.handout_master.is_none();
|
||||
if created {
|
||||
s.edit(|doc, _| {
|
||||
let mut m = deckcraft_model::Master {
|
||||
name: "Handout 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 HANDOUT_KINDS {
|
||||
let id = ShapeId(doc.alloc_id());
|
||||
m.shapes.push(handout_placeholder(k, size, id));
|
||||
}
|
||||
doc.handout_master = Some(Arc::new(m));
|
||||
Ok(())
|
||||
})?;
|
||||
}
|
||||
s.select(|_, sel| {
|
||||
sel.target = Target::Handout;
|
||||
sel.shapes.clear();
|
||||
sel.text = None;
|
||||
})?;
|
||||
Ok(json!({"created": created}))
|
||||
}
|
||||
|
||||
fn handout_placeholders(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
s.edit(|doc, _| {
|
||||
let size = doc.notes_size;
|
||||
let mut ids = vec![];
|
||||
for (key, _) in HANDOUT_KINDS {
|
||||
ids.push(p.get(key).and_then(Value::as_bool).map(|_| ShapeId(doc.alloc_id())));
|
||||
}
|
||||
let m = Arc::make_mut(doc.handout_master.as_mut().ok_or_else(|| bad("handout.placeholders", "no handout master"))?);
|
||||
for ((key, kind), id) in HANDOUT_KINDS.into_iter().zip(ids) {
|
||||
let (Some(on), Some(id)) = (p.get(key).and_then(Value::as_bool), id) else { continue };
|
||||
let has = m.shapes.iter().any(|x| x.ph_type() == Some(kind));
|
||||
if on && !has {
|
||||
m.shapes.push(handout_placeholder(kind, size, id));
|
||||
} else if !on {
|
||||
m.shapes.retain(|x| x.ph_type() != Some(kind));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
let m = s.doc()?.doc.handout_master.clone();
|
||||
let shown = |k: PhType| m.as_ref().is_some_and(|m| m.shapes.iter().any(|x| x.ph_type() == Some(k)));
|
||||
Ok(json!({"header": shown(PhType::Header), "date": shown(PhType::Date), "footer": shown(PhType::Footer), "pageNumber": shown(PhType::SlideNum)}))
|
||||
}
|
||||
|
||||
fn close_master(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
s.select(|_, sel| {
|
||||
sel.target = Target::Slides;
|
||||
|
||||
@@ -274,6 +274,7 @@ 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)),
|
||||
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)))
|
||||
}
|
||||
@@ -327,6 +328,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),
|
||||
crate::Target::Layout { master, layout } => doc.masters.get(master).and_then(|m| m.layouts.get(layout)).map(|l| &l.shapes),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -153,6 +153,7 @@ pub(crate) fn ctx_for<'a>(doc: &'a Presentation, sel: &Selection) -> Option<Ctx<
|
||||
match sel.target {
|
||||
Target::Slides => 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)),
|
||||
Target::Layout { master, layout } => doc.masters.get(master).and_then(|m| m.layouts.get(layout).map(|l| Ctx::for_layout(doc, m, l))),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ pub use cmd::{CommandInfo, CommandSpec, command_specs, find_command};
|
||||
pub use deckcraft_geom as geom;
|
||||
pub use deckcraft_media as media;
|
||||
pub use deckcraft_model as model;
|
||||
pub use deckcraft_pdf as pdf;
|
||||
pub use deckcraft_render as render;
|
||||
pub use tools::{Mods, PointerEvent, PointerKind, ToolKind};
|
||||
|
||||
@@ -100,6 +101,8 @@ pub enum Target {
|
||||
master: usize,
|
||||
layout: usize,
|
||||
},
|
||||
/// The handout master (View ▸ Handout Master), a page at the notes size.
|
||||
Handout,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
@@ -212,6 +215,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::Layout { master, layout } => {
|
||||
self.doc.masters.get(master).and_then(|m| m.layouts.get(layout)).map(|l| l.shapes.as_slice()).unwrap_or(&[])
|
||||
}
|
||||
@@ -225,11 +229,28 @@ 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 }
|
||||
}
|
||||
|
||||
/// 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<Presentation> {
|
||||
let m = doc.handout_master.clone()?;
|
||||
let mut p = doc.clone();
|
||||
p.slide_size = doc.notes_size;
|
||||
p.masters = vec![m];
|
||||
p.slides.clear();
|
||||
Some(p)
|
||||
}
|
||||
|
||||
/// Mutable access to the edited shape tree of a presentation.
|
||||
pub fn shapes_mut<'a>(doc: &'a mut Presentation, sel: &Selection) -> Option<&'a mut Vec<Shape>> {
|
||||
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),
|
||||
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)
|
||||
}
|
||||
@@ -536,6 +557,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,
|
||||
Target::Layout { master, layout } if st.doc.masters.get(master).is_none_or(|m| layout >= m.layouts.len()) => {
|
||||
st.selection.target = Target::Slides
|
||||
}
|
||||
|
||||
@@ -835,3 +835,41 @@ fn slide_ranges_parse_like_powerpoints_print_box() {
|
||||
let opts = cmd::file::pdf_options(&json!({"slides": [0, 7, 1]}), 2).unwrap();
|
||||
assert_eq!(opts.slides, Some(vec![0, 1]), "indices past the end are dropped");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handout_master_view_edits_and_round_trips() {
|
||||
use deckcraft_model::PhType;
|
||||
let mut s = Session::with_new();
|
||||
assert!(s.doc().unwrap().doc.handout_master.is_none());
|
||||
assert!(s.execute("handout.placeholders", &json!({"footer": false})).is_err(), "only in the view");
|
||||
let r = s.execute("view.handoutMaster", &json!({})).unwrap();
|
||||
assert_eq!(r["created"], true);
|
||||
let st = s.doc().unwrap();
|
||||
assert_eq!(st.selection.target, Target::Handout);
|
||||
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::Footer, PhType::SlideNum]);
|
||||
// Hide the footer, then show it again; undo restores the hidden state.
|
||||
let r = s.execute("handout.placeholders", &json!({"footer": false})).unwrap();
|
||||
assert_eq!(r["footer"], false);
|
||||
assert_eq!(s.doc().unwrap().shapes().len(), 3);
|
||||
s.execute("handout.placeholders", &json!({"footer": true})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().shapes().len(), 4);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().shapes().len(), 3);
|
||||
// Shapes drawn in the view land on the handout master, not the slide.
|
||||
let slide_shapes = s.doc().unwrap().doc.slides[0].shapes.len();
|
||||
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));
|
||||
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.
|
||||
assert_eq!(s.execute("view.handoutMaster", &json!({})).unwrap()["created"], false);
|
||||
let bytes = deckcraft_pptx::export(&s.doc().unwrap().doc).unwrap();
|
||||
let back = deckcraft_pptx::import(&bytes).unwrap();
|
||||
let hm = back.handout_master.expect("handout master kept");
|
||||
assert_eq!(hm.shapes.len(), 4);
|
||||
assert!(hm.shapes.iter().any(|x| x.ph_type() == Some(PhType::SlideNum)));
|
||||
}
|
||||
|
||||
@@ -38,7 +38,8 @@ fn mods(ui: &Ui) -> Mods {
|
||||
/// The slide's rect on screen for the current zoom.
|
||||
pub fn layout(app: &SlideApp, avail: Rect) -> (Rect, f32) {
|
||||
let Some(d) = app.session.active() else { return (avail, 1.0) };
|
||||
let (sw, sh) = (d.doc.slide_size.width as f32, d.doc.slide_size.height as f32);
|
||||
let page = deckcraft_engine::page_size(d);
|
||||
let (sw, sh) = (page.width as f32, page.height as f32);
|
||||
let margin = 28.0;
|
||||
let fit = ((avail.width() - margin * 2.0) / sw).min((avail.height() - margin * 2.0) / sh).max(0.05);
|
||||
let scale = app.ui.zoom.unwrap_or(fit);
|
||||
@@ -123,6 +124,7 @@ 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)),
|
||||
None => None,
|
||||
};
|
||||
if let Some((tex, region)) = tex {
|
||||
@@ -135,6 +137,24 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) {
|
||||
}
|
||||
app.perf.render_ms = app.textures.last_render_ms;
|
||||
media_frames(app, &painter, xf);
|
||||
if let Some(d) = app.session.active().filter(|d| d.selection.target == Target::Handout) {
|
||||
// Where the slides go on a printed handout, as in PowerPoint's preview.
|
||||
let (n, s) = (d.doc.notes_size, d.doc.slide_size);
|
||||
let aspect = s.width / s.height.max(1.0);
|
||||
for b in deckcraft_engine::pdf::handout_boxes(app.ui.handout_per_page, (n.width, n.height), aspect) {
|
||||
let r = Rect::from_min_max(
|
||||
pos2(slide_rect.min.x + b.x0 as f32 * scale, slide_rect.min.y + b.y0 as f32 * scale),
|
||||
pos2(slide_rect.min.x + b.x1 as f32 * scale, slide_rect.min.y + b.y1 as f32 * scale),
|
||||
);
|
||||
let dash = egui::Shape::dashed_line(
|
||||
&[r.left_top(), r.right_top(), r.right_bottom(), r.left_bottom(), r.left_top()],
|
||||
Stroke::new(1.0, Color32::from_gray(140)),
|
||||
4.0,
|
||||
3.0,
|
||||
);
|
||||
painter.extend(dash);
|
||||
}
|
||||
}
|
||||
if app.ui.gridlines {
|
||||
let step = 72.0 / 2.0 * scale;
|
||||
let mut x = slide_rect.min.x + step;
|
||||
|
||||
@@ -91,6 +91,8 @@ pub struct UiState {
|
||||
pub pane_width: f32,
|
||||
pub ruler: bool,
|
||||
pub gridlines: bool,
|
||||
/// Slides per page previewed in Handout Master view (1, 2, 3, 4, 6 or 9).
|
||||
pub handout_per_page: u8,
|
||||
pub guides: bool,
|
||||
pub smart_guides: bool,
|
||||
/// None = fit to window.
|
||||
@@ -118,6 +120,7 @@ impl Default for UiState {
|
||||
pane_width: 300.0,
|
||||
ruler: false,
|
||||
gridlines: false,
|
||||
handout_per_page: 6,
|
||||
guides: false,
|
||||
smart_guides: true,
|
||||
zoom: None,
|
||||
|
||||
@@ -190,6 +190,7 @@ pub fn menu_tree() -> Vec<(&'static str, Vec<(&'static str, &'static str)>)> {
|
||||
("Slide Show", "show.start"),
|
||||
("-", ""),
|
||||
("Slide Master", "view.slideMaster"),
|
||||
("Handout Master", "view.handoutMaster"),
|
||||
("-", ""),
|
||||
("Ruler", "view.ruler"),
|
||||
("Gridlines", "view.gridlines"),
|
||||
|
||||
@@ -106,7 +106,9 @@ pub fn title_bar(app: &mut SlideApp, ui: &mut Ui) {
|
||||
fn contextual(app: &SlideApp) -> Vec<&'static str> {
|
||||
let mut v = vec![];
|
||||
let Some(st) = app.session.active() else { return v };
|
||||
if st.selection.target != Target::Slides {
|
||||
if st.selection.target == Target::Handout {
|
||||
v.push("Handout Master");
|
||||
} else if st.selection.target != Target::Slides {
|
||||
v.push("Slide Master");
|
||||
}
|
||||
let shapes = st.selected_shapes();
|
||||
@@ -222,6 +224,7 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) {
|
||||
"Chart Design" => chart_design(app, ui),
|
||||
"Playback" => playback(app, ui),
|
||||
"Slide Master" => slide_master(app, ui),
|
||||
"Handout Master" => handout_master(app, ui),
|
||||
_ => {}
|
||||
}
|
||||
});
|
||||
@@ -1822,8 +1825,10 @@ fn view(app: &mut SlideApp, ui: &mut Ui) {
|
||||
if big_button_ex(ui, Icon::SlideMaster, "Slide\nMaster", true, false, in_master).0.clicked() {
|
||||
let _ = app.run(if in_master { "view.closeMaster" } else { "view.slideMaster" }, json!({}));
|
||||
}
|
||||
let r = big_button(ui, Icon::HandoutMaster, "Handout\nMaster", false);
|
||||
let _ = r.on_hover_text("Handout Master (coming soon)");
|
||||
let in_handout = app.session.active().is_some_and(|d| d.selection.target == Target::Handout);
|
||||
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)");
|
||||
});
|
||||
@@ -2408,6 +2413,46 @@ fn slide_master(app: &mut SlideApp, ui: &mut Ui) {
|
||||
});
|
||||
}
|
||||
|
||||
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);
|
||||
egui::Popup::menu(&r).show(|ui| {
|
||||
for n in [1u8, 2, 3, 4, 6, 9] {
|
||||
if ui.selectable_label(app.ui.handout_per_page == n, format!("{n} Slide{}", if n == 1 { "" } else { "s" })).clicked() {
|
||||
app.ui.handout_per_page = n;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
group(ui, |ui| {
|
||||
let shown = |app: &SlideApp, k: deckcraft_model::PhType| {
|
||||
app.session.active().and_then(|d| d.doc.handout_master.as_ref()).is_some_and(|m| m.shapes.iter().any(|s| s.ph_type() == Some(k)))
|
||||
};
|
||||
rows(ui, |ui| {
|
||||
use deckcraft_model::PhType;
|
||||
for (label, key, k) in [
|
||||
("Header", "header", PhType::Header),
|
||||
("Date", "date", PhType::Date),
|
||||
("Footer", "footer", PhType::Footer),
|
||||
("Page Number", "pageNumber", PhType::SlideNum),
|
||||
] {
|
||||
let mut on = shown(app, k);
|
||||
if ui.checkbox(&mut on, label).changed() {
|
||||
run(app, "handout.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!({}));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Gallery glyphs for the freeform tools: a wave (curve), a polygon (freeform), a squiggle (scribble).
|
||||
fn paint_freeform_icon(p: &egui::Painter, r: Rect, name: &str, ink: Color32) {
|
||||
let at = |x: f32, y: f32| egui::pos2(r.min.x + x * r.width(), r.min.y + y * r.height());
|
||||
|
||||
@@ -11,6 +11,10 @@ use crate::theme::{self, Tokens};
|
||||
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) {
|
||||
return;
|
||||
}
|
||||
egui::Panel::left("thumbs")
|
||||
.resizable(true)
|
||||
.default_size(app.ui.thumbs_width)
|
||||
|
||||
+5
-3
@@ -161,9 +161,10 @@ 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:** checklist row missing the editing view. PPTX writes a notes master, and a handout
|
||||
master's header, footer, date and slide number are kept. There is no view to edit either master.
|
||||
- **Estimate:** 4–6 h.
|
||||
- **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).
|
||||
|
||||
### Edit points, text warp, 3-D
|
||||
- **Evidence:** checklist rows "Edit points" (P1 missing), "Text warp transforms" (partial: all 40
|
||||
@@ -257,6 +258,7 @@ Detail in [localization-parity.md](localization-parity.md).
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 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) |
|
||||
| 2026-10-12 | minor | IME composition in slide text and notes, and CJK interface text with real glyphs (#101): the IME gap is now Korean on the web and table-cell underlines |
|
||||
|
||||
Reference in New Issue
Block a user