From 7d7ab3f70130631be45dfb7fff3b43359ca0d39f Mon Sep 17 00:00:00 2001 From: midasmatterhorn <248507632+midasmatterhorn@users.noreply.github.com> Date: Thu, 15 Oct 2026 09:53:35 +0300 Subject: [PATCH] edit: commit the open paragraph when a click switches text boxes (#2302) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Clicking straight into another text box while one was open for typing replaced the editor and dropped the typed text: only a click on empty canvas (the editor losing focus) or Cmd+Enter applied it (#2022). The click now queues the open paragraph's edit and asks for the clicked box to open on the next frame, from the document that edit has been applied to, so the box's index and geometry are the new inventory's, not the one the click came with. A click on the open box's own frame keeps the editor and its draft instead of resetting it. The regression test types into the top of two boxes, clicks straight into the bottom one, and requires the first box's text applied ("Top edited"), the edit on the undo stack, and the second box open. On the old code it failed with ["Top", "Bottom"] — the draft had been dropped. --- crates/ui-egui/src/canvas.rs | 4 ++ crates/ui-egui/src/edit_text_ui.rs | 104 ++++++++++++++++++++--------- crates/ui-egui/tests/editing.rs | 45 +++++++++++++ 3 files changed, 123 insertions(+), 30 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 6bb8a106f..e8700d813 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -410,6 +410,9 @@ pub struct DocView { /// line being edited. pub(crate) edit_lines: HashMap)>, pub line_editor: Option, + /// The box a click switched to while another paragraph was being edited: it opens once the + /// queued edit has applied, so its index and geometry come from the document it now is (#2022). + pub(crate) pending_text_open: Option<(usize, usize)>, pub(crate) text_background: crate::edit_text_ui::TextBackground, pub(crate) move_background: crate::content_ui::MoveBackground, /// Edit text & images: the images per page (by document generation), and the selected one. @@ -664,6 +667,7 @@ impl DocView { pending_edit: None, edit_lines: HashMap::new(), line_editor: None, + pending_text_open: None, text_background: Default::default(), move_background: Default::default(), edit_images: HashMap::new(), diff --git a/crates/ui-egui/src/edit_text_ui.rs b/crates/ui-egui/src/edit_text_ui.rs index b1629318a..d06cb7c8f 100644 --- a/crates/ui-egui/src/edit_text_ui.rs +++ b/crates/ui-egui/src/edit_text_ui.rs @@ -834,6 +834,53 @@ pub(crate) fn image_input( true } +/// Open `block` on `page` in the inline editor. The box's geometry and source are read from the +/// `lines` handed in, so callers pass the document's current inventory. +#[allow(clippy::too_many_arguments)] +fn open_editor( + ui: &egui::Ui, + xf: &PageXform, + doc: &pdfcraft_engine::Document, + lines: &[pdfcraft_engine::TextBlock], + boxes: &[Rect], + page: usize, + block: usize, + view: &mut DocView, +) { + view.pending_text_open = None; + let (Some(l), Some(b)) = (lines.get(block), boxes.get(block)) else { return }; + view.image_selection = None; + let source_lines = doc.text_lines(page); + let text = l.editor_text.clone(); + // Screen pixels per point, from the box's width. + let scale = (b.width() - 4.0) / ((l.rect[2] - l.rect[0]).max(1.0) as f32); + // How far the editor box may grow: a single-line paragraph's rewrite grows to the + // page's right edge, a multi-line paragraph rewraps to its own width. + let right = xf.rect.right().min(view.viewport_rect().right()) - 6.0; + let max_width = if l.rows.len() == 1 { (right - b.left()).max(b.width()) } else { b.width() }; + view.line_editor = Some(LineEditor { + selection: None, + font_resource: None, + page, + block, + text: text.clone(), + original: text, + rect: *b, + source_rect: l.rect.map(|v| v as f32), + multiline: l.rows.len() > 1, + source: Some((l.clone(), source_lines)), + fonts: install_source_fonts(ui.ctx(), doc, page), + overflow: false, + max_width, + size: (l.size as f32 * scale).clamp(8.0, 72.0), + focus: true, + look: look_of(l), + look0: look_of(l), + extras: Extras::default(), + extras0: Extras::default(), + }); +} + /// Lines and their screen boxes for a page; hover outlines, click opens the editor. Returns /// `true` when the pointer was used. #[allow(clippy::too_many_arguments)] @@ -860,6 +907,15 @@ pub(crate) fn page_input( painter.rect_stroke(*b, CornerRadius::same(2), Stroke::new(0.75, ACCENT.gamma_multiply(0.35)), egui::StrokeKind::Outside); } } + // A box a switching click asked for opens now, once the edit queued with that click has + // applied and `lines`/`boxes` are the document's new inventory (#2022). + if allow_interaction + && active.is_none() + && let Some((open_page, block)) = view.pending_text_open + && open_page == page + { + open_editor(ui, xf, doc, lines, &boxes, page, block, view); + } if !allow_interaction { return false; } @@ -946,36 +1002,24 @@ pub(crate) fn page_input( }); if resp.clicked() { view.image_selection = None; - let l = &lines[hit]; - let source_lines = doc.text_lines(page); - let text = l.editor_text.clone(); - // Screen pixels per point, from the box's width. - let scale = (boxes[hit].width() - 4.0) / ((l.rect[2] - l.rect[0]).max(1.0) as f32); - // How far the editor box may grow: a single-line paragraph's rewrite grows to the - // page's right edge, a multi-line paragraph rewraps to its own width. - let right = xf.rect.right().min(view.viewport_rect().right()) - 6.0; - let max_width = if l.rows.len() == 1 { (right - boxes[hit].left()).max(boxes[hit].width()) } else { boxes[hit].width() }; - view.line_editor = Some(LineEditor { - selection: None, - font_resource: None, - page, - block: hit, - text: text.clone(), - original: text, - rect: boxes[hit], - source_rect: l.rect.map(|v| v as f32), - multiline: l.rows.len() > 1, - source: Some((l.clone(), source_lines)), - fonts: install_source_fonts(ui.ctx(), doc, page), - overflow: false, - max_width, - size: (l.size as f32 * scale).clamp(8.0, 72.0), - focus: true, - look: look_of(l), - look0: look_of(l), - extras: Extras::default(), - extras0: Extras::default(), - }); + // A click that moves the editor to another paragraph applies the open one first, so its + // typed text is not dropped with the switched-away editor (#2022). A click on the open + // box's own frame, which the editor's overlay does not cover, keeps it open instead. + if let Some(open) = view.line_editor.as_ref().filter(|e| e.has_changes()) { + if open.page == page && open.block == hit { + return true; + } + if let Some(old) = view.line_editor.take() { + let style = old.style(); + if old.text != old.original || style != pdfcraft_engine::BlockStyle::default() { + view.pending_edit = Some(Edit::EditTextBlock { page: old.page, block: old.block, text: old.text, style }); + } + } + // The clicked box opens next frame, from the document with that edit applied. + view.pending_text_open = Some((page, hit)); + return true; + } + open_editor(ui, xf, doc, lines, &boxes, page, hit, view); } true } diff --git a/crates/ui-egui/tests/editing.rs b/crates/ui-egui/tests/editing.rs index fee644d62..537386e67 100644 --- a/crates/ui-egui/tests/editing.rs +++ b/crates/ui-egui/tests/editing.rs @@ -2483,6 +2483,51 @@ fn switching_from_a_narrow_editor_keeps_one_frame_and_a_wide_heading_on_one_line assert!(!dirty(&h)); } +/// #2022: clicking straight into another text box while one is open for typing must apply the +/// open paragraph first; the typed text used to be dropped with the editor that switched away. +#[test] +fn switching_straight_to_another_box_applies_the_open_paragraph() { + let bytes = source_font_fixture_with_text("BT /F1 24 Tf 20 220 Td (Top) Tj ET BT /F2 24 Tf 20 120 Td (Bottom) Tj ET"); + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.open_bytes("boxes.pdf", None, bytes).expect("opens"); + app + }); + h.run_steps(4); + assert!(h.state_mut().execute("edit.edit_text")); + h.run_steps(2); + let r = h.state().views[0].page_screen_rect(0).expect("on screen"); + let k = r.width() / 300.0; + let mut blocks = { + let s = h.state(); + s.session.get(s.views[0].id).unwrap().text_blocks(0) + }; + blocks.sort_by(|a, b| b.rect[1].partial_cmp(&a.rect[1]).unwrap_or(std::cmp::Ordering::Equal)); + assert_eq!(blocks.iter().map(|b| b.text.as_str()).collect::>(), ["Top", "Bottom"], "two editable boxes"); + let center = |b: &pdfcraft_engine::TextBlock| { + egui::pos2(r.left() + ((b.rect[0] + b.rect[2]) / 2.0) as f32 * k, r.top() + (300.0 - (b.rect[1] + b.rect[3]) / 2.0) as f32 * k) + }; + // Open the top box and type into it. + click_at(&mut h, center(&blocks[0])); + let ed = h.state().views[0].line_editor.clone().expect("the top box opens for typing"); + assert_eq!(ed.text, "Top"); + h.state_mut().views[0].line_editor.as_mut().unwrap().text = "Top edited".into(); + h.run_steps(1); + // Click straight into the bottom box: the open paragraph is applied, and the bottom box opens. + click_at(&mut h, center(&blocks[1])); + let s = h.state(); + let doc = s.session.get(s.views[0].id).unwrap(); + assert_eq!( + doc.text_blocks(0).iter().map(|b| b.text.clone()).collect::>(), + ["Top edited", "Bottom"], + "the typed text is applied, not dropped" + ); + assert_eq!(doc.can_undo(), Some("Edit text")); + let ed = s.views[0].line_editor.clone().expect("the bottom box is now open"); + assert_eq!(ed.text, "Bottom"); +} + #[test] fn editing_existing_text_in_place() { let mut h = harness(1, |_| {});