From 44e8eeb28921690fcc80a16fe1dda19b041da350 Mon Sep 17 00:00:00 2001 From: Ahmad Mu <55625580+ahmadmuj@users.noreply.github.com> Date: Fri, 23 Oct 2026 18:43:05 +0400 Subject: [PATCH] Cancel stale Organize page drags after document changes (#4039) * M4: Cancel stale Organize page drags after document changes * M4: Record Organize drag cancellation coverage --- ROADMAP.md | 3 + crates/ui-egui/src/canvas.rs | 3 +- crates/ui-egui/tests/it/main.rs | 1 + crates/ui-egui/tests/it/organize_drag.rs | 192 +++++++++++++++++++++++ docs/roadmap.md | 3 +- 5 files changed, 200 insertions(+), 2 deletions(-) create mode 100644 crates/ui-egui/tests/it/organize_drag.rs diff --git a/ROADMAP.md b/ROADMAP.md index f4246c30a9..5634d6bf28 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -190,6 +190,8 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md | 8 | 1.0 polish: performance, right-to-left, missing languages, keyboard-only (M14) | 55–95 | ## Progress log + +- **2026-10-22 (M4, #3916 partial):** Document edits and Undo/Redo cancel an Organize page drag. Pointer-release regressions check exact page order and save/reopen. - **2026-10-22 (M14, macOS association recovery candidate):** Successful desktop open/save/sign can queue recovery of an existing per-file binding to the running PDFCraft bundle through NSWorkspace. Metadata-only watcher changes retain undo and avoid false overwrite prompts; real same-length/same-mtime edits remain protected. Rebased on 0.12.0 and reuses #3042's shared atomic metadata preservation; the bounded recovery parser does not narrow save limits. Prior native Preview TEST-signature and Finder workflows passed on macOS 27.0.1; other versions remain unqualified. Estimates unchanged. - **2026-10-13 (M7, free image rotation):** A selected page image has a rotate knob (Shift: 15 degree steps) and `image_edit` a `rotate_by` action; the angle is written as one undoable `cm`. Snapping to page edges while moving is in #1740; resize snapping is not done. Estimates unchanged. @@ -638,6 +640,7 @@ Newest first: the date, what moved, and the new overall estimate. | Date | Change | Summary | |---|---|---| +| 2026-10-22 | minor | M4, #3916 partial: document edits and Undo/Redo cancel Organize drags. Pointer-release and save/reopen regressions check page order. Estimates unchanged. | | 2026-10-20 | minor | M4: initial-view window and interface options resolve indirect boolean ViewerPreferences values; synthetic true/false and save/reopen regressions retain defaults for malformed entries. Estimates unchanged. | | 2026-10-20 | minor | M6, #3502: form detection preserves comments across content streams; estimates unchanged. | | 2026-10-20 | minor | M10, #3532 partial: vector printing retains existing appearances with absent Subtype; estimates unchanged. | diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index f8df86fb33..1af2a6a4f3 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -876,8 +876,9 @@ impl DocView { self.goto_point = None; self.zoom_anchor = None; self.flash = None; - // A thumbnail drag holds page indexes from before the change. + // Thumbnail drags hold page indexes from before the change. self.panel_drag = None; + self.org_drag = None; self.reset_coarse_state(); } diff --git a/crates/ui-egui/tests/it/main.rs b/crates/ui-egui/tests/it/main.rs index 7c04df20e3..36834999e6 100644 --- a/crates/ui-egui/tests/it/main.rs +++ b/crates/ui-egui/tests/it/main.rs @@ -49,6 +49,7 @@ mod links; mod marks; mod object_selection; mod ocr; +mod organize_drag; mod pageboxes; mod panel_snapshots; mod pdf_links; diff --git a/crates/ui-egui/tests/it/organize_drag.rs b/crates/ui-egui/tests/it/organize_drag.rs new file mode 100644 index 0000000000..f64ef95b1a --- /dev/null +++ b/crates/ui-egui/tests/it/organize_drag.rs @@ -0,0 +1,192 @@ +//! A document edit cancels an Organize drag before old page indices can be used. + +use egui::{Modifiers, PointerButton, Pos2}; +use egui_kittest::Harness; +use egui_kittest::kittest::Queryable; +use pdfcraft_engine::{AddedText, Edit, Session}; +use pdfcraft_render::{PageRenderer, RenderRequest, RequestKind}; +use pdfcraft_ui_egui::PdfCraftApp; + +fn document(prefix: &str) -> std::sync::Arc> { + // Use the engine's synthetic document builder rather than another PDF serializer. + let mut session = Session::new(); + let bytes = session.create_blank(200.0, 300.0, 4).unwrap(); + let id = session.open("pages.pdf", None, bytes, None).unwrap(); + for page in 0..4 { + session + .apply( + id, + Edit::AddText { + page, + text: AddedText { text: format!("{prefix} {}", page + 1), rect: [20.0, 120.0, 180.0, 150.0], ..Default::default() }, + }, + ) + .unwrap(); + } + session.save_bytes(id).unwrap() +} + +fn organize() -> Harness<'static, PdfCraftApp> { + let bytes = document("Page"); + 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("pages.pdf", None, bytes.as_ref().clone()).unwrap(); + app.set_option("organize", "on").unwrap(); + app + }); + h.run_steps(4); + assert_eq!(page_texts(h.state()), ["Page 1", "Page 2", "Page 3", "Page 4"]); + h +} + +fn page_texts(app: &PdfCraftApp) -> Vec { + let doc = app.session.get(app.views[0].id).unwrap(); + let mut renderer = PageRenderer::new(doc.bytes.clone(), Default::default()); + (0..renderer.page_count()) + .map(|page| { + renderer + .render(RenderRequest { page, kind: RequestKind::Text, scale: 1.0, ..Default::default() }) + .text + .unwrap() + .plain_text() + .trim() + .to_string() + }) + .collect() +} + +fn copy_modifier(h: &Harness<'static, PdfCraftApp>, copying: bool) -> Modifiers { + if !copying { + Modifiers::NONE + } else if h.ctx.os() == egui::os::OperatingSystem::Mac { + Modifiers::ALT + } else { + Modifiers::CTRL + } +} + +fn start_drag(h: &mut Harness<'static, PdfCraftApp>, copying: bool) -> (Pos2, Modifiers) { + let from = h.get_by_label("Page 1").rect().center(); + let to = h.get_by_label("Page 3").rect().right_center() - egui::vec2(10.0, 0.0); + let modifiers = copy_modifier(h, copying); + h.event(egui::Event::ModifiersChanged(modifiers)); + h.hover_at(from); + h.run_steps(1); + h.event(egui::Event::PointerButton { pos: from, button: PointerButton::Primary, pressed: true, modifiers }); + h.run_steps(1); + for step in 1..=5 { + h.hover_at(from + (to - from) * (step as f32 / 5.0)); + h.run_steps(1); + } + (to, modifiers) +} + +fn release(h: &mut Harness<'static, PdfCraftApp>, to: Pos2, modifiers: Modifiers) { + h.event(egui::Event::PointerButton { pos: to, button: PointerButton::Primary, pressed: false, modifiers }); + h.run_steps(4); + h.event(egui::Event::ModifiersChanged(Modifiers::NONE)); + h.run_steps(1); +} + +fn assert_saved_order(h: &Harness<'static, PdfCraftApp>, expected: &[&str]) { + let app = h.state(); + let bytes = app.session.save_bytes(app.views[0].id).unwrap(); + let mut reopened = PdfCraftApp::new(); + reopened.open_bytes("saved.pdf", None, bytes.as_ref().clone()).unwrap(); + assert_eq!(page_texts(&reopened), expected, "save/reopen preserves the post-edit order"); +} + +fn check_undo_cancels_drag(copying: bool) { + let mut h = organize(); + assert!(h.state_mut().apply_edit(Edit::MovePages { pages: vec![0], to: 3 })); + h.run_steps(2); + assert_eq!(page_texts(h.state()), ["Page 2", "Page 3", "Page 4", "Page 1"]); + let (to, modifiers) = start_drag(&mut h, copying); + // The source index now belongs to Page 2. Undo changes it back to Page 1. + h.state_mut().undo(); + h.run_steps(2); + assert_eq!(page_texts(h.state()), ["Page 1", "Page 2", "Page 3", "Page 4"]); + h.hover_at(to - egui::vec2(1.0, 0.0)); + h.run_steps(2); + release(&mut h, to, modifiers); + let expected = ["Page 1", "Page 2", "Page 3", "Page 4"]; + assert_eq!(page_texts(h.state()), expected, "releasing the old drag must not move or copy a different page"); + assert_eq!(h.state().session.get(h.state().views[0].id).unwrap().can_redo(), Some("Move page"), "the cancelled gesture leaves Redo available"); + assert_saved_order(&h, &expected); + h.state_mut().redo(); + h.run_steps(2); + assert_eq!(page_texts(h.state()), ["Page 2", "Page 3", "Page 4", "Page 1"], "the preserved Redo still applies the original edit"); + h.state_mut().undo(); + h.run_steps(2); + assert_eq!(page_texts(h.state()), expected); + + // Cancellation must not disable the next ordinary drag or its history. + let (to, modifiers) = start_drag(&mut h, copying); + release(&mut h, to, modifiers); + let changed = if copying { vec!["Page 1", "Page 2", "Page 3", "Page 1", "Page 4"] } else { vec!["Page 2", "Page 3", "Page 1", "Page 4"] }; + assert_eq!(page_texts(h.state()), changed); + h.state_mut().undo(); + h.run_steps(2); + assert_eq!(page_texts(h.state()), expected); + h.state_mut().redo(); + h.run_steps(2); + assert_eq!(page_texts(h.state()), changed); + assert_saved_order(&h, &changed); +} + +#[test] +fn undo_cancels_an_organize_move_before_pointer_release() { + check_undo_cancels_drag(false); +} + +#[test] +fn undo_cancels_an_organize_copy_before_pointer_release() { + check_undo_cancels_drag(true); +} + +#[test] +fn redo_cancels_an_organize_drag_before_pointer_release() { + let mut h = organize(); + assert!(h.state_mut().apply_edit(Edit::MovePages { pages: vec![0], to: 3 })); + h.state_mut().undo(); + h.run_steps(2); + let (to, modifiers) = start_drag(&mut h, false); + h.state_mut().redo(); + h.run_steps(2); + release(&mut h, to, modifiers); + let expected = ["Page 2", "Page 3", "Page 4", "Page 1"]; + assert_eq!(page_texts(h.state()), expected, "Redo cancels the old drag even though its source index remains valid"); + assert_saved_order(&h, &expected); +} + +#[test] +fn deleting_a_page_cancels_an_organize_drag_before_pointer_release() { + let mut h = organize(); + let (to, modifiers) = start_drag(&mut h, false); + assert!(h.state_mut().apply_edit(Edit::DeletePages { pages: vec![0] })); + h.run_steps(2); + release(&mut h, to, modifiers); + let expected = ["Page 2", "Page 3", "Page 4"]; + assert_eq!(page_texts(h.state()), expected, "a page edit cancels the old drag before indices shift"); + assert_saved_order(&h, &expected); +} + +/// Replacement already starts a fresh view; valid old indices must not survive its reset. +#[test] +fn replacing_a_document_cancels_an_organize_drag_before_pointer_release() { + let mut h = organize(); + let (to, modifiers) = start_drag(&mut h, false); + let bytes = document("Replacement"); + let app = h.state_mut(); + let id = app.views[0].id; + app.session.reload(id, bytes).unwrap(); + let info = &app.session.get(id).unwrap().info; + app.views[0].document_replaced(info); + let expected = ["Replacement 1", "Replacement 2", "Replacement 3", "Replacement 4"]; + assert_eq!(page_texts(h.state()), expected, "the new content replaces all four pages before release"); + h.run_steps(2); + release(&mut h, to, modifiers); + assert_eq!(page_texts(h.state()), expected, "releasing the old drag must not reorder replacement pages"); + assert_saved_order(&h, &expected); +} diff --git a/docs/roadmap.md b/docs/roadmap.md index 84876c54f3..bb542aefc7 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -162,7 +162,7 @@ total matches the parity estimate. | M2 | Model, render, text | 200–350 | 15% | 130–230 | hayro bootstrap renderer (vendored patches); inspector predictor scratch is bounded by available input, and JPX sample rescaling handles 1–32 bits safely. Text extraction reaches word-F1 0.98 against pdftotext. Short same-line redraws split before placement rather than interleaving. A custom text match that ends on a reconstructed separator stops before the next glyph (#1315). Missing: model crate, fonts, DisplayList, renderer independent of hayro. Inspector page-label traversal and output are resource-bounded, with physical-number fallback warnings | | M2 | Model, render, text | 200–350 | 15% | 130–230 | hayro bootstrap renderer (vendored patches); Lab Bradford adaptation uses LMS white-point ratios, covered by independent neutral/chromatic and direct/Indexed render oracles; inspector predictor scratch is bounded by available input, and JPX sample rescaling handles 1–32 bits safely. Text extraction reaches word-F1 0.98 against pdftotext. Short same-line redraws split before placement rather than interleaving. Missing: model crate, fonts, DisplayList, renderer independent of hayro. Inspector page-label traversal and output are resource-bounded, with physical-number fallback warnings. Literal page-label prefix whitespace is preserved; nine public-inspection cases cover prefix spaces, encodings and empty-prefix controls | | M3 | Viewer app (native + web) | 80–150 | 90% | 10–20 | Acrobat-style shell, find, select, panels, tiles, web build, UI control channel for agents (opt-in). Missing: 60 fps test on a 500-page document, snapshot tests of every panel
Done since: fit visible, document title in the window; Linux middle-button scrolling; shared three-state theme preferences in the toolbar, View menu and control channel; numeric Linux portal events and idle wakeups completing #881, with native appearance inheritance retained (#446); late browser startup downloads preserve the current document and Save target (#171). | -| M4 | Engine, history, save, organize | 100–180 | 95% | 8–15 | Done:
- command registry (menus, shortcuts and palette all use it);
- undo/redo; incremental, atomic and encrypted saves;
- autosave and crash recovery;
- organize, combine, extract, split and insert-from-file, with identical fonts and images stored once;
- CLI `edit/combine/extract/split`.
Done since: Organize opens directly into the page overview, with checkbox selection, an editable page-range selection (#1092), and per-page rotate/delete controls; Combine files in its own tab (sortable, resizable columns; folders; early warnings; unlocking protected files), bookmark editing, page labels (Number pages), CLI `run`, Set Page Boxes, Crop tool, Duplicate pages, Ctrl+A / Cmd+A to select all pages in Organize, Replace pages, bookmarks from tagged headings. Extract and Split preserve shared Type 3 glyph resource graphs, retaining conservative pruning limits. Undo back to the opened or last saved file is clean; Revert opens that saved file after an undo; a later save appends to it so the saved revision stays in the file (#1310, #1311, #1312). Ctrl-drag (Option on macOS) on a grid thumbnail copies the pages into the drop gap, with a "+" badge and the copies selected (#2997). Initial-view window and interface options resolve indirect boolean preferences. Done since: #3309: Stack-safe named-destination pruning visits shared/cyclic indirect nodes once, without a name-count limit. Synthetic deep-tree and 60,000-name tests verify deletion, surviving targets, undo/redo, both save modes and reopening. Missing: page transitions, recovery on the web | +| M4 | Engine, history, save, organize | 100–180 | 95% | 8–15 | Done:
- command registry (menus, shortcuts and palette all use it);
- undo/redo; incremental, atomic and encrypted saves;
- autosave and crash recovery;
- organize, combine, extract, split and insert-from-file, with identical fonts and images stored once;
- CLI `edit/combine/extract/split`.
Done since: Organize opens directly into the page overview, with checkbox selection, an editable page-range selection (#1092), and per-page rotate/delete controls; Combine files in its own tab (sortable, resizable columns; folders; early warnings; unlocking protected files), bookmark editing, page labels (Number pages), CLI `run`, Set Page Boxes, Crop tool, Duplicate pages, Ctrl+A / Cmd+A to select all pages in Organize, Replace pages, bookmarks from tagged headings. Extract and Split preserve shared Type 3 glyph resource graphs, retaining conservative pruning limits. Undo back to the opened or last saved file is clean; Revert opens that saved file after an undo; a later save appends to it so the saved revision stays in the file (#1310, #1311, #1312). Ctrl-drag (Option on macOS) on a grid thumbnail copies the pages into the drop gap, with a "+" badge and the copies selected (#2997). Initial-view window and interface options resolve indirect boolean preferences. Done since: #3309: Stack-safe named-destination pruning visits shared/cyclic indirect nodes once, without a name-count limit. Synthetic deep-tree and 60,000-name tests verify deletion, surviving targets, undo/redo, both save modes and reopening. Organize drags cancel after document edits and Undo/Redo (#3916 partial). Missing: page transitions, recovery on the web | | M4 | Engine, history, save, organize | 100–180 | 95% | 8–15 | Done:
- command registry (menus, shortcuts and palette all use it);
- undo/redo; incremental, atomic and encrypted saves;
- autosave and crash recovery;
- organize, combine, extract, split and insert-from-file, with identical fonts and images stored once;
- CLI `edit/combine/extract/split`.
Done since: Organize opens directly into the page overview, with checkbox selection, an editable page-range selection (#1092), and per-page rotate/delete controls; Combine files in its own tab (sortable, resizable columns; folders; early warnings; unlocking protected files), bookmark editing, page labels (Number pages), CLI `run`, Set Page Boxes, Crop tool, Duplicate pages, Ctrl+A / Cmd+A to select all pages in Organize, Replace pages, bookmarks from tagged headings. Extract and Split preserve shared Type 3 glyph resource graphs, retaining conservative pruning limits. Undo back to the opened or last saved file is clean; Revert opens that saved file after an undo; a later save appends to it so the saved revision stays in the file (#1310, #1311, #1312). Rotation queries and edits resolve indirect values on pages and ancestors. Refreshed against upstream `99941daa` (2026-10-15). Repeated page indices accumulate rotations from the latest local value (#1919); saved/reopened regressions retain inherited angles and siblings. Missing: page transitions, recovery on the web | | M4 | Engine, history, save, organize | 100–180 | 95% | 8–15 | Done:
- command registry (menus, shortcuts and palette all use it);
- undo/redo; incremental, atomic and encrypted saves;
- autosave and crash recovery;
- organize, combine, extract, split and insert-from-file, with identical fonts and images stored once;
- CLI `edit/combine/extract/split`.
Done since: Organize opens directly into the page overview, with checkbox selection, an editable page-range selection (#1092), and per-page rotate/delete controls; Combine files in its own tab (sortable, resizable columns; folders; early warnings; unlocking protected files), bookmark editing, page labels (Number pages), CLI `run`, Set Page Boxes, Crop tool, Duplicate pages, Ctrl+A / Cmd+A to select all pages in Organize, Replace pages, bookmarks from tagged headings. Extract and Split preserve shared Type 3 glyph resource graphs, retaining conservative pruning limits. Undo back to the opened or last saved file is clean; Revert opens that saved file after an undo; a later save appends to it so the saved revision stays in the file (#1310, #1311, #1312). Rotation queries and edits resolve indirect values on pages and ancestors. Missing: page transitions, recovery on the web | | M4 | Engine, history, save, organize | 100–180 | 95% | 8–15 | Done:
- command registry (menus, shortcuts and palette all use it);
- undo/redo; incremental, atomic and encrypted saves;
- autosave and crash recovery;
- organize, combine, extract, split and insert-from-file, with identical fonts and images stored once;
- CLI `edit/combine/extract/split`.
Done since: Organize opens directly into the page overview, with checkbox selection, an editable page-range selection (#1092), and per-page rotate/delete controls; Combine files in its own tab (sortable, resizable columns; folders; early warnings; unlocking protected files), bookmark editing, page labels (Number pages), CLI `run`, Set Page Boxes, Crop tool, Duplicate pages, Ctrl+A / Cmd+A to select all pages in Organize, Replace pages, bookmarks from tagged headings. Extract and Split preserve shared Type 3 glyph resource graphs, retaining conservative pruning limits. Missing: page transitions, recovery on the web M4: Insert-from-file preserves source bookmarks; integration tests retained with current main. | @@ -239,6 +239,7 @@ M2 (renderer), M7 (content editing), M10 (OCR and Office), M11 (Preflight and st | Date | Change | Summary | |---|---|---| +| 2026-10-22 | minor | M4, #3916 partial: edits and Undo/Redo cancel Organize page drags. Pointer-release and save/reopen regressions check page order. Estimates unchanged. | | 2026-10-22 | minor | M14 #3558: rebased macOS association recovery; reuse #3042 shared metadata preservation instead of a duplicate save wrapper. Estimates unchanged. | | 2026-10-20 | minor | M4: initial-view window and interface options resolve indirect boolean ViewerPreferences values; synthetic true/false and save/reopen regressions retain defaults for malformed entries. Estimates unchanged. | | 2026-10-21 | minor | M3, #3808: the Pages inspector has an independent 50–150% thumbnail size slider and buttons; selection and current-page following are retained, with headless ui.set coverage. Estimates unchanged. |