diff --git a/crates/ui-egui/src/commands.rs b/crates/ui-egui/src/commands.rs index 92bc0b360e..ee19c168eb 100644 --- a/crates/ui-egui/src/commands.rs +++ b/crates/ui-egui/src/commands.rs @@ -726,7 +726,10 @@ impl PdfCraftApp { if matches!(spec.id, "view.previous_view" | "view.next_view") && !self.command_enabled(spec) { continue; } - if typing && !spec.in_text { + // A box typed into on the page (a paragraph being edited, added text) keeps Undo and + // Redo only while it holds typing the page doesn't have; then they reach the document. + let page_box = matches!(spec.id, "edit.undo" | "edit.redo") && self.page_box_focused(ctx) && !self.page_typing_unapplied(); + if typing && !spec.in_text && !page_box { continue; } let Some(key) = Key::from_name(s.key) else { continue }; diff --git a/crates/ui-egui/src/content_ui.rs b/crates/ui-egui/src/content_ui.rs index f8db4c8ca5..c99c2cd787 100644 --- a/crates/ui-egui/src/content_ui.rs +++ b/crates/ui-egui/src/content_ui.rs @@ -66,8 +66,18 @@ pub struct TextDraft { /// Display space. pub rect: [f64; 4], pub text: String, + /// The box's formatting; its `text` is what the page has (empty for new text). pub style: AddedText, pub focus: bool, + /// The text the box's own Undo history starts from ([`crate::edit_text_ui::forget_typing_before`]). + pub typing_from: Option, +} + +impl TextDraft { + /// Typing the page doesn't have yet: Undo takes it back in the box before touching the document. + pub(crate) fn has_unapplied_typing(&self) -> bool { + self.text != self.style.text + } } #[derive(Clone, Copy, Debug, PartialEq)] @@ -394,7 +404,7 @@ pub(crate) fn page_input( let page_width = info.pages.get(page).map_or(f64::INFINITY, |p| p.width as f64); let rect = [at[0], at[1] - style.size * 1.2, (at[0] + 200.0).min(page_width), at[1]]; style.rect = rect; - cv.draft = Some(TextDraft { page, index: None, rect, text: String::new(), style, focus: true }); + cv.draft = Some(TextDraft { page, index: None, rect, text: String::new(), style, focus: true, typing_from: None }); consumed = true; if typed.is_some() { view.pending_edit = typed; @@ -432,7 +442,7 @@ pub(crate) fn page_input( if let Some(edit) = cv.finish_draft(added) { view.pending_edit = Some(edit); } - cv.draft = Some(TextDraft { page, index: Some(i), rect: t.rect, text: t.text.clone(), style: t.clone(), focus: true }); + cv.draft = Some(TextDraft { page, index: Some(i), rect: t.rect, text: t.text.clone(), style: t.clone(), focus: true, typing_from: None }); cv.selected = Some((page, i)); consumed = true; return consumed; @@ -567,6 +577,8 @@ pub(crate) fn editor(ctx: &egui::Context, view: &mut DocView, info: &DocInfo, ad pdfcraft_engine::TextAlign::Right => egui::Align::Max, pdfcraft_engine::TextAlign::Left | pdfcraft_engine::TextAlign::Justify => egui::Align::Min, }; + let input = input_id(); + crate::edit_text_ui::forget_typing_before(ui.ctx(), input, &mut t.typing_from, &t.style.text); ui.horizontal_top(|ui| { ui.spacing_mut().item_spacing.x = 4.0; let resp = ui.add( @@ -578,7 +590,7 @@ pub(crate) fn editor(ctx: &egui::Context, view: &mut DocView, info: &DocInfo, ad .background_color(if background_ready { Color32::TRANSPARENT } else { Color32::WHITE }) .text_color(Color32::from_rgb(cr, cg, cb)) .hint_text(tl!("Type text")) - .id_salt("added-text-edit"), + .id(input), ); if t.focus { resp.request_focus(); @@ -641,6 +653,11 @@ fn finish(cv: &mut ContentView, d: TextDraft, added: &[Added]) -> Option { } } +/// The added-text box's text field. +pub(crate) fn input_id() -> egui::Id { + egui::Id::new("added-text-input") +} + /// How to use the tools, under the Add content list. pub(crate) fn hint(ui: &mut egui::Ui, t: &Tokens) { ui.label( diff --git a/crates/ui-egui/src/edit_text_ui.rs b/crates/ui-egui/src/edit_text_ui.rs index 3a1ab04367..f2c5c55151 100644 --- a/crates/ui-egui/src/edit_text_ui.rs +++ b/crates/ui-egui/src/edit_text_ui.rs @@ -256,6 +256,29 @@ pub struct LineEditor { /// horizontal scale (%), and what they were. pub extras: Extras, extras0: Extras, + /// The text the box's own Undo history starts from; see [`forget_typing_before`]. + typing_from: Option, +} + +/// The paragraph editor's text box. +pub(crate) fn input_id() -> egui::Id { + egui::Id::new("edit-text-line-input") +} + +/// The page text box `id` keeps its own Undo history, across every paragraph or box it has shown +/// (they share the id). Once the page has `text` (the box opened on it, or formatting applied it), +/// that history is cleared: Undo in the box takes back only what was typed since, and then reaches +/// the document. Kept, it put back text from another paragraph, or the whole paragraph as it was +/// before a format change, into a box showing the page as it is now. +pub(crate) fn forget_typing_before(ctx: &egui::Context, id: egui::Id, from: &mut Option, text: &str) { + if from.as_deref() == Some(text) { + return; + } + if let Some(mut state) = egui::TextEdit::load_state(ctx, id) { + state.clear_undoer(); + egui::TextEdit::store_state(ctx, id, state); + } + *from = Some(text.to_owned()); } #[derive(Clone, Copy, Debug, PartialEq)] @@ -1199,6 +1222,7 @@ fn open_editor( family_chosen: false, extras: Extras::default(), extras0: Extras::default(), + typing_from: None, }); } @@ -1668,8 +1692,10 @@ pub(crate) fn overlay(ctx: &egui::Context, view: &mut DocView, info: &DocInfo) - let galley = editor_galley(ui, &snapshot, text.as_str(), scale, wrap_width); if snapshot.changes_with(text.as_str()) { galley } else { unseen(galley) } }; + let input = input_id(); + forget_typing_before(ui.ctx(), input, &mut ed.typing_from, &ed.original); let output = egui::TextEdit::multiline(&mut ed.text) - .id(egui::Id::new("edit-text-line-input")) + .id(input) .font(font.clone()) .margin(egui::Margin::ZERO) .layouter(&mut layouter) @@ -1801,6 +1827,7 @@ mod tests { family_chosen: false, extras: Extras::default(), extras0: Extras::default(), + typing_from: None, }; (app, ed) } diff --git a/crates/ui-egui/src/editing.rs b/crates/ui-egui/src/editing.rs index 2fdc2e9d02..6f16837242 100644 --- a/crates/ui-egui/src/editing.rs +++ b/crates/ui-egui/src/editing.rs @@ -229,6 +229,9 @@ impl PdfCraftApp { fn history_step(&mut self, undo: bool) { let Some((i, id)) = self.active_ids() else { return }; + if !self.close_text_editors(i, id) { + return; + } let result = if undo { self.session.undo(id) } else { self.session.redo(id) }; match result { Ok(label) => { @@ -247,6 +250,57 @@ impl PdfCraftApp { } } + /// Before Undo or Redo: a paragraph or added text box open on the page shows the document as + /// it is before the step, so it is applied as clicking away applies it, then closed. Left + /// open, it kept showing what the step took away, and wrote from it when it was next applied. + /// What was typed is then the step Undo takes back. False (nothing is undone, and the box + /// stays open) while it holds text the standard fonts can't draw, or text that couldn't be + /// applied: it is shown, never lost. + pub(crate) fn close_text_editors(&mut self, i: usize, id: pdfcraft_engine::DocId) -> bool { + let Some(view) = self.views.get_mut(i) else { return true }; + if let Some(c) = view.content.blocked() { + view.content.hold_blocked(); + self.notify(crate::content_ui::undrawable_message(c)); + return false; + } + if let Some(ed) = view.line_editor.take() + && let Some(edit) = crate::edit_text_ui::finished_edit(ed.clone()) + && !self.apply_edit(edit) + { + if let Some(view) = self.views.get_mut(i) { + view.line_editor = Some(ed); + } + return false; + } + let added = self.session.get(id).map(|d| d.added().to_vec()).unwrap_or_default(); + let Some(view) = self.views.get_mut(i) else { return true }; + let draft = view.content.draft.clone(); + if let Some(edit) = view.content.finish_draft(&added) + && !self.apply_edit(edit) + { + if let Some(view) = self.views.get_mut(i) { + view.content.draft = draft; + } + return false; + } + true + } + + /// Whether the keyboard is in a box typed into on the page: a paragraph being edited, or added + /// text. + pub(crate) fn page_box_focused(&self, ctx: &egui::Context) -> bool { + let focused = ctx.memory(|m| m.focused()); + focused == Some(crate::edit_text_ui::input_id()) || focused == Some(crate::content_ui::input_id()) + } + + /// Typing in a box on the page (a paragraph being edited, added text) that the page doesn't + /// have yet: Undo and Redo keys take it back in the box first. + pub(crate) fn page_typing_unapplied(&self) -> bool { + self.active.and_then(|i| self.views.get(i)).is_some_and(|v| { + v.line_editor.as_ref().is_some_and(|ed| ed.has_unsaved_text()) || v.content.draft.as_ref().is_some_and(|d| d.has_unapplied_typing()) + }) + } + /// Apply any edit a view queued this frame (organize toolbar, keys). /// Returns false when a field refused the typing queued for it (its editor is open again). pub(crate) fn process_pending_edits(&mut self) -> bool { diff --git a/crates/ui-egui/tests/it/editing.rs b/crates/ui-egui/tests/it/editing.rs index 73464d33e7..2fcf67dc42 100644 --- a/crates/ui-egui/tests/it/editing.rs +++ b/crates/ui-egui/tests/it/editing.rs @@ -6019,3 +6019,58 @@ fn mode_tabs_keep_the_hand_over_a_paragraph_scrolled_under_them() { click_at(&mut h, convert); assert_eq!(h.state().mode, pdfcraft_ui_egui::Mode::Convert, "the tab takes the click, not the paragraph's editor"); } + +/// The paragraph opened for typing, as a person opens it: a click on its text. +fn open_paragraph(h: &mut Harness<'static, PdfCraftApp>) { + let r = h.state().views[0].page_screen_rect(0).expect("on screen"); + let at = egui::pos2(r.left() + 40.0 / 200.0 * r.width(), r.top() + (300.0 - 158.0) / 300.0 * r.height()); + click_at(h, at); + assert!(h.state().views[0].line_editor.is_some(), "the paragraph opens for typing"); +} + +/// Ctrl+Z after a formatting change, with the paragraph still open: the change is undone and +/// the editor closes. The box's own Undo used to take the key, putting back the text it had +/// before the change into a box laid out for the page after it, and the document kept the change. +#[test] +fn undo_with_the_paragraph_open_undoes_the_formatting() { + let mut h = harness(1, |_| {}); + assert!(h.state_mut().execute("edit.edit_text")); + h.run_steps(2); + open_paragraph(&mut h); + h.get_by_label("Bold").click(); + h.run_steps(4); + { + let s = h.state(); + let doc = s.session.get(s.views[0].id).unwrap(); + assert_eq!(doc.can_undo(), Some("Edit text")); + assert_eq!(doc.text_blocks(0)[0].base_font, "Helvetica-Bold"); + assert!(s.views[0].line_editor.is_some(), "still editing"); + } + h.key_press_modifiers(Modifiers::COMMAND, Key::Z); + h.run_steps(4); + let s = h.state(); + let doc = s.session.get(s.views[0].id).unwrap(); + assert_eq!(doc.can_undo(), None, "the formatting is undone"); + assert_eq!(doc.text_blocks(0)[0].base_font, "Helvetica"); + assert!(s.views[0].line_editor.is_none(), "the editor closed with the step it showed"); +} + +/// Undo while typing takes back the typing in the box first; the document is left alone. +#[test] +fn undo_while_typing_takes_back_the_typing_first() { + let mut h = harness(1, |_| {}); + assert!(h.state_mut().execute("edit.edit_text")); + h.run_steps(2); + open_paragraph(&mut h); + let original = h.state().views[0].line_editor.as_ref().unwrap().text.clone(); + h.key_press_modifiers(Modifiers::COMMAND, Key::End); + h.event(egui::Event::Text(" more".into())); + h.run_steps(2); + assert_ne!(h.state().views[0].line_editor.as_ref().unwrap().text, original); + h.key_press_modifiers(Modifiers::COMMAND, Key::Z); + h.run_steps(3); + let s = h.state(); + let ed = s.views[0].line_editor.as_ref().expect("still typing"); + assert_eq!(ed.text, original, "the typing is taken back"); + assert_eq!(s.session.get(s.views[0].id).unwrap().can_undo(), None, "the document is untouched"); +}