diff --git a/apps/ely-pdf/src/content.rs b/apps/ely-pdf/src/content.rs index 4ec84f3..a718080 100644 --- a/apps/ely-pdf/src/content.rs +++ b/apps/ely-pdf/src/content.rs @@ -76,6 +76,10 @@ pub struct Drag { pub from: (f32, f32), pub now: (f32, f32), pub scale: bool, + /// Where the press landed, in window pixels. + pub pressed: (f32, f32), + /// The pointer went past a click's slack. + pub moved: bool, } /// The corner handle's reach, in page points. @@ -107,6 +111,11 @@ fn inside(rect: &[f32; 4], (x, y): (f32, f32)) -> bool { x >= rect[0] && x <= rect[2] && y >= rect[1] && y <= rect[3] } +/// Whether `outer` holds all of `inner`. +fn covers(outer: &[f32; 4], inner: &[f32; 4]) -> bool { + outer[0] <= inner[0] && outer[1] <= inner[1] && outer[2] >= inner[2] && outer[3] >= inner[3] +} + impl PdfView { /// A drag or drawing is under way. pub fn gesturing(&self) -> bool { @@ -174,16 +183,21 @@ impl PdfView { Some(Aim::Object { page: on, index }) if on == page => layer.drawn.get(index).filter(|d| corner(&d.rect)).map(|_| (index, true)), _ => None, }; - let hit = held.or_else(|| layer.drawn.iter().rposition(|d| inside(&d.rect, point)).map(|index| (index, false))); + let text = layer.blocks.iter().position(|b| b.editable && inside(&b.rect, point)); + // Text wins over an object drawn all around it. + let around = |rect: &[f32; 4]| text.is_some_and(|t| covers(rect, &layer.blocks[t].rect)); + let hit = held.or_else(|| { + let object = layer.drawn.iter().rposition(|d| inside(&d.rect, point) && !around(&d.rect)); + object.map(|index| (index, false)) + }); self.aim = match hit { Some((index, scale)) => { let rect = layer.drawn[index].rect; - self.drag = Some(Drag { page, index, rect, from: point, now: point, scale }); + let pressed = (f32::from(at.x), f32::from(at.y)); + self.drag = Some(Drag { page, index, rect, from: point, now: point, scale, pressed, moved: false }); Some(Aim::Object { page, index }) } - None => (layer.blocks.iter()) - .position(|b| b.editable && inside(&b.rect, point)) - .map(|index| Aim::Text { page, index }), + None => text.map(|index| Aim::Text { page, index }), }; cx.notify(); } @@ -193,6 +207,8 @@ impl PdfView { return; }; if let Some(drag) = self.drag.as_mut().filter(|drag| drag.page == page) { + let far = (f32::from(at.x) - drag.pressed.0).abs() + (f32::from(at.y) - drag.pressed.1).abs(); + drag.moved |= far >= crate::annotate::CLICK; drag.now = point; cx.notify(); } @@ -203,7 +219,8 @@ impl PdfView { let Some(drag) = self.drag.take() else { return; }; - if (drag.now.0 - drag.from.0).abs() < 1.0 && (drag.now.1 - drag.from.1).abs() < 1.0 { + // A hand that shakes a little still clicks. + if !drag.moved { return cx.notify(); } // A queued edit may renumber what was dragged. @@ -264,7 +281,7 @@ mod tests { #[test] fn a_drag_moves_or_scales_from_the_top_left() { - let mut drag = Drag { page: 0, index: 0, rect: [10.0, 20.0, 110.0, 70.0], from: (50.0, 40.0), now: (60.0, 45.0), scale: false }; + let mut drag = Drag { page: 0, index: 0, rect: [10.0, 20.0, 110.0, 70.0], from: (50.0, 40.0), now: (60.0, 45.0), scale: false, pressed: (0.0, 0.0), moved: true }; assert_eq!(drag.change(), [1.0, 0.0, 0.0, 1.0, 10.0, 5.0]); assert_eq!(drag.shown(), [20.0, 25.0, 120.0, 75.0]); drag = Drag { from: (110.0, 70.0), now: (210.0, 80.0), scale: true, ..drag }; diff --git a/apps/ely-pdf/src/tests/editing.rs b/apps/ely-pdf/src/tests/editing.rs index b5c9164..b089149 100644 --- a/apps/ely-pdf/src/tests/editing.rs +++ b/apps/ely-pdf/src/tests/editing.rs @@ -1,6 +1,6 @@ //! Editing text and objects from the app (C11.3). -use ely_pdf_engine::sample::{Extras, pdf_with}; +use ely_pdf_engine::sample::{Extras, one_page, pdf_with}; use gpui::{Entity, Modifiers, MouseButton, TestAppContext, VisualTestContext}; use super::input::over; @@ -339,3 +339,61 @@ fn a_drag_follows_the_hand_when_the_page_shifts_under_it() { let rect = cx.update(|_, app| view.read(app).layers[&0].drawn[1].rect); assert!((rect[0] - 122.0).abs() < 0.5, "moved as far as the hand: {rect:?}"); } + +#[test] +fn text_on_a_full_page_picture_aims_at_the_text_and_a_shake_stays_a_click() { + let dir = tempfile::tempdir().expect("a temp dir"); + let mut cx = TestAppContext::single(); + let path = dir.path().join("scan.pdf"); + let extras = Extras { picture: true, backdrop: true, ..Extras::default() }; + std::fs::write(&path, pdf_with(&[(612.0, 792.0, &["Hello world"])], &[], &extras)).expect("writes"); + let (viewer, cx) = windowed(&mut cx, dir.path(), &[path], true); + let view = shown(cx, &viewer); + settle(cx, |app| view.read(app).room.size.width > gpui::px(100.0)); + cx.update(|_, app| view.update(app, |view, cx| { + view.zoom = crate::layout::Zoom::Scale(1.0); + view.set_tool(Tool::Edit, cx) + })); + settle(cx, |app| view.read(app).layers.contains_key(&0)); + click(cx, &view, (80.0, 66.0)); + assert!(matches!(cx.update(|_, app| view.read(app).aim), Some(Aim::Text { .. })), "the text, not the picture under it"); + // A two-pixel wobble on the picture moves nothing. + let from = over(cx, &view, 0, (300.0, 400.0)); + cx.simulate_mouse_down(from, MouseButton::Left, Modifiers::none()); + let to = from + gpui::point(gpui::px(2.0), gpui::px(0.0)); + cx.simulate_mouse_move(to, MouseButton::Left, Modifiers::none()); + cx.simulate_mouse_up(to, MouseButton::Left, Modifiers::none()); + frames(cx); + assert!(matches!(cx.update(|_, app| view.read(app).aim), Some(Aim::Object { .. })), "the picture is aimed"); + assert_eq!(cx.update(|_, app| view.read(app).edited), 0, "and not moved"); +} + +#[test] +fn a_small_picture_on_text_under_a_page_form_still_aims() { + let dir = tempfile::tempdir().expect("a temp dir"); + let mut cx = TestAppContext::single(); + let path = dir.path().join("layered.pdf"); + // The page-size form comes last, on top of both. + let content = "q 10 0 0 10 80 722 cm /Im1 Do Q BT /F1 18 Tf 72 720 Td (Hello world) Tj ET q /Fm1 Do Q"; + let form = "0 0 1 rg 500 50 20 20 re f"; + let objects = [ + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".to_string(), + format!("<< /Type /XObject /Subtype /Form /BBox [0 0 612 792] /Length {} >>\nstream\n{form}\nendstream", form.len()), + "<< /Type /XObject /Subtype /Image /Width 1 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8 /Length 1 >>\nstream\nA\nendstream".to_string(), + ]; + let page = " /Resources << /Font << /F1 5 0 R >> /XObject << /Fm1 6 0 R /Im1 7 0 R >> >>"; + std::fs::write(&path, one_page("", page, content, &objects)).expect("writes"); + let (viewer, cx) = windowed(&mut cx, dir.path(), &[path], true); + let view = shown(cx, &viewer); + settle(cx, |app| view.read(app).room.size.width > gpui::px(100.0)); + cx.update(|_, app| view.update(app, |view, cx| { + view.zoom = crate::layout::Zoom::Scale(1.0); + view.set_tool(Tool::Edit, cx) + })); + settle(cx, |app| view.read(app).layers.contains_key(&0)); + // The picture sits at 80..90 across, 60..70 down. + click(cx, &view, (85.0, 65.0)); + assert!(matches!(cx.update(|_, app| view.read(app).aim), Some(Aim::Object { index: 0, .. })), "the small picture"); + click(cx, &view, (140.0, 66.0)); + assert!(matches!(cx.update(|_, app| view.read(app).aim), Some(Aim::Text { .. })), "the text, not the form around it"); +} diff --git a/crates/ely-pdf-engine/src/sample.rs b/crates/ely-pdf-engine/src/sample.rs index 3734bd2..b43599c 100644 --- a/crates/ely-pdf-engine/src/sample.rs +++ b/crates/ely-pdf-engine/src/sample.rs @@ -26,6 +26,8 @@ pub struct Extras<'a> { pub stamp: Option<&'a str>, /// Page one: a 2x2 image, 100x50 at 72, 500. pub picture: bool, + /// With `picture`: page one draws it full-page first. + pub backdrop: bool, /// Fields hang under one parent of this name. pub parent: Option<&'a str>, /// A parent and child field with no widget. @@ -95,7 +97,11 @@ pub fn pdf_with(pages: &[(f32, f32, &[&str])], marks: &[Mark], extras: &Extras) _ => b"<< /Type /Outlines /Count 0 >>".to_vec(), }); for (n, (w, h, lines)) in pages.iter().enumerate() { - let mut stream = String::from("BT /F1 18 Tf 72 0 Td\n"); + let mut stream = match n == 0 && extras.backdrop { + true => format!("q {w} 0 0 {h} 0 0 cm /Im1 Do Q\n"), + false => String::new(), + }; + stream.push_str("BT /F1 18 Tf 72 0 Td\n"); for (at, line) in lines.iter().enumerate() { let y = h - 72.0 - at as f32 * 24.0; stream.push_str(&format!("1 0 0 1 72 {y} Tm ({line}) Tj\n")); diff --git a/tasks/BUGS.md b/tasks/BUGS.md index 117be61..2b0a130 100644 --- a/tasks/BUGS.md +++ b/tasks/BUGS.md @@ -35,7 +35,7 @@ - [ ] S-10 开始放映的两处拒绝无提示(规则 2) - [x] P-01 文件系统的 `PermissionDenied` 被拼成不存在的文案键,应用崩溃 — worker 的拒绝改为自有错误类型 Refused;R1 测试不跨平台、不看提示;组件库 f834f55 加 Toaster::shown;R2 PASS - [x] P-02 每次编辑落地清掉全部图块与缩略图,页面空白、刚画的墨迹消失 — 图块随 R2.6;缩略图在编辑后留到重画;R1、R2 测试未证实际绘制;R3 PASS -- [ ] P-03 “编辑文字和图片”先命中图片对象再命中文字;一点抖动就移动了背景图 +- [x] P-03 “编辑文字和图片”先命中图片对象再命中文字;一点抖动就移动了背景图 — 包住文字块的对象让位于文字,移动须过 3 个窗口像素;R1 漏掉同点的小图;R2 PASS - [ ] P-04 `TEXT_BLOCKS` 对每个文字块重跑一遍整页内容过滤,编辑工具长时间无反应 - [ ] P-05 连续快画或快点被拒:“文档在此间已改动,未执行” - [ ] P-06 默认字体“原字体”在 Type 1/Type 3 字体的文件上失败,只给引擎原话 diff --git a/tasks/reviews/P-03-r1.md b/tasks/reviews/P-03-r1.md new file mode 100644 index 0000000..7c42823 --- /dev/null +++ b/tasks/reviews/P-03-r1.md @@ -0,0 +1,3 @@ +FAIL + +apps/ely-pdf/src/content.rs:190 — `rposition` 先选中最后一个对象,再过滤包围文字的对象,过滤后直接落到文字,漏掉同点命中的小图片,影响 PDF-04 的图片选择与编辑。实际 worker + GPUI 探针复现:文字框为 [72,52.65,161.01001,77.382],小图片为 [80,67,90,77],随后绘制一个 BBox 为 [0,0,612,792]、仅在远处画色块的 Form;点击可见小图片中心 (85,70),得到 `Aim::Text`,小图片整个范围均落在文字框内,无法选中来移动、替换或删除。把 `!around(&d.rect)` 放进 `rposition` 的谓词后,同一探针选中图片并通过。修复方向:反向查找时逐个跳过包围文字的对象,继续检查其余命中对象;补充文字、小图片和包围 Form 同时存在的回归测试。 diff --git a/tasks/reviews/P-03-r2.md b/tasks/reviews/P-03-r2.md new file mode 100644 index 0000000..7ef22e9 --- /dev/null +++ b/tasks/reviews/P-03-r2.md @@ -0,0 +1 @@ +PASS