mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-28 12:08:46 -06:00
fix(comments): Add a text box takes a drag that sizes the box (part of #2669)
This commit is contained in:
@@ -385,7 +385,11 @@ pub enum Gesture {
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub enum ComposerKind {
|
||||
Note,
|
||||
TextBox,
|
||||
/// A text box; `size` is the box dragged out (displayed width and height, in user space
|
||||
/// units), `None` after a click: then the box fits its text.
|
||||
TextBox {
|
||||
size: Option<[f64; 2]>,
|
||||
},
|
||||
/// A callout whose arrow touches `point`; the composer's anchor is the text box's top-left.
|
||||
Callout {
|
||||
point: [f64; 2],
|
||||
@@ -928,11 +932,43 @@ pub(crate) fn page_input(ui: &egui::Ui, resp: &egui::Response, cx: &PageCx<'_>,
|
||||
if over_page {
|
||||
ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair);
|
||||
}
|
||||
// A text box also takes a drag, as in Acrobat: the box is drawn at that size and its
|
||||
// text wraps inside it (#2669).
|
||||
if tool == CommentTool::TextBox {
|
||||
if resp.drag_started()
|
||||
&& pressed_here
|
||||
&& let Some(o) = origin
|
||||
{
|
||||
cv.gesture = Some(Gesture::Draw { page: cx.page, tool, points: vec![cx.to_user(o)] });
|
||||
cv.selected = None;
|
||||
}
|
||||
if let Some(Gesture::Draw { page, points, .. }) = cv.gesture.as_mut()
|
||||
&& *page == cx.page
|
||||
&& let Some(p) = pointer
|
||||
{
|
||||
points.truncate(1);
|
||||
points.push(cx.to_user(clamp_to(page_rect, p)));
|
||||
}
|
||||
if resp.drag_stopped()
|
||||
&& let Some(Gesture::Draw { page, points, .. }) = cv.gesture.clone()
|
||||
&& page == cx.page
|
||||
{
|
||||
cv.gesture = None;
|
||||
if let (Some(first), Some(last)) = (points.first(), points.last()) {
|
||||
let r = Rect::from_two_pos(cx.to_screen(*first), cx.to_screen(*last));
|
||||
// A drag of a few pixels is a click: the box fits its text.
|
||||
let drawn =
|
||||
(r.width() >= 6.0 && r.height() >= 6.0).then(|| cx.info.pages.get(page)).flatten().map(|p| drawn_box(p, *first, *last));
|
||||
let (at, size) = drawn.map_or((*first, None), |(at, size)| (at, Some(size)));
|
||||
cv.composer = Some(Composer { page, at, kind: ComposerKind::TextBox { size }, text: String::new(), focus: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
if resp.clicked()
|
||||
&& over_page
|
||||
&& let Some(p) = pointer
|
||||
{
|
||||
let kind = if tool == CommentTool::Note { ComposerKind::Note } else { ComposerKind::TextBox };
|
||||
let kind = if tool == CommentTool::Note { ComposerKind::Note } else { ComposerKind::TextBox { size: None } };
|
||||
cv.composer = Some(Composer { page: cx.page, at: cx.to_user(p), kind, text: String::new(), focus: true });
|
||||
cv.selected = None;
|
||||
}
|
||||
@@ -1274,6 +1310,9 @@ fn paint_draw(painter: &egui::Painter, cx: &PageCx<'_>, tool: &CommentTool, poin
|
||||
painter.circle_stroke(*first, 5.0, Stroke::new(1.0, SELECT_BLUE));
|
||||
}
|
||||
}
|
||||
CommentTool::TextBox if pts.len() == 2 => {
|
||||
painter.rect_stroke(Rect::from_two_pos(pts[0], pts[1]), CornerRadius::ZERO, Stroke::new(1.0, SELECT_BLUE), egui::StrokeKind::Inside);
|
||||
}
|
||||
CommentTool::Callout if pts.len() == 2 => {
|
||||
painter.line_segment([pts[0], pts[1]], stroke);
|
||||
painter.rect_stroke(Rect::from_min_size(pts[1], vec2(160.0, 40.0) * zoom), CornerRadius::ZERO, stroke, egui::StrokeKind::Inside);
|
||||
@@ -1352,7 +1391,7 @@ pub(crate) fn composer(ctx: &egui::Context, view: &mut DocView, info: &DocInfo,
|
||||
let c = view.comments.composer.as_mut()?;
|
||||
let title = match c.kind {
|
||||
ComposerKind::Note => "Sticky note",
|
||||
ComposerKind::TextBox => "Text box",
|
||||
ComposerKind::TextBox { .. } => "Text box",
|
||||
ComposerKind::Callout { .. } => "Callout",
|
||||
ComposerKind::Caret { .. } => "Inserted text",
|
||||
ComposerKind::Replace => "Replacement text",
|
||||
@@ -1368,7 +1407,7 @@ pub(crate) fn composer(ctx: &egui::Context, view: &mut DocView, info: &DocInfo,
|
||||
});
|
||||
ui.add_space(6.0);
|
||||
let hint = match c.kind {
|
||||
ComposerKind::TextBox | ComposerKind::Callout { .. } => tl!("Type text"),
|
||||
ComposerKind::TextBox { .. } | ComposerKind::Callout { .. } => tl!("Type text"),
|
||||
ComposerKind::Caret { .. } => tl!("Text to insert"),
|
||||
ComposerKind::Replace => tl!("Replacement text"),
|
||||
_ => tl!("Add a comment"),
|
||||
@@ -1419,14 +1458,15 @@ pub(crate) fn composer(ctx: &egui::Context, view: &mut DocView, info: &DocInfo,
|
||||
view.comments.tool_done = true;
|
||||
Some(new_comment(&cx, CommentTool::Note, Shape::Note { at: c.at, icon: NoteIcon::Comment }, text))
|
||||
}
|
||||
ComposerKind::TextBox => {
|
||||
ComposerKind::TextBox { size } => {
|
||||
view.comments.tool_done = true;
|
||||
Some(new_comment(
|
||||
&cx,
|
||||
CommentTool::TextBox,
|
||||
Shape::TextBox { rect: text_box_rect(c.at, &text, 12.0, page_rotation(info, page)), font_size: 12.0 },
|
||||
text,
|
||||
))
|
||||
let rotation = page_rotation(info, page);
|
||||
let border = prefs.style(CommentTool::TextBox).width;
|
||||
let rect = match (size, info.pages.get(page)) {
|
||||
(Some(size), Some(p)) => drawn_text_box_rect(p, c.at, size, &text, 12.0, border),
|
||||
_ => text_box_rect(c.at, &text, 12.0, rotation),
|
||||
};
|
||||
Some(new_comment(&cx, CommentTool::TextBox, Shape::TextBox { rect, font_size: 12.0 }, text))
|
||||
}
|
||||
ComposerKind::Callout { point } => {
|
||||
view.comments.tool_done = true;
|
||||
@@ -1548,6 +1588,32 @@ pub fn text_box_rect(at: [f64; 2], text: &str, size: f64, rotation: i64) -> [f64
|
||||
pdfcraft_engine::text_box_at(rotation, at, w, h).unwrap_or([at[0], at[1] - h, at[0] + w, at[1]])
|
||||
}
|
||||
|
||||
/// The box dragged between user-space points `a` and `b`: its displayed top-left corner (user
|
||||
/// space) and its displayed width and height. Measured in the page's view space, so the view's
|
||||
/// own rotation and zoom (View > Rotate View) do not turn or scale it.
|
||||
fn drawn_box(page: &PageInfo, a: [f64; 2], b: [f64; 2]) -> ([f64; 2], [f64; 2]) {
|
||||
let (a, b) = (page.user_to_view(a[0] as f32, a[1] as f32), page.user_to_view(b[0] as f32, b[1] as f32));
|
||||
let at = page.view_to_user(a[0].min(b[0]), a[1].min(b[1]));
|
||||
([f64::from(at[0]), f64::from(at[1])], [f64::from((a[0] - b[0]).abs()), f64::from((a[1] - b[1]).abs())])
|
||||
}
|
||||
|
||||
/// A text box dragged out `size` (displayed width and height) from its displayed top-left corner
|
||||
/// `at`, with a border `border` wide: the text wraps to its width inside the border, as its
|
||||
/// appearance lays it out. When the text needs more lines the box grows down, and moves up to stay
|
||||
/// on the page; text that cannot fit even so keeps the drawn height and is clipped.
|
||||
pub fn drawn_text_box_rect(page: &PageInfo, at: [f64; 2], size: [f64; 2], text: &str, font_size: f64, border: f64) -> [f64; 4] {
|
||||
use pdfcraft_engine::annot_text::wrap;
|
||||
let pad = 2.0 + border.max(0.0);
|
||||
let [w, drawn] = size;
|
||||
let lines = wrap(text, font_size, (w - 2.0 * pad).max(1.0)).len().max(1);
|
||||
let needed = lines as f64 * font_size * 1.2 + 2.0 * pad + 2.0;
|
||||
let [vx, vy] = page.user_to_view(at[0] as f32, at[1] as f32).map(f64::from);
|
||||
let bottom = f64::from(page.height);
|
||||
let (top, h) = if needed <= drawn || needed > bottom { (vy, drawn) } else { (vy.min(bottom - needed).max(0.0), needed) };
|
||||
let at = page.view_to_user(vx as f32, top as f32).map(f64::from);
|
||||
pdfcraft_engine::text_box_at(i64::from(page.rotation), at, w, h).unwrap_or([at[0], at[1] - h, at[0] + w, at[1]])
|
||||
}
|
||||
|
||||
/// The `/Rotate` of `page` (0 when it is unknown).
|
||||
pub(crate) fn page_rotation(info: &DocInfo, page: usize) -> i64 {
|
||||
info.pages.get(page).map_or(0, |p| i64::from(p.rotation))
|
||||
|
||||
@@ -488,6 +488,117 @@ fn sticky_note_composer_posts_and_returns_to_select() {
|
||||
assert_eq!(comments(&h).len(), 1);
|
||||
}
|
||||
|
||||
/// The rectangle of the comment whose text starts with `text`.
|
||||
fn rect_of(h: &Harness<'static, PdfCraftApp>, text: &str) -> [f32; 4] {
|
||||
let c = comments(h);
|
||||
c.iter().find(|a| a.contents.as_deref().is_some_and(|t| t.starts_with(text))).unwrap_or_else(|| panic!("no comment {text:?}: {c:?}")).rect
|
||||
}
|
||||
|
||||
/// Post `text` in the open text box composer.
|
||||
fn post_text(h: &mut Harness<'static, PdfCraftApp>, text: &str) {
|
||||
field(h, "Type text").type_text(text);
|
||||
h.run_steps(2);
|
||||
h.get_by_label("Post").click();
|
||||
h.run_steps(3);
|
||||
}
|
||||
|
||||
fn near(r: [f32; 4], want: [f32; 4]) -> bool {
|
||||
r.iter().zip(want).all(|(a, b)| (a - b).abs() < 1.5)
|
||||
}
|
||||
|
||||
/// #2669: a drag with Add a text box draws the box at that size, and the text wraps inside it;
|
||||
/// a click (or a drag of a few pixels) still makes a box sized to its text.
|
||||
#[test]
|
||||
fn a_text_box_is_sized_by_dragging() {
|
||||
let mut h = harness(|app| app.set_option("quick", "freetext").unwrap());
|
||||
let (from, to) = (at(&h, 40.0, 150.0), at(&h, 200.0, 100.0));
|
||||
h.hover_at(from);
|
||||
h.run_steps(1);
|
||||
h.drag_at(from);
|
||||
h.run_steps(1);
|
||||
h.hover_at(to);
|
||||
h.run_steps(2);
|
||||
assert!(
|
||||
matches!(&h.state().views[0].comments.gesture, Some(pdfcraft_ui_egui::comments::Gesture::Draw { points, .. }) if points.len() == 2),
|
||||
"the box shows while it is dragged"
|
||||
);
|
||||
h.drop_at(to);
|
||||
h.run_steps(3);
|
||||
post_text(&mut h, "Drawn");
|
||||
let r = rect_of(&h, "Drawn");
|
||||
assert!(near(r, [40.0, 100.0, 200.0, 150.0]), "{r:?}");
|
||||
|
||||
// Too much text for the drawn box: it keeps its width and grows down, moving up so that it
|
||||
// stays on the page (whose bottom is at 0).
|
||||
h.state_mut().set_option("quick", "freetext").unwrap();
|
||||
drag_pt(&mut h, (20.0, 80.0), (120.0, 70.0));
|
||||
post_text(&mut h, &"word ".repeat(20));
|
||||
let r = rect_of(&h, "word word");
|
||||
assert!((r[0] - 20.0).abs() < 1.5 && (r[2] - 120.0).abs() < 1.5 && r[1].abs() < 0.5 && r[3] > 90.0, "{r:?}");
|
||||
|
||||
// More text than the page can hold: the box keeps the drawn size and the text is clipped.
|
||||
h.state_mut().set_option("quick", "freetext").unwrap();
|
||||
drag_pt(&mut h, (150.0, 190.0), (250.0, 180.0));
|
||||
post_text(&mut h, &"longerwords ".repeat(16));
|
||||
let r = rect_of(&h, "longerwords");
|
||||
assert!(near(r, [150.0, 180.0, 250.0, 190.0]), "{r:?}");
|
||||
|
||||
// Drags of two and four pixels are clicks: the box fits its text.
|
||||
let fitted = pdfcraft_ui_egui::comments::text_box_rect([0.0, 0.0], "Hi", 12.0, 0);
|
||||
for (text, x, d) in [("Hi", 200.0, 2.0), ("Hi again", 270.0, 4.0)] {
|
||||
h.state_mut().set_option("quick", "freetext").unwrap();
|
||||
let p = at(&h, x, 40.0);
|
||||
drag(&mut h, p, p + egui::vec2(d, d));
|
||||
post_text(&mut h, text);
|
||||
let r = rect_of(&h, text).map(f64::from);
|
||||
let fitted = if text == "Hi" { fitted } else { pdfcraft_ui_egui::comments::text_box_rect([0.0, 0.0], text, 12.0, 0) };
|
||||
assert!((r[2] - r[0] - (fitted[2] - fitted[0])).abs() < 1.0 && (r[3] - r[1] - (fitted[3] - fitted[1])).abs() < 1.0, "{text}: {r:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// #2669: with the view turned (View > Rotate View) the box lands where it was drawn, not
|
||||
/// transposed or scaled by the turned page's other side.
|
||||
#[test]
|
||||
fn a_dragged_text_box_follows_a_rotated_view() {
|
||||
for rotation in [90u16, 270] {
|
||||
let mut h = harness(|app| app.set_option("quick", "freetext").unwrap());
|
||||
while h.state().views[0].rotation != rotation {
|
||||
h.state_mut().views[0].rotate_view(true);
|
||||
}
|
||||
h.state_mut().set_option("zoom", "150").unwrap();
|
||||
h.run_steps(3);
|
||||
settle(&mut h);
|
||||
let screen = |h: &Harness<'static, PdfCraftApp>, x: f32, y: f32| {
|
||||
let r = h.state().views[0].page_screen_rect(0).expect("page 1 on screen");
|
||||
let (u, v) = (x / 300.0, (200.0 - y) / 200.0);
|
||||
let (a, b) = if rotation == 90 { (1.0 - v, u) } else { (v, 1.0 - u) };
|
||||
pos2(r.left() + a * r.width(), r.top() + b * r.height())
|
||||
};
|
||||
let (from, to) = (screen(&h, 40.0, 150.0), screen(&h, 200.0, 100.0));
|
||||
drag(&mut h, from, to);
|
||||
post_text(&mut h, "Turned");
|
||||
let r = rect_of(&h, "Turned");
|
||||
assert!(near(r, [40.0, 100.0, 200.0, 150.0]), "view turned {rotation}: {r:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// #2669: the text of a drawn box wraps inside its border, as its appearance draws it, so a thick
|
||||
/// border does not cut off the last line.
|
||||
#[test]
|
||||
fn a_dragged_text_box_wraps_inside_its_border() {
|
||||
use pdfcraft_ui_egui::comments::CommentTool;
|
||||
let mut h = harness(|app| {
|
||||
app.set_option("quick", "freetext").unwrap();
|
||||
app.comment_prefs.set_width(CommentTool::TextBox, 3.0);
|
||||
});
|
||||
// Wide enough for "word word" inside a 2 pt padding, not inside 2 + 3 pt.
|
||||
let w = pdfcraft_engine::annot_text::text_width("word word", 12.0) as f32 + 6.0;
|
||||
drag_pt(&mut h, (20.0, 190.0), (20.0 + w, 180.0));
|
||||
post_text(&mut h, "word word");
|
||||
let r = rect_of(&h, "word word");
|
||||
assert!((r[3] - r[1] - (2.0 * 14.4 + 2.0 * 5.0 + 2.0)).abs() < 1.0, "two lines inside the border: {r:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn comments_are_selected_moved_and_deleted_with_the_select_tool() {
|
||||
let mut h = harness(|app| app.set_option("quick", "square").unwrap());
|
||||
|
||||
Reference in New Issue
Block a user