//! Same-page mixed selection in Edit a PDF. Source references belong to one document //! generation; a drag previews geometry and commits one engine edit on release. use std::collections::HashMap; use egui::{Color32, Pos2, Rect, Stroke}; use pdfcraft_engine::{Document, Edit, EditableObject, MAX_MOVE_OBJECTS, ObjectKind, ObjectTarget}; use pdfcraft_render::DocInfo; use crate::canvas::{DocView, PageXform}; const BLUE: Color32 = Color32::from_rgb(0x14, 0x73, 0xE6); #[derive(Default)] pub(crate) struct ObjectSelection { generation: Option, cache: HashMap, String>>, page: Option, selected: Vec, gesture: Option, /// Expected geometry after our own edit, for remapping indexes that grouping can change. pending: Option>, pub notice: Option, pub hold_editor: bool, blocked_page: Option, input_blocked: bool, } #[derive(Clone, Copy)] struct Gesture { start: Pos2, now: Pos2, marquee: bool, additive: bool, } impl ObjectSelection { pub fn count(&self) -> usize { self.selected.len() } /// A preview belongs to the active, focused document. Returning from another tab or a /// modal must not turn a release that happened elsewhere into a document edit. pub fn set_input_blocked(&mut self, blocked: bool) { self.input_blocked = blocked; if blocked { self.gesture = None; self.blocked_page = None; } } fn clear(&mut self) { self.page = None; self.selected.clear(); self.gesture = None; self.pending = None; self.blocked_page = None; } pub fn prepare(&mut self, doc: &Document, enabled: bool, ctx: &egui::Context) { self.hold_editor = false; if !enabled { self.clear(); return; } let generation = doc.edit_generation(); if self.generation != Some(generation) { self.cache.clear(); self.gesture = None; let expected = self.pending.take(); let remapped = self.page.zip(expected).and_then(|(page, expected)| { let inventory = doc.editable_objects(page).ok()?; let mut out = Vec::new(); for old in expected { let mut matches = inventory.iter().filter(|o| { o.target.kind == old.target.kind && o.content_type == old.content_type && o.text == old.text && o.rect.iter().zip(old.rect).all(|(a, b)| (a - b).abs() < 0.01) }); let object = matches.next()?; if matches.next().is_some() || out.iter().any(|o: &EditableObject| o.target == object.target) { return None; } out.push(object.clone()); } Some(out) }); if let Some(objects) = remapped { self.selected = objects; } else { self.clear(); } self.generation = Some(generation); } else { // A refused edit keeps the generation and the original selection. self.pending = None; } if self.page.is_some() && ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Escape)) { self.clear(); } } /// The move of the whole selection by `offset` (user space). The selection follows it. fn move_by(&mut self, page: usize, offset: [f64; 2]) -> Edit { let mut expected = self.selected.clone(); for object in &mut expected { object.rect = [object.rect[0] + offset[0], object.rect[1] + offset[1], object.rect[2] + offset[0], object.rect[3] + offset[1]]; } self.pending = Some(expected); Edit::MoveObjects { page, objects: self.selected.iter().map(|o| o.target).collect(), offset } } fn inventory(&mut self, doc: &Document, page: usize) -> Result, String> { if self.cache.len() >= 8 && !self.cache.contains_key(&page) { self.cache.clear(); } self.cache.entry(page).or_insert_with(|| doc.editable_objects(page)).clone() } } fn screen(xf: &PageXform, info: &DocInfo, page: usize, object: &EditableObject) -> Rect { xf.user_rect(info, page, object.rect.map(|v| v as f32)) } fn modified(ui: &egui::Ui) -> bool { ui.input(|i| { let modifiers = i .events .iter() .find_map(|e| match e { egui::Event::PointerButton { button: egui::PointerButton::Primary, pressed: true, modifiers, .. } => Some(*modifiers), _ => None, }) .unwrap_or(i.modifiers); modifiers.shift || modifiers.ctrl || modifiers.command }) } fn single(view: &DocView, page: usize) -> Option { if let Some((_, index)) = view.content.selected.filter(|(p, _)| *p == page) { Some(ObjectTarget { kind: ObjectKind::Added, index }) } else if let Some(s) = view.image_selection.as_ref().filter(|s| s.page == page) { Some(ObjectTarget { kind: ObjectKind::Image, index: s.index }) } else { view.line_editor.as_ref().filter(|e| e.page == page).map(|e| ObjectTarget { kind: ObjectKind::Text, index: e.block }) } } fn leave_single(view: &mut DocView) { view.content.selected = None; view.image_selection = None; view.line_editor = None; view.block_drag = None; } /// Returns true while group selection owns the interaction. Ordinary clicks still reach the /// single-object text/resize tools. A text draft is finished or discarded through its editor. pub(crate) fn page_input(ui: &egui::Ui, resp: &egui::Response, xf: &PageXform, page: usize, doc: &Document, view: &mut DocView) -> bool { if view.objects.input_blocked { return true; } if view.content.draft.is_some() || view.line_editor.as_ref().is_some_and(|e| e.has_changes()) { let blocked = view.objects.blocked_page == Some(page); let modifier_press = ui.input(|i| i.pointer.primary_pressed()) && modified(ui) && ui.input(|i| i.pointer.interact_pos()).is_some_and(|p| xf.rect.contains(p) && ui.clip_rect().contains(p)); if blocked || modifier_press { view.objects.hold_editor = true; view.objects.blocked_page = ui.input(|i| i.pointer.primary_down()).then_some(page); if modifier_press { view.objects.notice = Some(tl!("Finish or discard the text being edited before selecting several objects.").into()); } return true; } return false; } let mut selection = std::mem::take(&mut view.objects); let result = input(&mut selection, ui, resp, xf, page, doc, view); view.objects = selection; result } #[allow(clippy::too_many_arguments)] fn input(s: &mut ObjectSelection, ui: &egui::Ui, resp: &egui::Response, xf: &PageXform, page: usize, doc: &Document, view: &mut DocView) -> bool { let (pointer, pressed, down) = ui.input(|i| (i.pointer.interact_pos(), i.pointer.primary_pressed(), i.pointer.primary_down())); let info = &doc.info; if s.page == Some(page) && let Some(mut gesture) = s.gesture { if let Some(p) = pointer { gesture.now = p; } if !down { if !gesture.marquee && gesture.start.distance(gesture.now) <= 3.0 && resp.double_clicked() { s.clear(); return false; } s.gesture = None; if gesture.marquee { let area = Rect::from_two_pos(gesture.start, gesture.now).intersect(xf.rect); if !gesture.additive { s.selected.clear(); } if area.width() > 3.0 && area.height() > 3.0 && let Ok(objects) = s.inventory(doc, page) { let candidates: Vec<_> = objects.into_iter().filter(|o| area.intersects(screen(xf, info, page, o))).collect(); let mut selected = s.selected.clone(); for object in candidates { if !selected.iter().any(|o| o.target == object.target) { selected.push(object); } } if selected.len() <= MAX_MOVE_OBJECTS { s.selected = selected; } else { s.notice = Some(tl!("Select at most 1000 objects at a time.").into()); } } if s.selected.is_empty() { s.clear(); } } else if gesture.start.distance(gesture.now) > 3.0 && view.pending_edit.is_none() { let (a, b) = (xf.screen_to_view(gesture.start), xf.screen_to_view(gesture.now)); match doc.object_move_offset(page, [f64::from(b.0 - a.0), f64::from(b.1 - a.1)]) { Ok(offset) => view.pending_edit = Some(s.move_by(page, offset)), Err(_) => s.notice = Some(tl!("These objects could not be moved. Select them again and try a smaller move.").into()), } } } else { s.gesture = Some(gesture); } ui.ctx().set_cursor_icon(if gesture.marquee { egui::CursorIcon::Crosshair } else { egui::CursorIcon::Move }); return true; } let Some(p) = pointer.filter(|p| xf.rect.contains(*p) && ui.clip_rect().contains(*p) && resp.rect.contains(*p)) else { return s.page == Some(page) && !s.selected.is_empty(); }; if !pressed { if s.page == Some(page) && !s.selected.is_empty() { let union = s.selected.iter().fold(Rect::NOTHING, |r, o| r.union(screen(xf, info, page, o))); if union.contains(p) { ui.ctx().set_cursor_icon(egui::CursorIcon::Move); } return true; } return false; } let objects = match s.inventory(doc, page) { Ok(objects) => objects, Err(_) => { if modified(ui) { s.notice = Some(tl!("This page contains objects that cannot be selected together.").into()); return true; } return false; } }; let additive = modified(ui); // A page-sized background image must not hide editable labels or added notes. // Within each kind, prefer the last object as the ordinary single-item tools do. let hit = [ObjectKind::Added, ObjectKind::Text, ObjectKind::Image] .into_iter() .find_map(|kind| objects.iter().rev().find(|o| o.target.kind == kind && screen(xf, info, page, o).expand(2.0).contains(p))); if additive { if s.page != Some(page) { s.clear(); s.page = Some(page); if let Some(target) = single(view, page) && let Some(object) = objects.iter().find(|o| o.target == target) { s.selected.push(object.clone()); } } if let Some(object) = hit { if let Some(index) = s.selected.iter().position(|o| o.target == object.target) { s.selected.remove(index); } else if s.selected.len() < MAX_MOVE_OBJECTS { s.selected.push(object.clone()); } else { s.notice = Some(tl!("Select at most 1000 objects at a time.").into()); } } else { s.gesture = Some(Gesture { start: p, now: p, marquee: true, additive: true }); } leave_single(view); view.current = page; return true; } if s.page == Some(page) && !s.selected.is_empty() { let union = s.selected.iter().fold(Rect::NOTHING, |r, o| r.union(screen(xf, info, page, o))); if union.contains(p) && !resp.double_clicked() { s.gesture = Some(Gesture { start: p, now: p, marquee: false, additive: false }); return true; } } // The frame of the paragraph open for typing takes hold of the box, as in Acrobat: a click // selects it (for the arrow keys), a drag moves it. Its text keeps the caret. if let Some(block) = view.line_editor.as_ref().filter(|e| e.page == page && !e.has_changes()).map(|e| e.block) && let Some(object) = objects.iter().find(|o| o.target == ObjectTarget { kind: ObjectKind::Text, index: block }) && on_frame(screen(xf, info, page, object), p) { s.clear(); s.page = Some(page); s.selected.push(object.clone()); s.gesture = Some(Gesture { start: p, now: p, marquee: false, additive: false }); leave_single(view); view.current = page; return true; } s.clear(); if hit.is_some() { return false; } leave_single(view); s.page = Some(page); s.gesture = Some(Gesture { start: p, now: p, marquee: true, additive: false }); view.current = page; true } /// Whether `p` is on the frame drawn around a paragraph open for typing (`r`, screen): just /// outside the text, where a press would otherwise close the paragraph. fn on_frame(r: Rect, p: Pos2) -> bool { r.expand(2.0 + FRAME_REACH).contains(p) && !r.expand(2.0).contains(p) } /// How far outside a paragraph's frame a press still grabs it (screen pixels). const FRAME_REACH: f32 = 6.0; /// The arrow keys move the selected image or objects a point, Shift and an arrow ten points, /// as in Acrobat. They are taken before the view turns the page with them. pub(crate) fn nudge_keys(ctx: &egui::Context, view: &mut DocView, doc: &Document) { if ctx.egui_wants_keyboard_input() || view.pending_edit.is_some() { return; } let image = view.image_selection.as_ref().filter(|s| !s.dragging()).map(|s| (s.page, s.index)); let objects = view.objects.page.filter(|_| !view.objects.selected.is_empty() && view.objects.gesture.is_none()); let Some(page) = objects.or(image.map(|(page, _)| page)) else { return }; let Some(xf) = view.page_xform(page) else { return }; use egui::{Key, Modifiers}; let mut step = egui::Vec2::ZERO; ctx.input_mut(|i| { for (key, direction) in [ (Key::ArrowLeft, egui::vec2(-1.0, 0.0)), (Key::ArrowRight, egui::vec2(1.0, 0.0)), (Key::ArrowUp, egui::vec2(0.0, -1.0)), (Key::ArrowDown, egui::vec2(0.0, 1.0)), ] { // Shift first: a plain key pattern also matches a Shift press. if i.consume_key(Modifiers::SHIFT, key) { step += direction * 10.0; } else if i.consume_key(Modifiers::NONE, key) { step += direction; } } }); if step == egui::Vec2::ZERO { return; } // A point on screen, whatever the zoom and the view's rotation: one screen pixel's step in // view space, at unit length. let c = xf.rect.center(); let unit = |d: egui::Vec2| { let (a, b) = (xf.screen_to_view(c), xf.screen_to_view(c + d)); let v = egui::vec2(b.0 - a.0, b.1 - a.1); if v.length() > 0.0 { v / v.length() } else { egui::Vec2::ZERO } }; let delta = unit(egui::vec2(1.0, 0.0)) * step.x + unit(egui::vec2(0.0, 1.0)) * step.y; let Ok(offset) = doc.object_move_offset(page, [f64::from(delta.x), f64::from(delta.y)]) else { return }; if objects.is_some() { view.pending_edit = Some(view.objects.move_by(page, offset)); } else if let Some((page, index)) = image && let Some(r) = doc.page_images(page).get(index).map(|im| im.rect) { let rect = [r[0] + offset[0], r[1] + offset[1], r[2] + offset[0], r[3] + offset[1]]; view.pending_edit = Some(Edit::EditPageImage { page, index, change: pdfcraft_engine::ImageEdit::Move(rect) }); } } pub(crate) fn paint(painter: &egui::Painter, xf: &PageXform, page: usize, info: &DocInfo, selection: &ObjectSelection) { if selection.page != Some(page) { return; } let delta = selection.gesture.filter(|g| !g.marquee).map_or(egui::Vec2::ZERO, |g| g.now - g.start); let mut union = Rect::NOTHING; for object in &selection.selected { let rect = screen(xf, info, page, object).translate(delta); painter.rect_filled(rect, 0.0, BLUE.gamma_multiply(0.05)); painter.rect_stroke(rect, 0.0, Stroke::new(1.5, BLUE), egui::StrokeKind::Outside); union = union.union(rect); } if selection.selected.len() > 1 { painter.rect_stroke(union.expand(4.0), 0.0, Stroke::new(1.0, BLUE.gamma_multiply(0.7)), egui::StrokeKind::Outside); } if let Some(g) = selection.gesture.filter(|g| g.marquee) { let area = Rect::from_two_pos(g.start, g.now).intersect(xf.rect); painter.rect_filled(area, 0.0, BLUE.gamma_multiply(0.08)); painter.rect_stroke(area, 0.0, Stroke::new(1.0, BLUE), egui::StrokeKind::Outside); } } /// Keep the UI refusal actionable and translated; headless callers retain the detailed error. pub(crate) fn move_error(error: &pdfcraft_engine::EditError) -> String { let detail = error.to_string(); match detail.as_str() { "vertical text cannot be moved in a group" => tl!("Vertical text cannot be moved with this selection.").into(), "text used as a clipping path cannot be moved in a group" => tl!("Text used as a clipping path cannot be moved with this selection.").into(), _ => tl!("These objects could not be moved. Select them again and try a smaller move.").into(), } } #[cfg(test)] mod tests { #[test] fn selection_labels_and_refusals_are_translated_in_every_catalog() { for language in crate::i18n::LANGUAGES.iter().filter(|l| !l.source.is_empty()) { let lang = crate::i18n::Lang::from_code(language.code).unwrap(); for label in [ "Drag the selection to move it. Esc clears the selection.", "Shift-click to select several objects. Drag empty space to select an area.", "Select at most 1000 objects at a time.", "Finish or discard the text being edited before selecting several objects.", "Move object", "Move objects", "Vertical text cannot be moved with this selection.", "Text used as a clipping path cannot be moved with this selection.", "These objects could not be moved. Select them again and try a smaller move.", "This page contains objects that cannot be selected together.", ] { assert_ne!(crate::i18n::tr(lang, label), label, "{}: {label}", language.code); } assert_ne!(crate::i18n::trn(lang, 2, "{n} object selected", "{n} objects selected"), "2 objects selected", "{}", language.code); } } }