mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-10 13:01:29 +00:00
fix(ui): keep added image corner resize responsive and clip out-of-bounds drag preview (#4477, #3938) (#4625)
This commit is contained in:
1 parent
8e6db09459
commit
c6d9c13002
8 files changed
+308
-52
No files matched your search
@@ -61,6 +61,15 @@ impl PageImage {
|
||||
self.clips.iter().any(|clip| clip.app_crop)
|
||||
}
|
||||
|
||||
/// The editing frame, respecting explicit PDF/editor crops but not the page boundary.
|
||||
/// Selected handles remain reachable outside the page; body hits still use `contains`.
|
||||
pub fn frame_rect(&self) -> Option<[f64; 4]> {
|
||||
self.clips.iter().try_fold(self.rect, |rect, clip| {
|
||||
let b = clip.path.bounding_box();
|
||||
intersection(rect, [b.x0, b.y0, b.x1, b.y1])
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether a user-space point belongs to the placed artwork and every active clip.
|
||||
pub fn contains(&self, point: [f64; 2]) -> bool {
|
||||
if !point.iter().all(|v| v.is_finite()) {
|
||||
|
||||
@@ -13610,6 +13610,25 @@ fn review_inside_scope_group_move_matches_direct_transform_under_outer_scale() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn image_editing_frame_keeps_page_edge_outside_explicit_clips() {
|
||||
for (prefix, expected) in [
|
||||
("", [-30.0, -20.0, 70.0, 80.0]),
|
||||
("-10 -5 60 65 re W n ", [-10.0, -5.0, 50.0, 60.0]),
|
||||
("/PdfCraftImageCrop MP -10 -5 60 65 re W n ", [-10.0, -5.0, 50.0, 60.0]),
|
||||
] {
|
||||
let doc = image_page_with(&format!("q {prefix}100 0 0 100 -30 -20 cm /Im0 Do Q"));
|
||||
for doc in [&doc, &reopen(&doc)] {
|
||||
let im = images::page_images(doc, 0).unwrap().remove(0);
|
||||
assert_eq!(im.frame_rect(), Some(expected));
|
||||
assert_eq!(im.has_editor_crop(), prefix.starts_with("/PdfCraftImageCrop"));
|
||||
assert_eq!(im.visible_rect.unwrap()[0..2], [0.0, 0.0]);
|
||||
assert!(!im.contains([-5.0, 10.0]), "out-of-page pixels stay unselectable");
|
||||
assert!(im.contains([10.0, 10.0]));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_builtin_thai_replaces_an_unavailable_source_without_installing_a_font() {
|
||||
let mut doc = text_page("BT /F1 16 Tf 50 700 Td (A) Tj ET BT /F2 12 Tf 50 600 Td (ab) Tj ET");
|
||||
|
||||
@@ -420,13 +420,10 @@ fn run_edit_page_image(doc: &mut pdfcraft_cos::Document, page: usize, index: usi
|
||||
if let Some((added_idx, added_item)) = matched_added
|
||||
&& let AddedContent::Image(ai) = added_item.content.clone()
|
||||
{
|
||||
// Page visibility alone is not a crop: dragging an added image over the page edge
|
||||
// must not discard its editable added-item identity on the next resize.
|
||||
let has_active_crop = ai.crop.iter().any(|v| v.abs() > 1e-6)
|
||||
|| img.visible_rect.is_some_and(|v| {
|
||||
(v[0] - img.rect[0]).abs() > 1e-4
|
||||
|| (v[1] - img.rect[1]).abs() > 1e-4
|
||||
|| (v[2] - img.rect[2]).abs() > 1e-4
|
||||
|| (v[3] - img.rect[3]).abs() > 1e-4
|
||||
});
|
||||
|| img.frame_rect().is_none_or(|frame| frame.iter().zip(img.rect).any(|(a, b)| (a - b).abs() > 1e-4));
|
||||
let non_axis_transform = matches!(change, ImageEdit::Transform([a, b, c, d, _, _])
|
||||
if !((b.abs() < 1e-5 && c.abs() < 1e-5) || (a.abs() < 1e-5 && d.abs() < 1e-5)));
|
||||
let should_promote = has_active_crop
|
||||
|
||||
@@ -13241,6 +13241,25 @@ fn redacting_an_optimized_jbig2_page_stores_the_rest_of_its_group_again() {
|
||||
assert_eq!(named, 0, "no image names the dictionary, which left with them");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resizing_an_added_image_back_inside_the_page_keeps_its_identity() {
|
||||
let mut png = Vec::new();
|
||||
{
|
||||
let mut enc = png::Encoder::new(&mut png, 2, 2);
|
||||
enc.set_color(png::ColorType::Rgb);
|
||||
enc.write_header().unwrap().write_image_data(&[255u8; 12]).unwrap();
|
||||
}
|
||||
let (mut s, id) = session_with(1);
|
||||
s.apply(id, Edit::AddImage { page: 0, rect: Some([-30.0, -20.0, 70.0, 80.0]), name: "edge.png".into(), bytes: Arc::new(png) }).unwrap();
|
||||
let original = s.get(id).unwrap().added()[0].obj;
|
||||
let target = [10.0, 10.0, 60.0, 60.0];
|
||||
s.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::Move(target) }).unwrap();
|
||||
let added = s.get(id).unwrap().added();
|
||||
assert_eq!(added.len(), 1);
|
||||
assert_eq!(added[0].obj, original);
|
||||
assert_eq!(added[0].content.rect(), target);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fill_sign_multiline_text_survives_engine_save_reopen_and_render() {
|
||||
let (mut session, id) = rotated_blank(0);
|
||||
|
||||
@@ -102,39 +102,15 @@ impl PainterExt for egui::Painter {
|
||||
}
|
||||
}
|
||||
|
||||
/// User-space selectable bounding box for an image object.
|
||||
/// If the image object has an active clipping path applied to it (the crop box),
|
||||
/// this is the mathematical intersection of the full CTM-derived rect and the clipping path's rect.
|
||||
/// User-space editing frame: explicit PDF/editor crops apply, but the page edge does not
|
||||
/// truncate a selected image's handles. Body hit testing still respects page visibility.
|
||||
pub fn image_selectable_bounds(im: &pdfcraft_engine::PageImage) -> [f64; 4] {
|
||||
if let Some(clip) = im.clip_rect {
|
||||
let r = [im.rect[0].max(clip[0]), im.rect[1].max(clip[1]), im.rect[2].min(clip[2]), im.rect[3].min(clip[3])];
|
||||
if r.iter().all(|v| v.is_finite()) && r[2] > r[0] && r[3] > r[1] {
|
||||
return r;
|
||||
}
|
||||
}
|
||||
im.visible_rect.unwrap_or(im.rect)
|
||||
im.frame_rect().unwrap_or(im.rect)
|
||||
}
|
||||
|
||||
/// Screen-space selectable bounding box for an image object.
|
||||
/// If the image object has an active clipping path applied to it (the crop box),
|
||||
/// the final selectable `egui::Rect` is the mathematical intersection of the image's
|
||||
/// full CTM-derived rect and the clipping path's rect.
|
||||
/// Screen-space editing frame, including selected handles outside the page.
|
||||
pub fn image_screen_rect(im: &pdfcraft_engine::PageImage, xf: &PageXform, info: &DocInfo, page: usize) -> Rect {
|
||||
let full = xf.user_rect_f64(info, page, im.rect);
|
||||
if let Some(clip) = im.clip_rect {
|
||||
let clip_r = xf.user_rect_f64(info, page, clip);
|
||||
let inter = full.intersect(clip_r);
|
||||
if inter.is_positive() {
|
||||
return inter;
|
||||
}
|
||||
}
|
||||
if let Some(vr) = im.visible_rect {
|
||||
let r = xf.user_rect_f64(info, page, vr);
|
||||
if r.is_positive() {
|
||||
return r;
|
||||
}
|
||||
}
|
||||
full
|
||||
xf.user_rect_f64(info, page, image_selectable_bounds(im))
|
||||
}
|
||||
|
||||
/// An Oriented Bounding Box (OBB) representing a rotated rectangle in screen space,
|
||||
@@ -1860,7 +1836,12 @@ fn draw_pages(
|
||||
if delta != Vec2::ZERO && delta.is_finite() {
|
||||
ui.scroll_with_delta_animation(delta, egui::style::ScrollAnimation::none());
|
||||
}
|
||||
let (resp_rect, resp) = ui.allocate_exact_size(vec2(content_w, content_h), Sense::click_and_drag());
|
||||
// A resized handle can sit in the window beside a page that does not fill it. The
|
||||
// response has to cover that visible area or the next press never starts: the pointer
|
||||
// is reported only inside this rectangle once the previous drag has been released.
|
||||
// Page positions stay on `content_w`/`content_h`; the extra area is not a new page.
|
||||
let response_size = vec2(content_w.max(viewport.width()), content_h.max(viewport.height()));
|
||||
let (resp_rect, resp) = ui.allocate_exact_size(response_size, Sense::click_and_drag());
|
||||
view.page_widget = Some(resp.id);
|
||||
// A drag lasts only while egui reports it, so the mark can't outlive one.
|
||||
if !resp.dragged_by(egui::PointerButton::Primary) {
|
||||
|
||||
@@ -275,29 +275,30 @@ impl MoveBackground {
|
||||
return;
|
||||
}
|
||||
let r = screen_rect(xf, info, page, from);
|
||||
let base = self.settling.map_or(r, |s| screen_rect(xf, info, page, s));
|
||||
let to = if let Some((_, grab, start)) = cv.grab {
|
||||
let Some(p) = pointer else { return };
|
||||
dragged(r, grab, p - start, self.picture)
|
||||
} else if let Some(to) = self.settling {
|
||||
let to = screen_rect(xf, info, page, to);
|
||||
dragged(base, grab, p - start, self.picture)
|
||||
} else if self.settling.is_some() {
|
||||
// A moved text box keeps its top edge (its height follows its lines).
|
||||
if self.picture { to } else { r.translate(to.left_top() - r.left_top()) }
|
||||
if self.picture { base } else { r.translate(base.left_top() - r.left_top()) }
|
||||
} else {
|
||||
return;
|
||||
};
|
||||
let painter = painter.with_clip_rect(painter.clip_rect().intersect(xf.rect));
|
||||
xf.paint_image(&painter, background.id(), 0.0, 0.0, 1.0, 1.0);
|
||||
// A pixel around the box, so its edges come along.
|
||||
// A pixel around the box, so its edges come along. Clip the source to the page raster so
|
||||
// an item extending past the page border never samples clamped edge texels (#3938, #4477).
|
||||
let (r, to) = (r.expand(1.0), to.expand(1.0));
|
||||
let clipped = r.intersect(xf.rect);
|
||||
if !clipped.is_positive() || r.width() <= 0.0 || r.height() <= 0.0 {
|
||||
return;
|
||||
}
|
||||
let map = |p: Pos2| Pos2::new(to.min.x + (p.x - r.min.x) / r.width() * to.width(), to.min.y + (p.y - r.min.y) / r.height() * to.height());
|
||||
let mut mesh = egui::Mesh::with_texture(item.id());
|
||||
for (a, b) in [
|
||||
(r.left_top(), to.left_top()),
|
||||
(r.right_top(), to.right_top()),
|
||||
(r.right_bottom(), to.right_bottom()),
|
||||
(r.left_bottom(), to.left_bottom()),
|
||||
] {
|
||||
for a in [clipped.left_top(), clipped.right_top(), clipped.right_bottom(), clipped.left_bottom()] {
|
||||
let (u, v) = xf.screen_to_norm(a);
|
||||
mesh.vertices.push(egui::epaint::Vertex { pos: b, uv: Pos2::new(u, v), color: Color32::WHITE });
|
||||
mesh.vertices.push(egui::epaint::Vertex { pos: map(a), uv: Pos2::new(u, v), color: Color32::WHITE });
|
||||
}
|
||||
mesh.add_triangle(0, 1, 2);
|
||||
mesh.add_triangle(0, 2, 3);
|
||||
@@ -344,7 +345,11 @@ fn dragged(r: Rect, grab: Grab, d: egui::Vec2, keep_aspect: bool) -> Rect {
|
||||
Grab::Corner(left, top) => {
|
||||
let (mut dx, mut dy) = (if left { -d.x } else { d.x }, if top { -d.y } else { d.y });
|
||||
if keep_aspect && r.width() > 0.0 && r.height() > 0.0 {
|
||||
let k = ((r.width() + dx) / r.width()).max((r.height() + dy) / r.height()).max(0.05);
|
||||
// Use the axis with the larger relative pointer change so shrinking along one axis
|
||||
// does not freeze when the other axis has not moved inward (#4477).
|
||||
let (rx, ry) = (dx / r.width(), dy / r.height());
|
||||
let delta = if rx.abs() > ry.abs() { rx } else { ry };
|
||||
let k = (1.0 + delta).max(0.05);
|
||||
dx = r.width() * k - r.width();
|
||||
dy = r.height() * k - r.height();
|
||||
}
|
||||
@@ -373,7 +378,8 @@ pub(crate) fn hit(xf: &PageXform, info: &DocInfo, page: usize, added: &[Added],
|
||||
let items: Vec<(usize, &Added)> = on_page(added, page).into_iter().filter(|(_, a)| matches!(a.content, AddedContent::Text(_))).collect();
|
||||
let selected = view.content.selected.filter(|s| s.0 == page).and_then(|(_, si)| items.iter().find(|(i, _)| *i == si));
|
||||
if let Some((index, a)) = selected
|
||||
&& let Some((_, left, top)) = handles(screen_rect(xf, info, page, a.content.rect())).into_iter().find(|(c, ..)| c.distance(p) <= 6.0)
|
||||
&& let Some((_, left, top)) =
|
||||
handles(screen_rect(xf, info, page, a.content.rect()).expand(1.0)).into_iter().find(|(c, ..)| c.distance(p) <= 6.0)
|
||||
{
|
||||
let icon = if left == top { egui::CursorIcon::ResizeNwSe } else { egui::CursorIcon::ResizeNeSw };
|
||||
return Some(Hit::new(Owner::Added { index: *index, corner: Some((left, top)) }, Layer::Handle, icon));
|
||||
@@ -1596,4 +1602,14 @@ mod tests {
|
||||
assert_eq!(rows(width + 5.0), 2, "the saved text wraps here, so the editor must too");
|
||||
assert_eq!(rows(width + 10.0), 1, "and keeps the line when the spacing fits");
|
||||
}
|
||||
|
||||
/// #4477: dragging a corner handle predominantly inward on one axis must still shrink an image
|
||||
/// proportionally instead of freezing because the other axis did not shrink.
|
||||
#[test]
|
||||
fn corner_resize_shrinks_image_when_dragged_inward_along_one_axis() {
|
||||
let r = Rect::from_min_max(Pos2::new(100.0, 100.0), Pos2::new(200.0, 150.0));
|
||||
let shrunk = dragged(r, Grab::Corner(false, false), egui::vec2(-40.0, 2.0), true);
|
||||
assert!((shrunk.width() - 60.0).abs() < 1e-3, "width shrinks by 40%: {shrunk:?}");
|
||||
assert!((shrunk.height() - 30.0).abs() < 1e-3, "height shrinks proportionally by 40%: {shrunk:?}");
|
||||
}
|
||||
}
|
||||
@@ -528,12 +528,19 @@ fn stretched_picture(painter: &egui::Painter, xf: &PageXform, tex: egui::Texture
|
||||
for r in blank {
|
||||
clipped.rect_filled(*r, CornerRadius::ZERO, Color32::WHITE);
|
||||
}
|
||||
// The raster has no pixels beyond the page. Clip the source and its corresponding
|
||||
// destination together, rather than stretching clamped edge texels into the preview.
|
||||
let source = from.intersect(xf.rect);
|
||||
if !source.is_positive() || !from.is_positive() {
|
||||
return;
|
||||
}
|
||||
let target = egui::emath::RectTransform::from_to(from, to).transform_rect(source);
|
||||
mapped_picture(
|
||||
&clipped,
|
||||
xf,
|
||||
tex,
|
||||
[from.left_top(), from.right_top(), from.right_bottom(), from.left_bottom()],
|
||||
[to.left_top(), to.right_top(), to.right_bottom(), to.left_bottom()],
|
||||
[source.left_top(), source.right_top(), source.right_bottom(), source.left_bottom()],
|
||||
[target.left_top(), target.right_top(), target.right_bottom(), target.left_bottom()],
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1039,6 +1039,177 @@ fn a_resized_added_picture_shows_only_at_its_new_size() {
|
||||
assert!(red(&pixels, kept) > 100 && red(&pixels, gone) == 0, "the page raster has the picture at its new size");
|
||||
}
|
||||
|
||||
/// #4477, #3938: in Edit a PDF, an added image can be resized repeatedly by its corner handles
|
||||
/// (including the outer part of the handle circle and when a corner reaches past the page edge)
|
||||
/// without losing selection to the object marquee, and dragging an out-of-bounds image back onto
|
||||
/// the page does not smear edge pixels across the out-of-bounds half.
|
||||
#[test]
|
||||
fn added_picture_resizes_repeatedly_and_drags_back_from_out_of_bounds_without_smear() {
|
||||
let _gpu = gpu();
|
||||
let mut h = harness();
|
||||
assert!(h.state_mut().execute("edit.edit_text"));
|
||||
h.run_steps(2);
|
||||
let mut png = Vec::new();
|
||||
{
|
||||
let mut enc = png::Encoder::new(&mut png, 60, 60);
|
||||
enc.set_color(png::ColorType::Rgb);
|
||||
let mut w = enc.write_header().unwrap();
|
||||
w.write_image_data(&[200u8, 30, 30].repeat(3600)).unwrap();
|
||||
}
|
||||
h.state_mut().add_image("red.png".into(), png);
|
||||
h.run_steps(3);
|
||||
crate::support::settle(&mut h);
|
||||
|
||||
// 1. Grab the outer part of the lower-right corner handle (where the resize cursor is shown,
|
||||
// outside r.expand(2.0)) and drag inward along one axis to shrink the image.
|
||||
let rect0 = added(&h)[0].content.rect();
|
||||
let r0 = item_on_screen(&h, rect0);
|
||||
let grip1 = r0.expand(1.0).right_bottom() + egui::vec2(2.0, 2.0);
|
||||
h.hover_at(grip1);
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.output().platform_output.cursor_icon, egui::CursorIcon::ResizeNwSe);
|
||||
let to1 = grip1 + egui::vec2(-30.0, 0.0);
|
||||
h.drag_at(grip1);
|
||||
h.run_steps(1);
|
||||
for k in 1..=4 {
|
||||
h.hover_at(grip1 + (to1 - grip1) * (k as f32 / 4.0));
|
||||
h.run_steps(1);
|
||||
}
|
||||
h.drop_at(to1);
|
||||
h.run_steps(3);
|
||||
assert_eq!(h.state().views[0].content.selected, Some((0, 0)), "the added image stays selected after releasing a corner resize");
|
||||
let rect1 = added(&h)[0].content.rect();
|
||||
assert!(rect1[2] - rect1[0] < (rect0[2] - rect0[0]) - 10.0, "first corner resize shrinks the image: {rect0:?} -> {rect1:?}");
|
||||
|
||||
// 2. Immediately resize again right after letting go, stretching past the right page edge,
|
||||
// then shrink back from the out-of-bounds corner handle.
|
||||
let r1 = item_on_screen(&h, rect1);
|
||||
let page_right = h.state().views[0].page_screen_rect(0).unwrap().right();
|
||||
let grip2 = r1.expand(1.0).right_bottom();
|
||||
let to2 = pos2(page_right + 12.0, grip2.y);
|
||||
h.hover_at(grip2);
|
||||
h.run_steps(1);
|
||||
h.drag_at(grip2);
|
||||
h.run_steps(1);
|
||||
for k in 1..=4 {
|
||||
h.hover_at(grip2 + (to2 - grip2) * (k as f32 / 4.0));
|
||||
h.run_steps(1);
|
||||
}
|
||||
h.drop_at(to2);
|
||||
h.run_steps(3);
|
||||
let rect2 = added(&h)[0].content.rect();
|
||||
assert!(rect2[2] > 300.0, "second resize reaches past the page edge: {rect2:?}");
|
||||
|
||||
// Grab the corner handle that now sits outside the page rect and shrink it back inside.
|
||||
let r2 = item_on_screen(&h, rect2);
|
||||
let grip3 = r2.expand(1.0).right_bottom();
|
||||
assert!(!h.state().views[0].page_screen_rect(0).unwrap().contains(grip3), "corner handle lies outside the page rect");
|
||||
let to3 = pos2(page_right - 40.0, grip3.y);
|
||||
h.hover_at(grip3);
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.output().platform_output.cursor_icon, egui::CursorIcon::ResizeNwSe);
|
||||
h.drag_at(grip3);
|
||||
h.run_steps(1);
|
||||
for k in 1..=4 {
|
||||
h.hover_at(grip3 + (to3 - grip3) * (k as f32 / 4.0));
|
||||
h.run_steps(1);
|
||||
}
|
||||
h.drop_at(to3);
|
||||
h.run_steps(3);
|
||||
let rect3 = added(&h)[0].content.rect();
|
||||
assert!(rect3[2] < 300.0, "out-of-bounds corner handle still resizes the image back inside: {rect2:?} -> {rect3:?}");
|
||||
|
||||
// 3. Place the image half below the bottom edge of the page (y = -30..30), wait for the page
|
||||
// raster, then drag it up into the page: the out-of-bounds bottom half must not smear the
|
||||
// page edge's red texels across the lower half during the drag preview.
|
||||
let img = match &added(&h)[0].content {
|
||||
pdfcraft_engine::AddedContent::Image(i) => pdfcraft_engine::AddedImage { rect: [120.0, -30.0, 180.0, 30.0], ..i.clone() },
|
||||
_ => panic!(),
|
||||
};
|
||||
assert!(h.state_mut().apply_edit(pdfcraft_engine::Edit::UpdateContent { page: 0, index: 0, content: pdfcraft_engine::AddedContent::Image(img) }));
|
||||
crate::support::settle(&mut h);
|
||||
let from = item_on_screen(&h, [120.0, -30.0, 180.0, 30.0]);
|
||||
let grab = pos2(from.center().x, from.top() + 10.0);
|
||||
let shift = egui::vec2(0.0, -120.0);
|
||||
h.hover_at(grab);
|
||||
h.run_steps(1);
|
||||
h.drag_at(grab);
|
||||
h.run_steps(1);
|
||||
for k in 1..=4 {
|
||||
h.hover_at(grab + shift * (k as f32 / 4.0));
|
||||
h.run_steps(1);
|
||||
}
|
||||
crate::support::settle(&mut h);
|
||||
let moved = from.translate(shift);
|
||||
let upper_half = egui::Rect::from_min_max(moved.min + egui::vec2(4.0, 4.0), pos2(moved.max.x - 4.0, moved.center().y - 4.0));
|
||||
let lower_half = egui::Rect::from_min_max(pos2(moved.min.x + 4.0, moved.center().y + 4.0), moved.max - egui::vec2(4.0, 4.0));
|
||||
let pixels = h.render().unwrap();
|
||||
if let Ok(dir) = std::env::var("PDFCRAFT_SHOTS") {
|
||||
pixels.save(format!("{dir}/added-image-outside-page-drag.png")).unwrap();
|
||||
}
|
||||
assert!(red(&pixels, upper_half) > 100, "the in-bounds half of the picture follows the drag");
|
||||
assert_eq!(red(&pixels, lower_half), 0, "the out-of-bounds half must not smear edge texels across the lower half");
|
||||
h.drop_at(grab + shift);
|
||||
crate::support::settle(&mut h);
|
||||
}
|
||||
|
||||
/// A short page leaves empty window under the scroll content. A handle dragged into that
|
||||
/// margin has to start another resize after the pointer is released.
|
||||
#[test]
|
||||
fn a_handle_in_the_viewport_below_a_short_page_starts_another_resize() {
|
||||
let _gpu = gpu();
|
||||
let mut h = harness();
|
||||
h.state_mut().set_option("zoom", "50").unwrap();
|
||||
assert!(h.state_mut().execute("edit.edit_text"));
|
||||
h.run_steps(2);
|
||||
let mut png = Vec::new();
|
||||
{
|
||||
let mut enc = png::Encoder::new(&mut png, 60, 60);
|
||||
enc.set_color(png::ColorType::Rgb);
|
||||
let mut w = enc.write_header().unwrap();
|
||||
w.write_image_data(&[200u8, 30, 30].repeat(3600)).unwrap();
|
||||
}
|
||||
h.state_mut().add_image("red.png".into(), png);
|
||||
h.run_steps(3);
|
||||
let img = match &added(&h)[0].content {
|
||||
pdfcraft_engine::AddedContent::Image(i) => pdfcraft_engine::AddedImage { rect: [40.0, 0.0, 100.0, 40.0], ..i.clone() },
|
||||
_ => panic!("added an image"),
|
||||
};
|
||||
assert!(h.state_mut().apply_edit(pdfcraft_engine::Edit::UpdateContent { page: 0, index: 0, content: pdfcraft_engine::AddedContent::Image(img) }));
|
||||
h.run_steps(3);
|
||||
let page = h.state().views[0].page_screen_rect(0).unwrap();
|
||||
let grip = item_on_screen(&h, [40.0, 0.0, 100.0, 40.0]).expand(1.0).right_bottom();
|
||||
let below = pos2(grip.x, page.bottom() + 80.0);
|
||||
assert!(below.y < 880.0, "the margin point stays in the window: {below:?}");
|
||||
h.hover_at(grip);
|
||||
h.run_steps(1);
|
||||
h.drag_at(grip);
|
||||
h.run_steps(1);
|
||||
for k in 1..=4 {
|
||||
h.hover_at(grip + (below - grip) * (k as f32 / 4.0));
|
||||
h.run_steps(1);
|
||||
}
|
||||
h.drop_at(below);
|
||||
h.run_steps(3);
|
||||
let pulled = added(&h)[0].content.rect();
|
||||
assert!(pulled[1] < -5.0, "the first drag leaves the page: {pulled:?}");
|
||||
let grip2 = item_on_screen(&h, pulled).expand(1.0).right_bottom();
|
||||
assert!(grip2.y > page.bottom() + 40.0, "the handle is below the page margin: grip {grip2:?} page {page:?}");
|
||||
let to = pos2(grip2.x, grip2.y - 36.0);
|
||||
h.hover_at(grip2);
|
||||
h.run_steps(1);
|
||||
h.drag_at(grip2);
|
||||
h.run_steps(1);
|
||||
for k in 1..=4 {
|
||||
h.hover_at(grip2 + (to - grip2) * (k as f32 / 4.0));
|
||||
h.run_steps(1);
|
||||
}
|
||||
h.drop_at(to);
|
||||
h.run_steps(3);
|
||||
let again = added(&h)[0].content.rect();
|
||||
assert!(again[1] > pulled[1] + 5.0, "the second press never started a resize: {pulled:?} -> {again:?}");
|
||||
}
|
||||
|
||||
/// Bug A for added text: with one line of the box selected, an alignment applies to that line
|
||||
/// only, as does a second one, the box stays open, and Undo puts it back and closes the box.
|
||||
#[test]
|
||||
@@ -1491,6 +1662,43 @@ fn typing_in_one_tab_never_reaches_another_tabs_added_text() {
|
||||
assert_eq!(h.state().views[1].content.draft.as_ref().map(|d| d.text.as_str()), Some("Reviewed"), "the other tab's typing stays out");
|
||||
}
|
||||
|
||||
/// Out-of-page editing grips do not turn the invisible image body into a context-menu target.
|
||||
#[test]
|
||||
fn image_context_menu_requires_visible_body_even_when_the_frame_crosses_the_page() {
|
||||
for visible in [false, true] {
|
||||
let mut h = harness();
|
||||
assert!(h.state_mut().execute("edit.edit_text"));
|
||||
h.run_steps(2);
|
||||
let mut png = Vec::new();
|
||||
image::RgbImage::from_pixel(20, 20, image::Rgb([200, 30, 30]))
|
||||
.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png)
|
||||
.unwrap();
|
||||
h.state_mut().add_image("edge.png".into(), png);
|
||||
h.run_steps(3);
|
||||
let image = match &added(&h)[0].content {
|
||||
pdfcraft_engine::AddedContent::Image(i) => pdfcraft_engine::AddedImage { rect: [260.0, 150.0, 340.0, 230.0], ..i.clone() },
|
||||
_ => panic!("added an image"),
|
||||
};
|
||||
assert!(h.state_mut().apply_edit(pdfcraft_engine::Edit::UpdateContent {
|
||||
page: 0,
|
||||
index: 0,
|
||||
content: pdfcraft_engine::AddedContent::Image(image),
|
||||
}));
|
||||
h.run_steps(3);
|
||||
h.state_mut().views[0].image_selection = Some(pdfcraft_ui_egui::ImageSelection::at(0, 0));
|
||||
let x = if visible { 280.0 } else { 320.0 };
|
||||
let point = item_on_screen(&h, [x, 190.0, x, 190.0]).center();
|
||||
assert_eq!(h.state().views[0].page_screen_rect(0).unwrap().contains(point), visible);
|
||||
h.hover_at(point);
|
||||
h.run_steps(1);
|
||||
h.event(egui::Event::PointerButton { pos: point, button: egui::PointerButton::Secondary, pressed: true, modifiers: Default::default() });
|
||||
h.event(egui::Event::PointerButton { pos: point, button: egui::PointerButton::Secondary, pressed: false, modifiers: Default::default() });
|
||||
h.run_steps(2);
|
||||
// Flip horizontal also appears in the persistent sidebar; Arrange image is menu-only.
|
||||
assert_eq!(h.query_all_by_label("Arrange image").next().is_some(), visible, "image menu requires visible body");
|
||||
}
|
||||
}
|
||||
|
||||
/// The rail's page and zoom fields are the same size, and each sits wholly inside the rail, so
|
||||
/// neither box is cut off at the edge (the page box lost its right side once the rail's contents
|
||||
/// changed width).
|
||||
|
||||
Reference in new issue
Block a user