edit: commit the open paragraph when a click switches text boxes (#2302)

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.
This commit is contained in:
midasmatterhorn
2026-10-15 02:53:35 -04:00
committed by GitHub
parent 5288946b54
commit 7d7ab3f701
3 changed files with 123 additions and 30 deletions
+4
View File
@@ -410,6 +410,9 @@ pub struct DocView {
/// line being edited.
pub(crate) edit_lines: HashMap<usize, (u64, Vec<pdfcraft_engine::TextBlock>)>,
pub line_editor: Option<crate::edit_text_ui::LineEditor>,
/// 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(),
+74 -30
View File
@@ -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
}
+45
View File
@@ -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::<Vec<_>>(), ["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::<Vec<_>>(),
["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, |_| {});