mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-02 14:38:13 -06:00
fix(ui): Ctrl+Z in an open paragraph undoes the document, not stale text
Ctrl+Z is left to a focused text field, so in the paragraph editor it went to the box's own Undo history. That history still held the paragraph's text from before the last formatting change, so Ctrl+Z put it back into a box laid out for the page after the change, a layout the page never had, and the document kept the change. The box shared its id across paragraphs too, so it could put back another paragraph's text. The history of the paragraph editor and the added-text box now starts at what the page has. Undo and Redo keep the keys only while the box holds typing the page doesn't have; then they reach the document. An open box is applied (as clicking away applies it) and closed before an Undo or Redo step, so it never shows a state the document has left; text it can't apply keeps it open and stops the step.
This commit is contained in:
@@ -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 };
|
||||
|
||||
@@ -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<String>,
|
||||
}
|
||||
|
||||
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<Edit> {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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(
|
||||
|
||||
@@ -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<String>,
|
||||
}
|
||||
|
||||
/// 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<String>, 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)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user