mirror of
https://github.com/storytold/deckcraft.git
synced 2026-10-29 21:16:57 -06:00
Merge PR #168 from Mathiasko: Notes: speaker notes show their formatting and keep it when edited
# Conflicts: # crates/engine/src/tests.rs # crates/model/src/edit.rs # crates/text/src/lib.rs # crates/ui-egui/src/lib.rs # crates/ui-egui/src/sorter.rs # crates/ui-egui/src/status.rs
This commit is contained in:
@@ -10,7 +10,7 @@ use crate::{Result, Session};
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(query "document.inspect", "Inspect Presentation", [], None, "{} → slides with titles, layouts, shape counts; selection; size; theme", has_doc, document),
|
||||
cmd!(query "slide.inspect", "Inspect Slide", [], None, "{index?} → the slide's shapes with ids, kinds, boxes, text, fills, animations, transition, notes", has_doc, slide),
|
||||
cmd!(query "slide.inspect", "Inspect Slide", [], None, "{index?} → the slide's shapes with ids, kinds, boxes, text, fills, animations, transition, notes (text, and notesBody with its formatting)", has_doc, slide),
|
||||
cmd!(query "shape.inspect", "Inspect Shape", [], None, "{id} → full shape JSON plus its effective box", has_doc, shape),
|
||||
cmd!(query "commands.list", "List Commands", [], None, "{filter?: substring} → [{id, label, params, enabled}]", always, commands),
|
||||
]
|
||||
@@ -129,6 +129,7 @@ fn slide(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
"transition": serde_json::to_value(&sl.transition).unwrap_or_default(),
|
||||
"animations": serde_json::to_value(&sl.animations).unwrap_or_default(),
|
||||
"notes": sl.notes_text(),
|
||||
"notesBody": sl.notes,
|
||||
"comments": sl.comments.len(),
|
||||
"hidden": sl.hidden,
|
||||
}))
|
||||
|
||||
@@ -32,7 +32,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
cmd!("slide.hide", "Hide Slide", ["Slide Show", "Set Up"], None, "{index?, hidden?: bool}", has_slide, hide),
|
||||
cmd!("slide.layout", "Layout", ["Home", "Slides"], None, "{layout: name or kind, index?}", has_slide, set_layout),
|
||||
cmd!("slide.reset", "Reset", ["Home", "Slides"], None, "{index?}", has_slide, reset),
|
||||
cmd!("slide.notes", "Notes", ["View"], None, "{text, index?}", has_slide, notes),
|
||||
cmd!(
|
||||
"slide.notes",
|
||||
"Notes",
|
||||
["View"],
|
||||
None,
|
||||
"{text | body: formatted notes (as in slide.inspect's notesBody), index?} (text as typed: untouched notes keep their formatting)",
|
||||
has_slide,
|
||||
notes
|
||||
),
|
||||
cmd!("slide.rename", "Rename Slide", [], None, "{name, index?}", has_slide, rename),
|
||||
cmd!("section.add", "Add Section", ["Home", "Slides", "Section"], None, "{name?, at?: slide index}", has_slide, section_add),
|
||||
cmd!("section.rename", "Rename Section", ["Home", "Slides", "Section"], None, "{index, name}", has_slide, section_rename),
|
||||
@@ -468,10 +476,20 @@ fn reset(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
fn notes(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let i = index_of(s, p)?;
|
||||
// Formatted notes as a whole (the notes editors), or text: then only what was retyped
|
||||
// changes, so bold, levels and bullets around it survive.
|
||||
let body = match p.get("body") {
|
||||
Some(b) => Some(serde_json::from_value::<TextBody>(b.clone()).map_err(|e| bad("slide.notes", format!("invalid `body`: {e}")))?),
|
||||
None => None,
|
||||
};
|
||||
let text = str_param(p, "text").unwrap_or("").to_string();
|
||||
s.edit(|doc, _| {
|
||||
if let Some(sl) = doc.slides.get_mut(i) {
|
||||
Arc::make_mut(sl).notes = TextBody::from_text(&text);
|
||||
let notes = &mut Arc::make_mut(sl).notes;
|
||||
match body {
|
||||
Some(b) => *notes = b,
|
||||
None => deckcraft_model::edit::replace_text(notes, &text),
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
|
||||
@@ -2828,3 +2828,46 @@ fn tables_with_a_file_defined_style_edit_and_resize() {
|
||||
assert_eq!(table_of(&s, id).cols, vec![230.0, 170.0]);
|
||||
assert_eq!(text_sel(&s).unwrap().cell, Some((1, 1)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editing_notes_keeps_their_formatting_through_pptx() {
|
||||
use deckcraft_model::text::{Bullet, ParaProps, Paragraph, Run, RunProps, TextBody};
|
||||
// A deck whose notes have a bold heading over indented bullet points, as saved by PowerPoint.
|
||||
let mut p = deckcraft_model::defaults::new_presentation(None);
|
||||
let bullet = ParaProps { margin_left: Some(22.5), indent: Some(-22.5), bullet: Some(Bullet::Char { char: "•".into() }), ..Default::default() };
|
||||
let point = |t: &str| Paragraph { level: 1, props: bullet.clone(), runs: vec![Run::new(t)], ..Default::default() };
|
||||
std::sync::Arc::make_mut(&mut p.slides[0]).notes = TextBody {
|
||||
paragraphs: vec![
|
||||
Paragraph { runs: vec![Run::with("Overview", RunProps { bold: Some(true), ..Default::default() })], ..Default::default() },
|
||||
point("First point"),
|
||||
point("Second point"),
|
||||
],
|
||||
..Default::default()
|
||||
};
|
||||
let bytes = deckcraft_pptx::export(&p).unwrap();
|
||||
let mut s = session();
|
||||
s.execute("file.openBytes", &json!({"name": "notes.pptx", "data": crate::cmd::base64_encode(&bytes)})).unwrap();
|
||||
// Type a word into the second bullet, as the notes pane does.
|
||||
s.execute("slide.notes", &json!({"index": 0, "text": "Overview\nFirst point\nSecond main point"})).unwrap();
|
||||
let check = |notes: &TextBody| {
|
||||
assert_eq!(notes.text(), "Overview\nFirst point\nSecond main point");
|
||||
assert_eq!(notes.paragraphs[0].runs[0].props.bold, Some(true));
|
||||
for para in ¬es.paragraphs[1..] {
|
||||
assert_eq!(para.level, 1);
|
||||
assert_eq!(para.props.margin_left, Some(22.5));
|
||||
assert_eq!(para.props.indent, Some(-22.5));
|
||||
assert_eq!(para.props.bullet, Some(Bullet::Char { char: "•".into() }));
|
||||
}
|
||||
};
|
||||
check(&s.doc().unwrap().doc.slides[0].notes);
|
||||
let saved = s.execute("file.saveBytes", &json!({"format": "pptx"})).unwrap()["data"].as_str().unwrap().to_string();
|
||||
let reopened = deckcraft_pptx::import(&crate::cmd::base64_decode(&saved).unwrap()).unwrap();
|
||||
check(&reopened.slides[0].notes);
|
||||
// slide.inspect hands out the formatted notes, and slide.notes {body} takes them back as they are.
|
||||
let body = s.execute("slide.inspect", &json!({"index": 0})).unwrap()["notesBody"].clone();
|
||||
s.execute("slide.notes", &json!({"index": 0, "text": "Plain"})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().doc.slides[0].notes, TextBody::from_text("Plain"), "new text shares nothing: plain");
|
||||
s.execute("slide.notes", &json!({"index": 0, "body": body})).unwrap();
|
||||
check(&s.doc().unwrap().doc.slides[0].notes);
|
||||
assert!(s.execute("slide.notes", &json!({"index": 0, "body": 7})).is_err());
|
||||
}
|
||||
|
||||
@@ -401,6 +401,44 @@ pub fn char_move(body: &TextBody, pos: Pos, forward: bool) -> Pos {
|
||||
}
|
||||
}
|
||||
|
||||
/// The position of the `n`th character of the body's plain text (`\n` between paragraphs).
|
||||
fn pos_of_char(body: &TextBody, mut n: usize) -> Pos {
|
||||
for (pi, p) in body.paragraphs.iter().enumerate() {
|
||||
let len = p.char_len();
|
||||
if n <= len {
|
||||
return (pi, n);
|
||||
}
|
||||
n -= len + 1;
|
||||
}
|
||||
end(body)
|
||||
}
|
||||
|
||||
/// Make the body's plain text (paragraphs joined by `\n`) read `text`, as if it had been typed in
|
||||
/// place: only the span between the longest common prefix and suffix changes, so untouched text
|
||||
/// keeps its run and paragraph formatting and new text takes the formatting where it lands. Text
|
||||
/// that shares nothing with the old text replaces it as plain text.
|
||||
pub fn replace_text(body: &mut TextBody, text: &str) {
|
||||
let old: Vec<char> = body.text().chars().collect();
|
||||
let new: Vec<char> = text.chars().collect();
|
||||
if old == new {
|
||||
return;
|
||||
}
|
||||
let pre = old.iter().zip(&new).take_while(|(a, b)| a == b).count();
|
||||
let room = old.len().min(new.len()) - pre;
|
||||
let suf = old.iter().rev().zip(new.iter().rev()).take(room).take_while(|(a, b)| a == b).count();
|
||||
if pre == 0 && suf == 0 {
|
||||
*body = TextBody { body: body.body.clone(), list_style: body.list_style.clone(), ..TextBody::from_text(text) };
|
||||
return;
|
||||
}
|
||||
let a = pos_of_char(body, pre);
|
||||
let b = pos_of_char(body, old.len() - suf);
|
||||
let at = delete(body, a, b);
|
||||
let mid: String = new.get(pre..new.len() - suf).unwrap_or(&[]).iter().collect();
|
||||
if !mid.is_empty() {
|
||||
insert(body, at, &mid, None);
|
||||
}
|
||||
}
|
||||
|
||||
/// End of the body.
|
||||
pub fn end(body: &TextBody) -> Pos {
|
||||
let pi = body.paragraphs.len().saturating_sub(1);
|
||||
@@ -676,4 +714,53 @@ mod tests {
|
||||
assert_eq!(next_case("hello. world is HERE"), "upper");
|
||||
assert_eq!(next_case("123"), "upper");
|
||||
}
|
||||
|
||||
/// Speaker notes: a bold heading over indented bullet points.
|
||||
fn notes() -> TextBody {
|
||||
use crate::text::{Bullet, ParaProps};
|
||||
let mut b = body("Overview\nFirst point\nSecond point");
|
||||
format(&mut b, (0, 0), (0, 8), &|p| p.bold = Some(true));
|
||||
for p in b.paragraphs.iter_mut().skip(1) {
|
||||
p.level = 1;
|
||||
p.props =
|
||||
ParaProps { margin_left: Some(36.0), indent: Some(-18.0), bullet: Some(Bullet::Char { char: "•".into() }), ..Default::default() };
|
||||
}
|
||||
b
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replace_text_keeps_formatting() {
|
||||
let before = notes();
|
||||
// Same text: nothing changes.
|
||||
let mut b = before.clone();
|
||||
replace_text(&mut b, "Overview\nFirst point\nSecond point");
|
||||
assert_eq!(b, before);
|
||||
// A word typed into a bullet: heading stays bold, bullets keep level, indent and bullet.
|
||||
replace_text(&mut b, "Overview\nFirst main point\nSecond point");
|
||||
assert_eq!(b.text(), "Overview\nFirst main point\nSecond point");
|
||||
assert_eq!(b.paragraphs[0].runs[0].props.bold, Some(true));
|
||||
assert_eq!(b.paragraphs[1].props, before.paragraphs[1].props);
|
||||
assert_eq!((b.paragraphs[1].level, b.paragraphs[2].level), (1, 1));
|
||||
// Typing at the end of the heading continues it in bold.
|
||||
replace_text(&mut b, "Overview (5 min)\nFirst main point\nSecond point");
|
||||
assert_eq!(b.paragraphs[0].runs.len(), 1);
|
||||
assert_eq!(b.paragraphs[0].runs[0].props.bold, Some(true));
|
||||
// Enter after a bullet starts another bullet; deleting one joins nothing else.
|
||||
replace_text(&mut b, "Overview (5 min)\nFirst main point\nThird\nSecond point");
|
||||
assert_eq!(b.paragraphs.len(), 4);
|
||||
assert_eq!((b.paragraphs[2].level, &b.paragraphs[2].props), (1, &before.paragraphs[1].props));
|
||||
replace_text(&mut b, "Overview (5 min)\nSecond point");
|
||||
assert_eq!(b.text(), "Overview (5 min)\nSecond point");
|
||||
assert_eq!(b.paragraphs[0].runs[0].props.bold, Some(true));
|
||||
assert_eq!(b.paragraphs[1].level, 1);
|
||||
// Text with nothing in common is new plain text.
|
||||
replace_text(&mut b, "Plain");
|
||||
assert_eq!(b, body("Plain"));
|
||||
replace_text(&mut b, "");
|
||||
assert!(b.is_empty());
|
||||
// Into an empty body.
|
||||
let mut e = TextBody::default();
|
||||
replace_text(&mut e, "a\nb");
|
||||
assert_eq!(e.text(), "a\nb");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
//! Speaker notes keep their run and paragraph formatting: a bold heading, indented points with
|
||||
//! custom bullets, plain lines.
|
||||
|
||||
use std::io::{Cursor, Read, Write};
|
||||
use std::sync::Arc;
|
||||
|
||||
use deckcraft_model::defaults;
|
||||
use deckcraft_model::text::{Bullet, ParaProps, TextBody};
|
||||
|
||||
const NOTES: &str = "ppt/notesSlides/notesSlide1.xml";
|
||||
|
||||
/// A notes slide written the way PowerPoint writes one (Slovak text): a bold heading, a plain
|
||||
/// line, two level-2 points with a hanging indent and a Wingdings square bullet (one mixing a bold
|
||||
/// label and plain text), a level-2 paragraph with buNone, and a closing plain line.
|
||||
const NOTES_XML: &str = r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<p:notes xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main"><p:cSld><p:spTree><p:nvGrpSpPr><p:cNvPr id="1" name=""/><p:cNvGrpSpPr/><p:nvPr/></p:nvGrpSpPr><p:grpSpPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="0" cy="0"/><a:chOff x="0" y="0"/><a:chExt cx="0" cy="0"/></a:xfrm></p:grpSpPr><p:sp><p:nvSpPr><p:cNvPr id="2" name="Slide Image Placeholder 1"/><p:cNvSpPr><a:spLocks noGrp="1" noRot="1" noChangeAspect="1"/></p:cNvSpPr><p:nvPr><p:ph type="sldImg"/></p:nvPr></p:nvSpPr><p:spPr/></p:sp><p:sp><p:nvSpPr><p:cNvPr id="3" name="Notes Placeholder 2"/><p:cNvSpPr><a:spLocks noGrp="1"/></p:cNvSpPr><p:nvPr><p:ph type="body" idx="1"/></p:nvPr></p:nvSpPr><p:spPr/><p:txBody><a:bodyPr/><a:lstStyle/><a:p><a:r><a:rPr lang="sk-SK" b="1" dirty="0"/><a:t>Metodický prehľad</a:t></a:r></a:p><a:p><a:r><a:rPr lang="sk-SK" dirty="0"/><a:t>Žiaci pracujú vo dvojiciach.</a:t></a:r></a:p><a:p><a:pPr marL="285750" lvl="1" indent="-285750"><a:buFont typeface="Wingdings" panose="05000000000000000000" pitchFamily="2" charset="2"/><a:buChar char="§"/></a:pPr><a:r><a:rPr lang="sk-SK" b="1" dirty="0"/><a:t>Cieľ: </a:t></a:r><a:r><a:rPr lang="sk-SK" dirty="0"/><a:t>vysvetliť cyklus</a:t></a:r></a:p><a:p><a:pPr marL="285750" lvl="1" indent="-285750"><a:buFont typeface="Wingdings" panose="05000000000000000000" pitchFamily="2" charset="2"/><a:buChar char="§"/></a:pPr><a:r><a:rPr lang="sk-SK" i="1" dirty="0"/><a:t>Reflexia</a:t></a:r></a:p><a:p><a:pPr marL="285750" lvl="1" indent="-285750"><a:buNone/></a:pPr><a:r><a:rPr lang="sk-SK" dirty="0"/><a:t>Bez odrážky</a:t></a:r></a:p><a:p><a:r><a:rPr lang="sk-SK" dirty="0"/><a:t>Pomôcky: počítač</a:t></a:r></a:p></p:txBody></p:sp></p:spTree></p:cSld><p:clrMapOvr><a:masterClrMapping/></p:clrMapOvr></p:notes>"#;
|
||||
|
||||
/// A deck with one notes slide, its notes replaced by [`NOTES_XML`].
|
||||
fn deck() -> Vec<u8> {
|
||||
let mut p = defaults::new_presentation(None);
|
||||
Arc::make_mut(&mut p.slides[0]).notes = TextBody::from_text("placeholder");
|
||||
let bytes = deckcraft_pptx::export(&p).expect("export");
|
||||
let mut src = zip::ZipArchive::new(Cursor::new(bytes)).expect("zip");
|
||||
let mut out = zip::ZipWriter::new(Cursor::new(Vec::new()));
|
||||
let mut found = false;
|
||||
for i in 0..src.len() {
|
||||
let mut f = src.by_index(i).expect("entry");
|
||||
let name = f.name().to_string();
|
||||
let mut data = vec![];
|
||||
f.read_to_end(&mut data).expect("read");
|
||||
if name == NOTES {
|
||||
data = NOTES_XML.as_bytes().to_vec();
|
||||
found = true;
|
||||
}
|
||||
out.start_file(name, zip::write::SimpleFileOptions::default()).expect("start");
|
||||
out.write_all(&data).expect("write");
|
||||
}
|
||||
assert!(found, "the export has no {NOTES}");
|
||||
out.finish().expect("finish").into_inner()
|
||||
}
|
||||
|
||||
fn assert_formatted(notes: &TextBody) {
|
||||
assert_eq!(notes.text(), "Metodický prehľad\nŽiaci pracujú vo dvojiciach.\nCieľ: vysvetliť cyklus\nReflexia\nBez odrážky\nPomôcky: počítač");
|
||||
let [head, plain, goal, reflect, unbulleted, closing] = notes.paragraphs.as_slice() else { panic!("six paragraphs: {notes:?}") };
|
||||
assert_eq!(head.runs[0].props.bold, Some(true), "bold heading");
|
||||
for para in [head, plain, closing] {
|
||||
assert_eq!((para.level, ¶.props), (0, &ParaProps::default()), "{:?}", para.text());
|
||||
}
|
||||
assert_eq!(plain.runs[0].props.bold, None);
|
||||
for para in [goal, reflect] {
|
||||
assert_eq!(para.level, 1, "level 2");
|
||||
assert_eq!(para.props.margin_left, Some(22.5), "marL 285750 EMU");
|
||||
assert_eq!(para.props.indent, Some(-22.5), "hanging indent");
|
||||
assert_eq!(para.props.bullet, Some(Bullet::Char { char: "§".into() }));
|
||||
assert_eq!(para.props.bullet_font.as_deref(), Some("Wingdings"));
|
||||
}
|
||||
assert_eq!((goal.runs[0].text.as_str(), goal.runs[0].props.bold), ("Cieľ: ", Some(true)), "bold label");
|
||||
assert_eq!((goal.runs[1].text.as_str(), goal.runs[1].props.bold), ("vysvetliť cyklus", None), "then plain");
|
||||
assert_eq!(reflect.runs[0].props.italic, Some(true));
|
||||
assert_eq!((unbulleted.level, unbulleted.props.bullet.clone(), unbulleted.props.margin_left), (1, Some(Bullet::None), Some(22.5)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn notes_import_keeps_bold_levels_and_indents() {
|
||||
let p = deckcraft_pptx::import(&deck()).expect("import");
|
||||
assert_formatted(&p.slides[0].notes);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn notes_formatting_survives_a_round_trip() {
|
||||
let p = deckcraft_pptx::import(&deck()).expect("import");
|
||||
let again = deckcraft_pptx::import(&deckcraft_pptx::export(&p).expect("export")).expect("reimport");
|
||||
assert_formatted(&again.slides[0].notes);
|
||||
// And the written XML says so, for PowerPoint.
|
||||
let bytes = deckcraft_pptx::export(&again).expect("export");
|
||||
let mut z = zip::ZipArchive::new(Cursor::new(bytes)).expect("zip");
|
||||
let mut xml = String::new();
|
||||
z.by_name(NOTES).expect("notes part").read_to_string(&mut xml).expect("utf-8");
|
||||
assert!(xml.contains(r#"b="1""#), "{xml}");
|
||||
assert!(xml.contains(r#"lvl="1""#) && xml.contains(r#"marL="285750""#) && xml.contains(r#"indent="-285750""#), "{xml}");
|
||||
assert!(xml.contains(r#"<a:buFont typeface="Wingdings""#) && xml.contains("<a:buChar") && xml.contains("<a:buNone/>"), "{xml}");
|
||||
}
|
||||
@@ -1170,7 +1170,8 @@ pub fn symbol_char(face: &FontFace, font: &str, c: char) -> Option<char> {
|
||||
Some(symbol_font_char(face, &s, Some(font)).unwrap_or_else(|| map_symbol_bullet(&s, Some(font)).chars().next().unwrap_or('•')))
|
||||
}
|
||||
|
||||
fn map_symbol_bullet(c: &str, font: Option<&str>) -> String {
|
||||
/// The Unicode bullet for a symbol-font bullet character (Wingdings, Symbol), or the character.
|
||||
pub fn map_symbol_bullet(c: &str, font: Option<&str>) -> String {
|
||||
let f = font.unwrap_or("").to_ascii_lowercase();
|
||||
let first = c.chars().next().unwrap_or('•');
|
||||
if f.starts_with("webdings") {
|
||||
|
||||
@@ -18,6 +18,7 @@ pub mod fillui;
|
||||
pub mod icons;
|
||||
pub mod media;
|
||||
pub mod menus;
|
||||
pub mod notes;
|
||||
pub mod panes;
|
||||
pub mod qat;
|
||||
pub mod ribbon;
|
||||
@@ -277,7 +278,7 @@ pub struct SlideApp {
|
||||
pub last_recovery: f64,
|
||||
/// Quit was confirmed (changes saved or discarded): let the window close.
|
||||
pub quit_confirmed: bool,
|
||||
pub(crate) notes_buf: (u64, usize, String),
|
||||
pub(crate) notes_buf: (u64, usize, notes::Draft),
|
||||
/// Faces borrowed from the font database for text the interface fonts can't draw (#101).
|
||||
pub ui_fonts: uifonts::UiFonts,
|
||||
/// Thumbnail pane drag (slide index, current drop index).
|
||||
@@ -334,7 +335,7 @@ impl SlideApp {
|
||||
last_time: 0.0,
|
||||
last_recovery: 0.0,
|
||||
quit_confirmed: false,
|
||||
notes_buf: (0, usize::MAX, String::new()),
|
||||
notes_buf: (0, usize::MAX, notes::Draft::default()),
|
||||
ui_fonts: uifonts::UiFonts::default(),
|
||||
thumb_drag: None,
|
||||
selpane: selpane::State::default(),
|
||||
@@ -2003,4 +2004,90 @@ mod tests {
|
||||
let body = d.doc.slides[0].shapes[0].text.as_ref().unwrap();
|
||||
assert_eq!(body.paragraphs[0].runs[0].props.lang.as_deref(), Some("fr-FR"));
|
||||
}
|
||||
|
||||
/// Speaker notes keep their formatting when edited in the notes pane: typing into a bold
|
||||
/// heading, Enter in a bulleted point, then saving as .pptx and reopening.
|
||||
#[test]
|
||||
fn editing_notes_in_the_pane_keeps_their_formatting() {
|
||||
use deckcraft_model::text::{Bullet, ParaProps, Paragraph, Run, RunProps, TextBody};
|
||||
let point = |runs: Vec<Run>| Paragraph {
|
||||
level: 1,
|
||||
props: ParaProps {
|
||||
margin_left: Some(22.5),
|
||||
indent: Some(-22.5),
|
||||
bullet: Some(Bullet::Char { char: "§".into() }),
|
||||
bullet_font: Some("Wingdings".into()),
|
||||
..Default::default()
|
||||
},
|
||||
runs,
|
||||
..Default::default()
|
||||
};
|
||||
let bold = RunProps { bold: Some(true), ..Default::default() };
|
||||
let notes = TextBody {
|
||||
paragraphs: vec![
|
||||
Paragraph { runs: vec![Run::with("Metodický prehľad", bold.clone())], ..Default::default() },
|
||||
Paragraph::new("Žiaci pracujú vo dvojiciach."),
|
||||
point(vec![Run::with("Cieľ: ", bold.clone()), Run::new("vysvetliť cyklus")]),
|
||||
point(vec![Run::new("Reflexia")]),
|
||||
Paragraph::new("Pomôcky: počítač"),
|
||||
],
|
||||
..Default::default()
|
||||
};
|
||||
let mut session = Session::with_new();
|
||||
session.execute("slide.notes", &json!({"index": 0, "body": notes})).unwrap();
|
||||
let mut harness = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state(
|
||||
|ui, app: &mut SlideApp| {
|
||||
app.logic(ui.ctx());
|
||||
app.ui(ui);
|
||||
},
|
||||
SlideApp::new(session, Services::default()),
|
||||
);
|
||||
harness.run_steps(2);
|
||||
let id = egui::Id::new("notes_edit");
|
||||
let caret = |h: &mut egui_kittest::Harness<'_, SlideApp>, at: usize| {
|
||||
let mut st = egui::TextEdit::load_state(&h.ctx, id).unwrap_or_default();
|
||||
st.cursor.set_char_range(Some(egui::text::CCursorRange::one(egui::text::CCursor::new(at))));
|
||||
egui::TextEdit::store_state(&h.ctx, id, st);
|
||||
};
|
||||
let chars = |s: &str, of: &str| s.find(of).map(|b| s[..b].chars().count() + of.chars().count()).unwrap();
|
||||
let text = notes.text();
|
||||
harness.ctx.memory_mut(|m| m.request_focus(id));
|
||||
harness.run_steps(1);
|
||||
// Type into the bold heading.
|
||||
caret(&mut harness, chars(&text, "prehľad"));
|
||||
harness.run_steps(1);
|
||||
harness.event(egui::Event::Text(" hodiny".into()));
|
||||
harness.run_steps(1);
|
||||
// Enter after a bulleted point, and type the next one.
|
||||
let text = text.replacen("prehľad", "prehľad hodiny", 1);
|
||||
caret(&mut harness, chars(&text, "cyklus"));
|
||||
harness.run_steps(1);
|
||||
harness.key_press(egui::Key::Enter);
|
||||
harness.event(egui::Event::Text("Nový bod".into()));
|
||||
harness.run_steps(1);
|
||||
// Leave the field: the notes are saved.
|
||||
harness.ctx.memory_mut(|m| m.surrender_focus(id));
|
||||
harness.run_steps(2);
|
||||
let check = |n: &TextBody| {
|
||||
assert_eq!(
|
||||
n.text(),
|
||||
"Metodický prehľad hodiny\nŽiaci pracujú vo dvojiciach.\nCieľ: vysvetliť cyklus\nNový bod\nReflexia\nPomôcky: počítač"
|
||||
);
|
||||
assert_eq!(n.paragraphs[0].runs.len(), 1, "{n:?}");
|
||||
assert_eq!(n.paragraphs[0].runs[0].props.bold, Some(true));
|
||||
assert_eq!(n.paragraphs[1].props, ParaProps::default());
|
||||
assert_eq!(n.paragraphs[2].runs[0].props.bold, Some(true), "the bold label");
|
||||
assert_eq!(n.paragraphs[2].runs[1].props.bold, None);
|
||||
for i in [2, 3, 4] {
|
||||
assert_eq!((n.paragraphs[i].level, &n.paragraphs[i].props), (1, ¬es.paragraphs[2].props), "point {i}");
|
||||
}
|
||||
assert_eq!((n.paragraphs[5].level, &n.paragraphs[5].props), (0, &ParaProps::default()));
|
||||
};
|
||||
check(&harness.state().session.active().unwrap().doc.slides[0].notes);
|
||||
// Saved as .pptx and reopened.
|
||||
let s = &mut harness.state_mut().session;
|
||||
let data = s.execute("file.saveBytes", &json!({"format": "pptx"})).unwrap()["data"].clone();
|
||||
s.execute("file.openBytes", &json!({"name": "notes.pptx", "data": data})).unwrap();
|
||||
check(&s.active().unwrap().doc.slides[0].notes);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,327 @@
|
||||
//! Speaker notes editing (the notes pane and Notes Page view). The notes are edited as text and
|
||||
//! drawn with their formatting: bold, italic, underline, strikethrough and size from the runs,
|
||||
//! indents (wrapped lines included) and bullets or numbers from the paragraphs. Each edit is
|
||||
//! applied to a formatted draft, so typed text takes the formatting where it lands and the rest
|
||||
//! keeps its own; the draft is saved with `slide.notes {body}` once the editor loses focus.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use deckcraft_model::edit;
|
||||
use deckcraft_model::text::{Bullet, Paragraph, RunProps, Strike, TextBody};
|
||||
use egui::text::{CCursor, Galley, LayoutJob, TextFormat};
|
||||
use egui::{Align2, Color32, Stroke, Ui, vec2};
|
||||
|
||||
use crate::theme;
|
||||
|
||||
/// Size and colour of a notes editor's text.
|
||||
pub struct Look {
|
||||
/// Pixels for 12 pt text, the notes' default size; indents scale with it.
|
||||
pub size: f32,
|
||||
/// `None` = the widget text colour.
|
||||
pub color: Option<Color32>,
|
||||
}
|
||||
|
||||
/// Notes being edited: the text in the editor and the formatted notes it stands for.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct Draft {
|
||||
pub text: String,
|
||||
pub body: TextBody,
|
||||
/// Edited since it was saved.
|
||||
pub edited: bool,
|
||||
}
|
||||
|
||||
impl Draft {
|
||||
pub fn new(body: &TextBody) -> Self {
|
||||
Draft { text: body.text(), body: body.clone(), edited: false }
|
||||
}
|
||||
}
|
||||
|
||||
/// The draft as it reads with `text` typed in (one edit since the draft): `None` when that can't
|
||||
/// be mapped onto it.
|
||||
fn typed(body: &TextBody, text: &str) -> Option<TextBody> {
|
||||
let mut b = body.clone();
|
||||
edit::replace_text(&mut b, text);
|
||||
(b.text() == text).then_some(b)
|
||||
}
|
||||
|
||||
/// A length in points from the file as pixels, bounded.
|
||||
fn px(pt: f64, k: f32) -> f32 {
|
||||
if pt.is_finite() { pt.clamp(0.0, 400.0) as f32 * k } else { 0.0 }
|
||||
}
|
||||
|
||||
/// Where a paragraph's first line starts and its other lines start (points): a level without its
|
||||
/// own margin is indented 36 pt per level, as in the default notes master.
|
||||
fn indents(p: &Paragraph) -> (f64, f64) {
|
||||
let margin = p.props.margin_left.unwrap_or(36.0 * f64::from(p.level.min(8)));
|
||||
let first = margin + p.props.indent.unwrap_or(0.0);
|
||||
(first, margin)
|
||||
}
|
||||
|
||||
/// Each paragraph's bullet or number, numbered as on a slide (per level, restarting after an
|
||||
/// unnumbered paragraph at the same or a lower level).
|
||||
fn bullets(body: &TextBody) -> Vec<Option<String>> {
|
||||
let mut numbering = [0u32; 9];
|
||||
body.paragraphs
|
||||
.iter()
|
||||
.map(|p| {
|
||||
let lvl = usize::from(p.level.min(8));
|
||||
if p.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let auto = matches!(p.props.bullet, Some(Bullet::AutoNum { .. }));
|
||||
for (i, n) in numbering.iter_mut().enumerate().skip(lvl) {
|
||||
if i > lvl || !auto {
|
||||
*n = 0;
|
||||
}
|
||||
}
|
||||
match p.props.bullet.as_ref()? {
|
||||
Bullet::None => None,
|
||||
Bullet::Char { char } => Some(deckcraft_text::map_symbol_bullet(char, p.props.bullet_font.as_deref())),
|
||||
Bullet::AutoNum { scheme, start_at } => {
|
||||
let n = numbering.get_mut(lvl)?;
|
||||
*n = n.saturating_add(1);
|
||||
Some(deckcraft_text::autonum(scheme, start_at.saturating_sub(1).saturating_add(*n)))
|
||||
}
|
||||
Bullet::Picture { .. } => Some("•".into()),
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn format(props: &RunProps, size: f32, color: Color32) -> TextFormat {
|
||||
let size = match props.size {
|
||||
Some(pt) if pt.is_finite() && pt > 0.0 => (size * pt as f32 / 12.0).clamp(size * 0.75, size * 2.0),
|
||||
_ => size,
|
||||
};
|
||||
let font = if props.bold == Some(true) { theme::bold(size) } else { theme::font(size) };
|
||||
let mut f = TextFormat::simple(font, color);
|
||||
f.italics = props.italic == Some(true);
|
||||
if props.underline.as_deref().is_some_and(|u| u != "none") {
|
||||
f.underline = Stroke::new(1.0, color);
|
||||
}
|
||||
if matches!(props.strike, Some(Strike::Single | Strike::Double)) {
|
||||
f.strikethrough = Stroke::new(1.0, color);
|
||||
}
|
||||
f
|
||||
}
|
||||
|
||||
/// One paragraph to lay out: wrapped lines start at the margin (pixels), the first line's text at
|
||||
/// `text`, clear of a bullet.
|
||||
struct ParaLayout {
|
||||
job: LayoutJob,
|
||||
margin: f32,
|
||||
/// x of the first line's text.
|
||||
text: f32,
|
||||
}
|
||||
|
||||
fn para_layout(p: &Paragraph, bullet: Option<&str>, size: f32, color: Color32, wrap: f32) -> ParaLayout {
|
||||
let k = size / 12.0;
|
||||
let (first, margin) = indents(p);
|
||||
let (first, margin) = (px(first, k), px(margin, k));
|
||||
let text = match bullet {
|
||||
// The text starts at the margin, after the bullet if it's wider than the hanging indent.
|
||||
Some(b) => margin.max(first + size * (0.4 + 0.6 * b.chars().count() as f32)),
|
||||
None => first,
|
||||
};
|
||||
let mut job = LayoutJob::default();
|
||||
job.wrap.max_width = (wrap - margin).max(size * 4.0);
|
||||
let mut lead = (text - margin).max(0.0);
|
||||
for r in &p.runs {
|
||||
let t = r.content();
|
||||
if !t.is_empty() {
|
||||
job.append(t, lead, format(&r.props, size, color));
|
||||
lead = 0.0;
|
||||
}
|
||||
}
|
||||
if job.text.is_empty() {
|
||||
job.append("", 0.0, format(&p.end_props, size, color));
|
||||
}
|
||||
ParaLayout { job, margin, text }
|
||||
}
|
||||
|
||||
/// The whole notes as one job (the galley's text and formats).
|
||||
fn job(body: &TextBody, size: f32, color: Color32, wrap: f32) -> LayoutJob {
|
||||
let mut job = LayoutJob::default();
|
||||
job.wrap.max_width = wrap;
|
||||
for (i, (p, bullet)) in body.paragraphs.iter().zip(bullets(body)).enumerate() {
|
||||
if i > 0 {
|
||||
job.append("\n", 0.0, TextFormat::simple(theme::font(size), color));
|
||||
}
|
||||
let pl = para_layout(p, bullet.as_deref(), size, color, wrap);
|
||||
for s in pl.job.sections {
|
||||
if let Some(t) = pl.job.text.get(s.byte_range.start.0..s.byte_range.end.0) {
|
||||
job.append(t, s.leading_space, s.format);
|
||||
}
|
||||
}
|
||||
}
|
||||
job
|
||||
}
|
||||
|
||||
/// Lay the notes out paragraph by paragraph, each moved to its indent, so wrapped lines line up
|
||||
/// under the text and not under the bullet.
|
||||
fn galley(ui: &Ui, body: &TextBody, size: f32, color: Color32, wrap: f32) -> Arc<Galley> {
|
||||
let whole = job(body, size, color, wrap);
|
||||
if body.paragraphs.is_empty() {
|
||||
return ui.fonts_mut(|f| f.layout_job(whole));
|
||||
}
|
||||
let parts: Vec<Arc<Galley>> = ui.fonts_mut(|f| {
|
||||
body.paragraphs
|
||||
.iter()
|
||||
.zip(bullets(body))
|
||||
.map(|(p, bullet)| {
|
||||
let pl = para_layout(p, bullet.as_deref(), size, color, wrap);
|
||||
let mut g = (*f.layout_job(pl.job)).clone();
|
||||
for (i, row) in g.rows.iter_mut().enumerate() {
|
||||
// The first line already holds the gap after a bullet; a first line hanging
|
||||
// left of the margin without one starts there.
|
||||
row.pos.x += if i == 0 { pl.margin.min(pl.text) } else { pl.margin };
|
||||
}
|
||||
Arc::new(g)
|
||||
})
|
||||
.collect()
|
||||
});
|
||||
Arc::new(Galley::concat(Arc::new(whole), &parts, ui.ctx().pixels_per_point()))
|
||||
}
|
||||
|
||||
/// A multiline editor for the draft that draws its formatting; `configure` sets the id, hint,
|
||||
/// size and frame. Edits update the draft's text and formatted body.
|
||||
pub fn editor(
|
||||
ui: &mut Ui,
|
||||
draft: &mut Draft,
|
||||
look: &Look,
|
||||
configure: impl for<'a> FnOnce(egui::TextEdit<'a>) -> egui::TextEdit<'a>,
|
||||
) -> egui::Response {
|
||||
let color = look.color.unwrap_or_else(|| ui.visuals().override_text_color.unwrap_or_else(|| ui.visuals().widgets.inactive.text_color()));
|
||||
let size = look.size;
|
||||
let before = draft.body.clone();
|
||||
let mut layouter = |ui: &Ui, text: &dyn egui::TextBuffer, wrap: f32| match typed(&before, text.as_str()) {
|
||||
Some(b) => galley(ui, &b, size, color, wrap),
|
||||
None => ui.fonts_mut(|f| f.layout_job(LayoutJob::simple(text.as_str().to_string(), theme::font(size), color, wrap))),
|
||||
};
|
||||
let out = configure(egui::TextEdit::multiline(&mut draft.text)).layouter(&mut layouter).show(ui);
|
||||
if draft.body.text() != draft.text {
|
||||
edit::replace_text(&mut draft.body, &draft.text);
|
||||
draft.edited = true;
|
||||
}
|
||||
// Bullets and numbers sit at the start of each paragraph's first line.
|
||||
if draft.body.text() == draft.text {
|
||||
let painter = ui.painter().with_clip_rect(out.text_clip_rect);
|
||||
let k = size / 12.0;
|
||||
let mut at = 0usize;
|
||||
for (p, bullet) in draft.body.paragraphs.iter().zip(bullets(&draft.body)) {
|
||||
if let Some(b) = bullet {
|
||||
let row = out.galley.pos_from_cursor(CCursor::new(at));
|
||||
let pos = out.galley_pos + vec2(px(indents(p).0, k), row.center().y);
|
||||
let font = theme::font(row.height().clamp(size * 0.75, size * 2.0) * 0.8);
|
||||
painter.text(pos, Align2::LEFT_CENTER, b, font, color);
|
||||
}
|
||||
at += p.char_len() + 1;
|
||||
}
|
||||
}
|
||||
out.response.response
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used)]
|
||||
|
||||
use deckcraft_model::text::{ParaProps, Run};
|
||||
|
||||
use super::*;
|
||||
|
||||
/// Notes as PowerPoint users write them: a bold heading, a plain line, points with a
|
||||
/// Wingdings square bullet and a hanging indent (one long enough to wrap), numbered points,
|
||||
/// a point with an explicit buNone, closing plain lines; Slovak text throughout.
|
||||
fn notes() -> TextBody {
|
||||
let props = |bullet: Bullet| ParaProps {
|
||||
margin_left: Some(22.5),
|
||||
indent: Some(-22.5),
|
||||
bullet: Some(bullet),
|
||||
bullet_font: Some("Wingdings".into()),
|
||||
..Default::default()
|
||||
};
|
||||
let point = |runs: Vec<Run>, bullet: Bullet| Paragraph { level: 1, props: props(bullet), runs, ..Default::default() };
|
||||
let square = || Bullet::Char { char: "§".into() };
|
||||
let num = || Bullet::AutoNum { scheme: "arabicPeriod".into(), start_at: 1 };
|
||||
let bold = RunProps { bold: Some(true), ..Default::default() };
|
||||
TextBody {
|
||||
paragraphs: vec![
|
||||
Paragraph { runs: vec![Run::with("Metodický prehľad", bold.clone())], ..Default::default() },
|
||||
Paragraph::new("Žiaci pracujú vo dvojiciach."),
|
||||
point(vec![Run::with("Cieľ: ", bold), Run::new("vysvetliť cyklus")], square()),
|
||||
point(vec![Run::new("Dlhý bod, ktorý sa zalomí na viac riadkov, lebo je naozaj veľmi dlhý a pokračuje ďalej.")], square()),
|
||||
point(vec![Run::new("Jeden")], num()),
|
||||
point(vec![Run::new("Dva")], num()),
|
||||
point(vec![Run::new("Bez odrážky")], Bullet::None),
|
||||
Paragraph::new("Pomôcky: počítač"),
|
||||
],
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bullets_follow_the_paragraphs() {
|
||||
let b = bullets(¬es());
|
||||
assert_eq!(b, vec![None, None, Some("▪".into()), Some("▪".into()), Some("1.".into()), Some("2.".into()), None, None]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn notes_show_bold_runs_indents_and_wrap_under_the_text() {
|
||||
let body = notes();
|
||||
let ctx = egui::Context::default();
|
||||
theme::install_fonts(&ctx);
|
||||
let mut laid = None;
|
||||
let mut out = ctx.run_ui(Default::default(), |ui| laid = Some(galley(ui, &body, 12.0, Color32::BLACK, 300.0)));
|
||||
out.textures_delta.clear();
|
||||
let g = laid.unwrap();
|
||||
assert_eq!(g.job.text, body.text(), "the galley reads the notes");
|
||||
assert_eq!(g.rows.iter().map(|r| r.char_count_including_newline().0).sum::<usize>(), body.text().chars().count());
|
||||
// Bold where the runs are bold, regular elsewhere.
|
||||
let font_of = |s: &str| {
|
||||
let b = g.job.text.find(s).unwrap();
|
||||
g.job.sections.iter().find(|x| (x.byte_range.start.0..x.byte_range.end.0).contains(&b)).unwrap().format.font_id.clone()
|
||||
};
|
||||
assert_eq!(font_of("Metodický"), theme::bold(12.0));
|
||||
assert_eq!(font_of("Cieľ"), theme::bold(12.0));
|
||||
assert_eq!(font_of("vysvetliť"), theme::font(12.0));
|
||||
assert_eq!(font_of("Pomôcky"), theme::font(12.0));
|
||||
// Rows: x where each paragraph's text starts.
|
||||
let row_x = |s: &str| {
|
||||
let at = body.text().find(s).map(|b| body.text()[..b].chars().count()).unwrap();
|
||||
g.pos_from_cursor(CCursor::new(at)).min.x
|
||||
};
|
||||
assert!(row_x("Metodický") < 1.0 && row_x("Žiaci") < 1.0 && row_x("Pomôcky") < 1.0, "plain paragraphs aren't indented");
|
||||
assert!((row_x("Cieľ") - 22.5).abs() < 1.0, "bulleted text at the 22.5 pt margin: {}", row_x("Cieľ"));
|
||||
assert!((row_x("Bez odrážky") - 0.0).abs() < 1.0, "buNone: no bullet, the first line hangs to 0");
|
||||
// The long point wraps, and its wrapped lines start at the margin, not under the bullet.
|
||||
let long = body.text().find("Dlhý").map(|b| body.text()[..b].chars().count()).unwrap();
|
||||
let first_row = g.pos_from_cursor(CCursor::new(long));
|
||||
let wrapped: Vec<_> =
|
||||
g.rows.iter().filter(|r| r.pos.y > first_row.min.y + 1.0 && r.pos.y < g.pos_from_cursor(CCursor::new(long + 80)).max.y).collect();
|
||||
assert!(!wrapped.is_empty(), "the long point wraps");
|
||||
assert!(wrapped.iter().all(|r| (r.pos.x - 22.5).abs() < 1.0), "wrapped lines align with the text");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn typing_keeps_the_formatting_around_it() {
|
||||
let mut d = Draft::new(¬es());
|
||||
// Typing into the bold heading continues it in bold.
|
||||
let t = d.text.replacen("prehľad", "prehľad hodiny", 1);
|
||||
d.body = typed(&d.body, &t).unwrap();
|
||||
assert_eq!(d.body.paragraphs[0].runs.len(), 1);
|
||||
assert_eq!(d.body.paragraphs[0].runs[0].props.bold, Some(true));
|
||||
// Enter at the end of a bulleted point starts another one with the same bullet and indent.
|
||||
let t = d.body.text().replacen("cyklus", "cyklus\nnový bod", 1);
|
||||
d.body = typed(&d.body, &t).unwrap();
|
||||
let new = &d.body.paragraphs[3];
|
||||
assert_eq!(new.text(), "nový bod");
|
||||
assert_eq!((new.level, &new.props), (1, ¬es().paragraphs[2].props));
|
||||
assert_eq!(d.body.paragraphs[2].runs[0].props.bold, Some(true), "the bold label stays bold");
|
||||
// Hostile sizes and indents stay bounded.
|
||||
let mut wild = notes();
|
||||
wild.paragraphs[2].props.margin_left = Some(f64::NAN);
|
||||
wild.paragraphs[0].runs[0].props.size = Some(f64::INFINITY);
|
||||
let j = job(&wild, 12.0, Color32::BLACK, 300.0);
|
||||
assert!(j.sections.iter().all(|s| s.leading_space.is_finite() && s.format.font_id.size <= 24.0));
|
||||
}
|
||||
}
|
||||
@@ -394,23 +394,33 @@ pub fn notes_page(app: &mut SlideApp, ui: &mut Ui) {
|
||||
ui.painter().rect_stroke(srect, CornerRadius::ZERO, Stroke::new(1.0, Color32::GRAY), egui::StrokeKind::Outside);
|
||||
let notes_rect = to_screen((notes_box.x0, notes_box.y0, notes_box.x1, notes_box.y1));
|
||||
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(notes_rect));
|
||||
let current = st.current_slide().map(|s| s.notes_text()).unwrap_or_default();
|
||||
// A draft belongs to this document and to the notes it started from (#413): another document,
|
||||
// a reorder or an undo that changes them drops it.
|
||||
let id = egui::Id::new(("notespage", st.uid, idx));
|
||||
let stored: Option<(String, String)> = child.data_mut(|d| d.get_temp(id));
|
||||
let mut buf = widgets::text_draft(stored, ¤t);
|
||||
let r = child.add(
|
||||
egui::TextEdit::multiline(&mut buf)
|
||||
.hint_text("Click to add text")
|
||||
.desired_width(notes_rect.width())
|
||||
.desired_rows(12)
|
||||
.frame(egui::Frame::NONE)
|
||||
.text_color(Color32::from_gray(30))
|
||||
.font(theme::font(14.0)),
|
||||
);
|
||||
if r.lost_focus() && buf != current {
|
||||
let _ = app.run("slide.notes", json!({"text": buf, "index": idx}));
|
||||
let edit_id = egui::Id::new(("notespage-edit", idx));
|
||||
let focused = child.ctx().memory(|m| m.has_focus(edit_id));
|
||||
let notes_of = |app: &SlideApp| app.session.active().and_then(|st| st.doc.slides.get(idx)).map(|s| s.notes.clone()).unwrap_or_default();
|
||||
let notes = notes_of(app);
|
||||
let stored: Option<(deckcraft_model::text::TextBody, crate::notes::Draft)> = child.data_mut(|d| d.get_temp(id));
|
||||
let mut draft = stored.filter(|(base, _)| *base == notes).map(|(_, d)| d);
|
||||
// Save an edited draft once the field has lost focus, before anything reloads it.
|
||||
if !focused && let Some(d) = draft.as_mut().filter(|d| d.edited) {
|
||||
d.edited = false;
|
||||
let _ = app.run("slide.notes", json!({"body": d.body, "index": idx}));
|
||||
}
|
||||
child.data_mut(|d| d.insert_temp(id, (current, buf)));
|
||||
let notes = notes_of(app);
|
||||
let mut draft = draft.filter(|d| focused || d.body == notes).unwrap_or_else(|| crate::notes::Draft::new(¬es));
|
||||
let look = crate::notes::Look { size: 14.0, color: Some(Color32::from_gray(30)) };
|
||||
let width = notes_rect.width();
|
||||
let r = crate::notes::editor(&mut child, &mut draft, &look, |e| {
|
||||
e.id(edit_id).hint_text("Click to add text").desired_width(width).desired_rows(12).frame(egui::Frame::NONE)
|
||||
});
|
||||
if r.lost_focus() && draft.edited {
|
||||
draft.edited = false;
|
||||
let _ = app.run("slide.notes", json!({"body": draft.body, "index": idx}));
|
||||
}
|
||||
let base = notes_of(app);
|
||||
child.data_mut(|d| d.insert_temp(id, (base, draft)));
|
||||
let nav = ui.input(|i| (i.key_pressed(egui::Key::PageDown), i.key_pressed(egui::Key::PageUp)));
|
||||
if nav.0 {
|
||||
let _ = app.run("slide.next", json!({}));
|
||||
|
||||
@@ -111,9 +111,18 @@ pub fn notes_pane(app: &mut SlideApp, ui: &mut Ui) {
|
||||
if st.selection.target != deckcraft_engine::Target::Slides {
|
||||
return;
|
||||
}
|
||||
let idx = st.selection.slide;
|
||||
let uid = st.uid;
|
||||
let current = st.current_slide().map(|s| s.notes_text()).unwrap_or_default();
|
||||
let (idx, uid) = (st.selection.slide, st.uid);
|
||||
// Save an edited draft once the field has lost focus or another slide is shown (before
|
||||
// anything reloads it), to the slide it was written on.
|
||||
let id = egui::Id::new("notes_edit");
|
||||
let focused = ui.ctx().memory(|m| m.has_focus(id));
|
||||
if app.notes_buf.2.edited && (!focused || app.notes_buf.0 != uid || app.notes_buf.1 != idx) {
|
||||
app.notes_buf.2.edited = false;
|
||||
if app.notes_buf.0 == uid {
|
||||
let _ = app.run("slide.notes", json!({"body": app.notes_buf.2.body, "index": app.notes_buf.1}));
|
||||
}
|
||||
}
|
||||
let notes = app.session.active().and_then(|st| st.current_slide()).map(|s| s.notes.clone()).unwrap_or_default();
|
||||
egui::Panel::bottom("notes")
|
||||
.resizable(true)
|
||||
.default_size(app.ui.notes_height)
|
||||
@@ -124,42 +133,26 @@ pub fn notes_pane(app: &mut SlideApp, ui: &mut Ui) {
|
||||
let rect = ui.max_rect();
|
||||
ui.painter()
|
||||
.line_segment([pos2(rect.min.x - 8.0, rect.min.y - 6.0), pos2(rect.max.x + 8.0, rect.min.y - 6.0)], Stroke::new(1.0, t.separator));
|
||||
// Keep a local buffer per slide; commit when it changes and the field loses focus.
|
||||
let id = egui::Id::new("notes_edit");
|
||||
let focused = ui.ctx().memory(|m| m.has_focus(id));
|
||||
// Focus can leave in the frame before the field is drawn (Escape, a click on a
|
||||
// thumbnail): what was typed is still committed, to the slide it was typed for.
|
||||
let was_focused = ui.ctx().memory(|m| m.had_focus_last_frame(id));
|
||||
if app.notes_buf.0 != uid || app.notes_buf.1 != idx {
|
||||
let (old_uid, old_idx, typed) = std::mem::replace(&mut app.notes_buf, (uid, idx, current.clone()));
|
||||
let old = app.session.active().and_then(|d| d.doc.slides.get(old_idx)).map(|s| s.notes_text());
|
||||
if was_focused && old_uid == uid && old.is_some_and(|o| o != typed) {
|
||||
let _ = app.run("slide.notes", json!({"text": typed, "index": old_idx}));
|
||||
}
|
||||
// Keep a local draft per slide, following the notes while the field isn't focused.
|
||||
if app.notes_buf.0 != uid || app.notes_buf.1 != idx || (!focused && app.notes_buf.2.body != notes) {
|
||||
app.notes_buf = (uid, idx, crate::notes::Draft::new(¬es));
|
||||
}
|
||||
if !focused && !was_focused && app.notes_buf.2 != current {
|
||||
app.notes_buf.2 = current.clone();
|
||||
}
|
||||
let mut buf = app.notes_buf.2.clone();
|
||||
let mut draft = app.notes_buf.2.clone();
|
||||
let look = crate::notes::Look { size: 13.0, color: None };
|
||||
// Long notes scroll inside the pane instead of spilling over the slide (#221).
|
||||
let r = egui::ScrollArea::vertical()
|
||||
.max_height(ui.available_height())
|
||||
.auto_shrink([false, false])
|
||||
.show(ui, |ui| {
|
||||
ui.add(
|
||||
egui::TextEdit::multiline(&mut buf)
|
||||
.id(id)
|
||||
.hint_text("Click to add notes")
|
||||
.frame(egui::Frame::NONE)
|
||||
.desired_width(f32::INFINITY)
|
||||
.desired_rows(2)
|
||||
.font(theme::font(13.0)),
|
||||
)
|
||||
crate::notes::editor(ui, &mut draft, &look, |e| {
|
||||
e.id(id).hint_text("Click to add notes").frame(egui::Frame::NONE).desired_width(f32::INFINITY).desired_rows(2)
|
||||
})
|
||||
})
|
||||
.inner;
|
||||
app.notes_buf.2 = buf.clone();
|
||||
if r.lost_focus() && buf != current {
|
||||
let _ = app.run("slide.notes", json!({"text": buf, "index": idx}));
|
||||
if r.lost_focus() && draft.edited {
|
||||
draft.edited = false;
|
||||
let _ = app.run("slide.notes", json!({"body": draft.body, "index": idx}));
|
||||
}
|
||||
app.notes_buf.2 = draft;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** minor (owned corpus for tables, charts, animations, media, sections and masters; handout-master text kept; previous: trivial, groups with a small legacy child coordinate space) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (a PNG or JPEG slide export records its resolution, so the default 2× picture is 144 dpi; previous: groups with a small legacy child coordinate space draw outlines at their own width, #233) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (chart trendlines are drawn; previously minor: chart trendlines round-trip; groups with a small legacy child coordinate space draw outlines, dashes, arrowheads and ink at their own width (#233); formats taken from PowerPoint 16.113.4's Info.plist, PPTX coverage measured from `crates/pptx`) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (formatted speaker notes) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
|
||||
Every format PowerPoint reads or writes, DeckCraft's support, fidelity and how it is tested.
|
||||
Gaps are itemized in [gaps.md](gaps.md#file-formats). Dimension score: **~58% ready**
|
||||
@@ -83,6 +84,9 @@ the round trip even though DeckCraft doesn't model it.
|
||||
| Notes slides and notes master | yes | yes | Header, footer, date and slide-number text on the notes master is kept. Edited in View ▸ Notes Master |
|
||||
| Handout master | yes | yes | Header, footer, date and slide-number text is kept. Edited in View ▸ Handout Master |
|
||||
| Shapes: preset geometry (~180 presets), custom geometry, groups, connectors | yes | yes | Presets re-derived in `crates/geom/src/preset.rs` (and `preset/extra.rs`). Outlines, dashes, arrowheads and ink inside a group, and text, keep their size in points whatever the group's child-coordinate scale (#233). |
|
||||
| Notes slides and notes master | yes | yes | Notes keep their run and paragraph formatting (bold, italic, underline, size, levels, indents, bullets), shown and kept when edited in the notes pane and Notes Page (`crates/pptx/tests/notes.rs`). PDF notes pages print them as plain text; levels and bullets inherited from a custom notes master aren't shown; no notes-master editing view |
|
||||
| Handout master | no | no | |
|
||||
| Shapes: preset geometry (~150 presets), custom geometry, groups, connectors | yes | yes | Presets re-derived in `crates/geom/src/preset.rs` |
|
||||
| Fills: solid, gradient, picture (stretch/tile/crop), pattern, group | yes | yes | |
|
||||
| Lines: dash, compound, caps, joins, arrowheads | yes | yes | |
|
||||
| Effects: shadow, glow, soft edges, reflection | yes | yes | |
|
||||
@@ -147,5 +151,6 @@ the round trip even though DeckCraft doesn't model it.
|
||||
| 2026-10-12 | minor | EMF/WMF text and pattern brushes drawn; OLE (VML) and ActiveX previews read |
|
||||
| 2026-10-12 | minor | Text warps rendered; their adjust values read and written |
|
||||
| 2026-10-12 | trivial | Groups with a small legacy child coordinate space draw outlines, dashes, arrowheads and ink at their own width (#233) |
|
||||
| 2026-10-11 | minor | Speaker notes: formatting shown in the notes pane and Notes Page and kept when they are edited |
|
||||
| 2026-10-10 | major | Created from PowerPoint 16.113.4's `CFBundleDocumentTypes`, `crates/pptx` reader/writer and tests, and the open round-trip issues |
|
||||
| 2026-10-13 | minor | Comments: the writer emits the modern threaded part (`p188`) next to the flattened legacy one; the reader prefers the modern part and scales its EMU positions (#70) |
|
||||
|
||||
Reference in New Issue
Block a user