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, |_| {});