mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-06 02:21:45 +00:00
feat: major Acrobat UI parity, crop, and layer fixes
This commit is contained in:
1 parent
293faa0c05
commit
9a5fec0caa
11 files changed
+562
-91
No files matched your search
+172
-61
@@ -660,6 +660,122 @@ pub fn arrange_image(doc: &mut Document, page: usize, index: usize, order: Arran
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Locate the innermost graphics state scope enclosing only `target` (with no other painting operations).
|
||||
/// Returns `Some((start, end, scope_matrix))` where `start` is the index of `q`, `end` is the index of `Q`,
|
||||
/// and `scope_matrix` is the CTM in effect immediately before entering that `q`.
|
||||
fn image_exclusive_scope(ops: &[Op], target: usize) -> Result<Option<(usize, usize, Matrix)>, EditError> {
|
||||
struct Scope {
|
||||
start: usize,
|
||||
matrix: Matrix,
|
||||
inherited_clip: bool,
|
||||
inherited_path: bool,
|
||||
selected: bool,
|
||||
other_paint: bool,
|
||||
}
|
||||
let mut stack: Vec<Scope> = Vec::new();
|
||||
let mut matrix = Matrix::IDENTITY;
|
||||
let mut clipped = false;
|
||||
let mut path = false;
|
||||
let mut pending_clip = false;
|
||||
let mut text = false;
|
||||
let mut at_image = None;
|
||||
let mut isolated = None;
|
||||
for (index, op) in ops.iter().enumerate() {
|
||||
match op.op.as_slice() {
|
||||
b"q" => {
|
||||
if stack.len() >= 256 {
|
||||
return Err(EditError::Invalid("the image graphics state is too deeply nested".into()));
|
||||
}
|
||||
stack.push(Scope { start: index, matrix, inherited_clip: clipped, inherited_path: path, selected: false, other_paint: false });
|
||||
}
|
||||
b"Q" => {
|
||||
if let Some(scope) = stack.pop() {
|
||||
if scope.selected && !scope.other_paint && !scope.inherited_clip && !scope.inherited_path {
|
||||
isolated.get_or_insert((scope.start, index, scope.matrix));
|
||||
}
|
||||
matrix = scope.matrix;
|
||||
clipped = scope.inherited_clip;
|
||||
}
|
||||
}
|
||||
b"cm" => {
|
||||
if let Some(m) = op.nums::<6>() {
|
||||
matrix = Matrix(m).then(&matrix);
|
||||
}
|
||||
}
|
||||
b"BT" => text = true,
|
||||
b"ET" => text = false,
|
||||
b"m" | b"l" | b"c" | b"v" | b"y" | b"re" => path = true,
|
||||
b"W" | b"W*" => pending_clip = true,
|
||||
_ => {}
|
||||
}
|
||||
if index == target {
|
||||
if text {
|
||||
return Err(EditError::Invalid("an image inside a text object cannot be cropped".into()));
|
||||
}
|
||||
at_image = Some(matrix);
|
||||
for scope in &mut stack {
|
||||
scope.selected = true;
|
||||
}
|
||||
} else if matches!(
|
||||
op.op.as_slice(),
|
||||
b"Do" | b"Tj" | b"TJ" | b"'" | b"\"" | b"sh" | b"BI" | b"S" | b"s" | b"f" | b"F" | b"f*" | b"B" | b"B*" | b"b" | b"b*"
|
||||
) {
|
||||
for scope in &mut stack {
|
||||
scope.other_paint = true;
|
||||
}
|
||||
}
|
||||
if matches!(op.op.as_slice(), b"n" | b"S" | b"s" | b"f" | b"F" | b"f*" | b"B" | b"B*" | b"b" | b"b*") {
|
||||
clipped |= pending_clip;
|
||||
pending_clip = false;
|
||||
path = false;
|
||||
}
|
||||
}
|
||||
if at_image.is_none() {
|
||||
return Err(EditError::Invalid("the image operation is missing".into()));
|
||||
}
|
||||
Ok(isolated)
|
||||
}
|
||||
|
||||
fn extract_image_drawing_ops(ops: &[Op], start: usize, end: usize, scope_matrix: Matrix) -> Vec<Op> {
|
||||
let mut drawing = Vec::new();
|
||||
let mut i = start.saturating_add(1);
|
||||
while i < end {
|
||||
let op = match ops.get(i) {
|
||||
Some(o) => o,
|
||||
None => break,
|
||||
};
|
||||
if op.is("cm")
|
||||
&& scope_matrix != Matrix::IDENTITY
|
||||
&& let (Some(m), Some(inv)) = (op.nums::<6>(), scope_matrix.invert())
|
||||
&& Matrix(m) == inv
|
||||
&& ops.get(i + 1).is_some_and(|next| next.is("re"))
|
||||
{
|
||||
i = i.saturating_add(1);
|
||||
continue;
|
||||
}
|
||||
if op.is("re") {
|
||||
i = i.saturating_add(1);
|
||||
if ops.get(i).is_some_and(|next| next.is("W") || next.is("W*")) {
|
||||
i = i.saturating_add(1);
|
||||
}
|
||||
if ops.get(i).is_some_and(|next| next.is("n")) {
|
||||
i = i.saturating_add(1);
|
||||
}
|
||||
if scope_matrix != Matrix::IDENTITY
|
||||
&& ops.get(i).is_some_and(|next| next.is("cm"))
|
||||
&& let Some(m) = ops.get(i).and_then(|next| next.nums::<6>())
|
||||
&& Matrix(m) == scope_matrix
|
||||
{
|
||||
i = i.saturating_add(1);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
drawing.push(op.clone());
|
||||
i = i.saturating_add(1);
|
||||
}
|
||||
drawing
|
||||
}
|
||||
|
||||
/// Remove any private clipping path / crop on image `index` on `page`.
|
||||
pub fn remove_image_crop(doc: &mut Document, page: usize, index: usize) -> Result<(), EditError> {
|
||||
let images = page_images(doc, page)?;
|
||||
@@ -682,14 +798,19 @@ pub fn remove_image_crop(doc: &mut Document, page: usize, index: usize) -> Resul
|
||||
.iter()
|
||||
.position(|op| op.span.start == span && op.is("Do"))
|
||||
.ok_or_else(|| EditError::Invalid("the image operation could not be located".into()))?;
|
||||
if let Some((at, _)) = exclusive_clip(&ops, target) {
|
||||
let end_idx = at.saturating_add(2);
|
||||
if let (Some(start_op), Some(end_op)) = (ops.get(at), ops.get(end_idx)) {
|
||||
let range = start_op.span.start..end_op.span.end;
|
||||
let changed = pieces.splice([(range, Vec::new())]);
|
||||
let contents = crate::text::rewritten_contents(doc, &all, changed);
|
||||
doc.update_dict(p.obj, |d| d.set(b"Contents".to_vec(), contents))?;
|
||||
}
|
||||
if let Some((start, end, scope_matrix)) = image_exclusive_scope(&ops, target)? {
|
||||
let drawing_ops = extract_image_drawing_ops(&ops, start, end, scope_matrix);
|
||||
let start_op = ops.get(start).ok_or_else(|| EditError::Invalid("the image operation is missing".into()))?;
|
||||
let end_op = ops.get(end).ok_or_else(|| EditError::Invalid("the image operation is missing".into()))?;
|
||||
let range = start_op.span.start..end_op.span.end;
|
||||
let mut unit_ops = Vec::new();
|
||||
unit_ops.push(Op::new("q", vec![]));
|
||||
unit_ops.extend(drawing_ops);
|
||||
unit_ops.push(Op::new("Q", vec![]));
|
||||
let serialized = serialize_ops(&unit_ops);
|
||||
let changed = pieces.splice([(range, serialized)]);
|
||||
let contents = crate::text::rewritten_contents(doc, &all, changed);
|
||||
doc.update_dict(p.obj, |d| d.set(b"Contents".to_vec(), contents))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -725,62 +846,52 @@ pub fn crop_image(doc: &mut Document, page: usize, index: usize, crop_rect: [f64
|
||||
.position(|op| op.span.start == span && op.is("Do"))
|
||||
.ok_or_else(|| EditError::Invalid("the image operation could not be located".into()))?;
|
||||
|
||||
let (range, unit_ops) = if let Some((at, _)) = exclusive_clip(&ops, target) {
|
||||
let op_re = ops.get(at).ok_or_else(|| EditError::Invalid("the crop operation is missing".into()))?;
|
||||
(
|
||||
op_re.span.start..op_re.span.end,
|
||||
vec![Op::new("re", vec![pdfcraft_content::num(x0), pdfcraft_content::num(y0), pdfcraft_content::num(w), pdfcraft_content::num(h)])],
|
||||
)
|
||||
} else if target >= 2
|
||||
&& ops.get(target - 2).is_some_and(|o| o.is("q"))
|
||||
&& ops.get(target - 1).is_some_and(|o| o.is("cm"))
|
||||
&& ops.get(target + 1).is_some_and(|o| o.is("Q"))
|
||||
{
|
||||
let q_op = &ops[target - 2];
|
||||
let q_end = &ops[target + 1];
|
||||
(
|
||||
q_op.span.start..q_end.span.end,
|
||||
vec![
|
||||
Op::new("q", vec![]),
|
||||
Op::new("re", vec![pdfcraft_content::num(x0), pdfcraft_content::num(y0), pdfcraft_content::num(w), pdfcraft_content::num(h)]),
|
||||
Op::new("W", vec![]),
|
||||
Op::new("n", vec![]),
|
||||
ops[target - 1].clone(),
|
||||
ops[target].clone(),
|
||||
Op::new("Q", vec![]),
|
||||
],
|
||||
)
|
||||
} else if target >= 1 && ops.get(target - 1).is_some_and(|o| o.is("cm")) {
|
||||
let cm_op = &ops[target - 1];
|
||||
let do_op = &ops[target];
|
||||
(
|
||||
cm_op.span.start..do_op.span.end,
|
||||
vec![
|
||||
Op::new("q", vec![]),
|
||||
Op::new("re", vec![pdfcraft_content::num(x0), pdfcraft_content::num(y0), pdfcraft_content::num(w), pdfcraft_content::num(h)]),
|
||||
Op::new("W", vec![]),
|
||||
Op::new("n", vec![]),
|
||||
ops[target - 1].clone(),
|
||||
ops[target].clone(),
|
||||
Op::new("Q", vec![]),
|
||||
],
|
||||
)
|
||||
let (start, end, drawing_ops, scope_matrix) = if let Some((start, end, scope_matrix)) = image_exclusive_scope(&ops, target)? {
|
||||
let drawing_ops = extract_image_drawing_ops(&ops, start, end, scope_matrix);
|
||||
(start, end, drawing_ops, scope_matrix)
|
||||
} else {
|
||||
let do_op = &ops[target];
|
||||
(
|
||||
do_op.span.start..do_op.span.end,
|
||||
vec![
|
||||
Op::new("q", vec![]),
|
||||
Op::new("re", vec![pdfcraft_content::num(x0), pdfcraft_content::num(y0), pdfcraft_content::num(w), pdfcraft_content::num(h)]),
|
||||
Op::new("W", vec![]),
|
||||
Op::new("n", vec![]),
|
||||
Op::new("cm", img.draw_matrix.map(pdfcraft_content::num).to_vec()),
|
||||
ops[target].clone(),
|
||||
Op::new("Q", vec![]),
|
||||
],
|
||||
)
|
||||
let mut state = ImageState::new(Matrix::IDENTITY, p.crop(doc), Vec::new());
|
||||
let mut scope_matrix = Matrix::IDENTITY;
|
||||
let start = if target >= 1 && ops.get(target - 1).is_some_and(|o| o.is("cm")) {
|
||||
target - 1
|
||||
} else {
|
||||
target
|
||||
};
|
||||
for (i, o) in ops.iter().enumerate() {
|
||||
if i == start {
|
||||
scope_matrix = state.placement.matrix;
|
||||
break;
|
||||
}
|
||||
state.apply(o)?;
|
||||
}
|
||||
let drawing_ops = ops.get(start..=target).unwrap_or_default().to_vec();
|
||||
(start, target, drawing_ops, scope_matrix)
|
||||
};
|
||||
|
||||
let mut unit_ops = Vec::new();
|
||||
unit_ops.push(Op::new("q", vec![]));
|
||||
if scope_matrix != Matrix::IDENTITY
|
||||
&& let Some(inv) = scope_matrix.invert()
|
||||
{
|
||||
unit_ops.push(Op::new("cm", inv.0.map(pdfcraft_content::num).to_vec()));
|
||||
}
|
||||
unit_ops.push(Op::new("re", vec![
|
||||
pdfcraft_content::num(x0),
|
||||
pdfcraft_content::num(y0),
|
||||
pdfcraft_content::num(w),
|
||||
pdfcraft_content::num(h),
|
||||
]));
|
||||
unit_ops.push(Op::new("W", vec![]));
|
||||
unit_ops.push(Op::new("n", vec![]));
|
||||
if scope_matrix != Matrix::IDENTITY {
|
||||
unit_ops.push(Op::new("cm", scope_matrix.0.map(pdfcraft_content::num).to_vec()));
|
||||
}
|
||||
unit_ops.extend(drawing_ops);
|
||||
unit_ops.push(Op::new("Q", vec![]));
|
||||
|
||||
let start_op = ops.get(start).ok_or_else(|| EditError::Invalid("the image operation is missing".into()))?;
|
||||
let end_op = ops.get(end).ok_or_else(|| EditError::Invalid("the image operation is missing".into()))?;
|
||||
let range = start_op.span.start..end_op.span.end;
|
||||
let serialized = serialize_ops(&unit_ops);
|
||||
let changed = pieces.splice([(range, serialized)]);
|
||||
let contents = crate::text::rewritten_contents(doc, &all, changed);
|
||||
|
||||
@@ -1740,6 +1740,32 @@ fn image_arrange_stacking_order_and_remove_crop() {
|
||||
let doc = reopen(&doc);
|
||||
let img_restored = &images::page_images(&doc, 0).unwrap()[0];
|
||||
assert_eq!(img_restored.visible_rect, Some(img_restored.rect));
|
||||
|
||||
// Test cropping an image that was moved (has an outer transform)
|
||||
let mut doc = image_page_with("q 200 0 0 100 100 100 cm /Im0 Do Q");
|
||||
let move_transform = [1.0, 0.0, 0.0, 1.0, 50.0, 40.0];
|
||||
images::change_image(&mut doc, 0, 0, &images::ImageChange::Transform(move_transform)).unwrap();
|
||||
let doc = reopen(&doc);
|
||||
let img_moved = &images::page_images(&doc, 0).unwrap()[0];
|
||||
assert_eq!(img_moved.rect, [150.0, 140.0, 350.0, 240.0]);
|
||||
let mut doc = doc;
|
||||
images::crop_image(&mut doc, 0, 0, [170.0, 150.0, 300.0, 220.0]).unwrap();
|
||||
let doc = reopen(&doc);
|
||||
let img_crop_moved = &images::page_images(&doc, 0).unwrap()[0];
|
||||
assert!(img_crop_moved.visible_rect.is_some(), "cropped image must not be deleted");
|
||||
assert!(close(img_crop_moved.visible_rect.unwrap(), [170.0, 150.0, 300.0, 220.0]));
|
||||
// Adjust crop (re-crop)
|
||||
let mut doc = doc;
|
||||
images::crop_image(&mut doc, 0, 0, [180.0, 160.0, 280.0, 210.0]).unwrap();
|
||||
let doc = reopen(&doc);
|
||||
let img_recropped = &images::page_images(&doc, 0).unwrap()[0];
|
||||
assert!(close(img_recropped.visible_rect.unwrap(), [180.0, 160.0, 280.0, 210.0]));
|
||||
// Remove crop from moved image
|
||||
let mut doc = doc;
|
||||
images::remove_image_crop(&mut doc, 0, 0).unwrap();
|
||||
let doc = reopen(&doc);
|
||||
let img_restored_moved = &images::page_images(&doc, 0).unwrap()[0];
|
||||
assert_eq!(img_restored_moved.visible_rect, Some(img_restored_moved.rect));
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -542,6 +542,8 @@ pub(crate) enum PageMenuAction {
|
||||
SelectTool,
|
||||
HandTool,
|
||||
MarqueeZoom,
|
||||
Copy,
|
||||
Paste(usize, [f64; 2]),
|
||||
AddComment(usize, [f64; 2]),
|
||||
AddBookmark(usize),
|
||||
TakeSnapshot,
|
||||
@@ -558,6 +560,8 @@ pub(crate) fn default_page_context_menu(
|
||||
ui: &mut egui::Ui,
|
||||
page: usize,
|
||||
at: [f64; 2],
|
||||
can_copy: bool,
|
||||
can_paste: bool,
|
||||
action: &mut Option<PageMenuAction>,
|
||||
) {
|
||||
// Top horizontal icon bar: Rotate CCW, Print, Select tool, Hand tool, Marquee Zoom
|
||||
@@ -587,6 +591,17 @@ pub(crate) fn default_page_context_menu(
|
||||
|
||||
ui.separator();
|
||||
|
||||
if ui.add_enabled(can_copy, egui::Button::new(tl!("Copy")).shortcut_text("Ctrl+C")).clicked() {
|
||||
*action = Some(PageMenuAction::Copy);
|
||||
ui.close();
|
||||
}
|
||||
if ui.add_enabled(can_paste, egui::Button::new(tl!("Paste")).shortcut_text("Ctrl+V")).clicked() {
|
||||
*action = Some(PageMenuAction::Paste(page, at));
|
||||
ui.close();
|
||||
}
|
||||
|
||||
ui.separator();
|
||||
|
||||
if ui.button(tl!("Add comment")).clicked() {
|
||||
*action = Some(PageMenuAction::AddComment(page, at));
|
||||
ui.close();
|
||||
@@ -2191,6 +2206,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
|
||||
let editing_content =
|
||||
(app.left_open && app.left == crate::LeftPanel::Tool("edit")) || matches!(app.quick_tool, QuickTool::AddText | QuickTool::EditText);
|
||||
let text_style = app.text_style.clone();
|
||||
let can_paste_image = app.can_paste_image();
|
||||
let view = &mut app.views[index];
|
||||
// Opened without the owner password and something is restricted.
|
||||
let secured = doc.security_summary().is_some_and(|s| !(s.owner || (s.permissions.modify() && s.permissions.assemble())));
|
||||
@@ -3227,7 +3243,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
|
||||
return;
|
||||
}
|
||||
if tool == QuickTool::EditText && let Some(sel) = view.image_selection.clone() {
|
||||
crate::edit_text_ui::image_context_menu(ui, view, doc, sel.page, sel.index, &mut image_action);
|
||||
crate::edit_text_ui::image_context_menu(ui, view, doc, sel.page, sel.index, can_paste_image, &mut image_action);
|
||||
return;
|
||||
}
|
||||
if view.comments.selected.is_some() {
|
||||
@@ -3260,11 +3276,14 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
|
||||
ui.separator();
|
||||
}
|
||||
}
|
||||
let has_copy_image = copy_image.is_some();
|
||||
if copy_image.is_some() {
|
||||
canvas_action = copy_image;
|
||||
}
|
||||
let (page, at) = view.comments.context_at.unwrap_or((view.current, [0.0, 0.0]));
|
||||
default_page_context_menu(ui, page, at, &mut page_action);
|
||||
let can_copy = view.image_selection.is_some() || view.selected_text().is_some() || has_copy_image;
|
||||
let can_paste = can_paste_image;
|
||||
default_page_context_menu(ui, page, at, can_copy, can_paste, &mut page_action);
|
||||
});
|
||||
(wanted, visible_now)
|
||||
});
|
||||
@@ -3531,13 +3550,11 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
|
||||
Some(crate::edit_text_ui::ImageAction::Copy(page, i)) => app.copy_page_image(page, i),
|
||||
Some(crate::edit_text_ui::ImageAction::CopyFile(page, i)) => app.copy_page_image_file(page, i),
|
||||
Some(crate::edit_text_ui::ImageAction::Cut(page, i)) => {
|
||||
app.copy_page_image(page, i);
|
||||
app.views[index].pending_edit = Some(pdfcraft_engine::Edit::EditPageImage {
|
||||
page,
|
||||
index: i,
|
||||
change: pdfcraft_engine::ImageEdit::Delete,
|
||||
});
|
||||
app.views[index].image_selection = None;
|
||||
app.cut_page_image(index, page, i);
|
||||
}
|
||||
Some(crate::edit_text_ui::ImageAction::Paste(page, rect)) => {
|
||||
let offset_rect = [rect[0] + 20.0, rect[1] - 20.0, rect[2] + 20.0, rect[3] - 20.0];
|
||||
app.paste_image(page, Some(offset_rect));
|
||||
}
|
||||
Some(crate::edit_text_ui::ImageAction::DrawFreehand) => {
|
||||
app.quick_tool = crate::QuickTool::Comment(crate::comments::CommentTool::Ink);
|
||||
@@ -3591,6 +3608,23 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
|
||||
None => {}
|
||||
}
|
||||
match page_action {
|
||||
Some(PageMenuAction::Copy) => {
|
||||
if let Some(sel) = app.views[index].image_selection.clone() {
|
||||
app.copy_page_image(sel.page, sel.index);
|
||||
} else if let Some(text) = app.views[index].selected_text() {
|
||||
if let Some(id) = app.views.get(index).map(|v| v.id) && app.may_copy(id) {
|
||||
ui.ctx().copy_text(text);
|
||||
}
|
||||
} else if let Some((page, at)) = app.views[index].comments.context_at
|
||||
&& let Some(doc) = app.session.get(app.views[index].id)
|
||||
&& let Some(image) = doc.page_image_at(page, at)
|
||||
{
|
||||
app.copy_page_image(page, image);
|
||||
}
|
||||
}
|
||||
Some(PageMenuAction::Paste(page, at)) => {
|
||||
app.paste_image_at(page, at);
|
||||
}
|
||||
Some(PageMenuAction::RotateCCW(page)) => {
|
||||
app.views[index].pending_edit = Some(pdfcraft_engine::Edit::RotatePages { pages: vec![page], degrees: -90 });
|
||||
}
|
||||
|
||||
@@ -745,6 +745,15 @@ impl crate::PdfCraftApp {
|
||||
if !self.may_copy(id) {
|
||||
return;
|
||||
}
|
||||
let img_rect = self.session.get(id).and_then(|d| d.page_images(page).into_iter().nth(index).map(|im| im.rect));
|
||||
self.last_copied_image_rect = img_rect;
|
||||
if let Some(doc) = self.session.get(id)
|
||||
&& let Ok((ext, bytes)) = doc.page_image_copy_file(page, index)
|
||||
{
|
||||
let stem = crate::image_pick::safe_name(doc.name.rsplit_once('.').map_or(doc.name.as_str(), |(s, _)| s));
|
||||
let name = format!("{stem} p{} img{}.{ext}", page.saturating_add(1), index.saturating_add(1));
|
||||
self.last_copied_file = Some((name, bytes));
|
||||
}
|
||||
let Some(pixels) = self.session.get(id).map(|d| d.page_image_rgba(page, index)) else { return };
|
||||
let copied = pixels.and_then(|(w, h, rgba)| {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
@@ -765,6 +774,128 @@ impl crate::PdfCraftApp {
|
||||
}
|
||||
}
|
||||
|
||||
/// Cut Image: copy image `index` on `page` to the clipboard, then delete it.
|
||||
pub(crate) fn cut_page_image(&mut self, view_idx: usize, page: usize, index: usize) {
|
||||
self.copy_page_image(page, index);
|
||||
if let Some(view) = self.views.get_mut(view_idx) {
|
||||
view.pending_edit = Some(pdfcraft_engine::Edit::EditPageImage {
|
||||
page,
|
||||
index,
|
||||
change: pdfcraft_engine::ImageEdit::Delete,
|
||||
});
|
||||
view.image_selection = None;
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether there is an image in the in-memory clipboard or system clipboard ready to paste.
|
||||
pub fn can_paste_image(&self) -> bool {
|
||||
if self.last_copied_image.is_some() || self.last_copied_file.is_some() {
|
||||
return true;
|
||||
}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
if self.system_clipboard
|
||||
&& let Ok(mut cb) = arboard::Clipboard::new()
|
||||
{
|
||||
return cb.get_image().is_ok();
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Paste an image from the clipboard onto `page`.
|
||||
pub fn paste_image(&mut self, page: usize, rect: Option<[f64; 4]>) -> bool {
|
||||
let Some((i, id)) = self.active_ids() else { return false; };
|
||||
if let Some(doc) = self.session.get(id)
|
||||
&& !doc.allows_modification()
|
||||
{
|
||||
self.notify_error(tl!("The document's security settings don't allow changes"));
|
||||
return false;
|
||||
}
|
||||
|
||||
let image_data: Option<(String, Vec<u8>)> = if let Some((name, bytes)) = self.last_copied_file.clone() {
|
||||
Some((name, bytes))
|
||||
} else if let Some((w, h, rgba)) = self.last_copied_image.clone() {
|
||||
crate::create_ui::png_from_rgba(w as usize, h as usize, &rgba).ok().map(|b| ("pasted_image.png".to_string(), b))
|
||||
} else {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
if self.system_clipboard {
|
||||
arboard::Clipboard::new()
|
||||
.ok()
|
||||
.and_then(|mut cb| cb.get_image().ok())
|
||||
.and_then(|img| {
|
||||
crate::create_ui::png_from_rgba(img.width, img.height, &img.bytes)
|
||||
.ok()
|
||||
.map(|b| ("clipboard_image.png".to_string(), b))
|
||||
})
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
None
|
||||
};
|
||||
|
||||
let Some((name, bytes)) = image_data else {
|
||||
self.notify_tr("No image on clipboard to paste");
|
||||
return false;
|
||||
};
|
||||
|
||||
let target_rect = match rect {
|
||||
Some(r) => Some(r),
|
||||
None => {
|
||||
if let Some(src_rect) = self.last_copied_image_rect {
|
||||
let w = src_rect[2] - src_rect[0];
|
||||
let h = src_rect[3] - src_rect[1];
|
||||
let offset_x = 20.0;
|
||||
let offset_y = -20.0;
|
||||
let new_r = [src_rect[0] + offset_x, src_rect[1] + offset_y, src_rect[0] + offset_x + w, src_rect[1] + offset_y + h];
|
||||
self.last_copied_image_rect = Some(new_r);
|
||||
Some(new_r)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let before = self.session.get(id).map_or(0, |d| d.page_images(page).len());
|
||||
if self.apply_edit(pdfcraft_engine::Edit::AddImage {
|
||||
page,
|
||||
rect: target_rect,
|
||||
name,
|
||||
bytes: std::sync::Arc::new(bytes),
|
||||
}) {
|
||||
if let Some(v) = self.views.get_mut(i) {
|
||||
v.current = page;
|
||||
v.image_selection = Some(crate::edit_text_ui::ImageSelection {
|
||||
page,
|
||||
index: before,
|
||||
crop_mode: false,
|
||||
crop_rect: None,
|
||||
drag: None,
|
||||
});
|
||||
}
|
||||
self.quick_tool = crate::QuickTool::EditText;
|
||||
self.notify_tr("Image pasted");
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Paste an image centered at context menu point `at: [f64; 2]` on `page`.
|
||||
pub fn paste_image_at(&mut self, page: usize, at: [f64; 2]) -> bool {
|
||||
let rect = if let Some(src_r) = self.last_copied_image_rect {
|
||||
let w = src_r[2] - src_r[0];
|
||||
let h = src_r[3] - src_r[1];
|
||||
Some([at[0] - w * 0.5, at[1] - h * 0.5, at[0] + w * 0.5, at[1] + h * 0.5])
|
||||
} else {
|
||||
None
|
||||
};
|
||||
self.paste_image(page, rect)
|
||||
}
|
||||
|
||||
/// Click an image field: pick a picture for it.
|
||||
pub fn choose_field_image(&mut self, name: &str) {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
|
||||
@@ -66,7 +66,7 @@ fn read_clipboard() -> Option<Clip> {
|
||||
cb.get_text().ok().filter(|t| !t.trim().is_empty()).map(Clip::Text)
|
||||
}
|
||||
|
||||
fn png_from_rgba(width: usize, height: usize, rgba: &[u8]) -> Result<Vec<u8>, String> {
|
||||
pub(crate) fn png_from_rgba(width: usize, height: usize, rgba: &[u8]) -> Result<Vec<u8>, String> {
|
||||
let (w, h) = (u32::try_from(width).map_err(|e| e.to_string())?, u32::try_from(height).map_err(|e| e.to_string())?);
|
||||
if w == 0 || h == 0 || rgba.len() != width * height * 4 {
|
||||
return Err("the clipboard image is empty or malformed".into());
|
||||
|
||||
@@ -616,7 +616,7 @@ impl ImageSelection {
|
||||
/// applies after the pointer leaves the image/page while dragging.
|
||||
pub(crate) fn image_owns_pointer(ui: &egui::Ui, xf: &PageXform, page: usize, info: &DocInfo, view: &DocView) -> bool {
|
||||
let Some(selection) = view.image_selection.as_ref().filter(|s| s.page == page) else { return false };
|
||||
if selection.drag.is_some() {
|
||||
if selection.drag.is_some() || selection.crop_mode {
|
||||
return true;
|
||||
}
|
||||
let Some(image) = view.edit_images.get(&page).and_then(|(_, images)| images.get(selection.index)) else { return false };
|
||||
@@ -698,6 +698,7 @@ pub(crate) enum ImageAction {
|
||||
Copy(usize, usize),
|
||||
CopyFile(usize, usize),
|
||||
Cut(usize, usize),
|
||||
Paste(usize, [f64; 4]),
|
||||
DrawFreehand,
|
||||
Redact(usize, usize),
|
||||
CreateLink(usize, usize),
|
||||
@@ -858,14 +859,22 @@ pub(crate) fn image_input(
|
||||
painter.line_segment([Pos2::new(active_crop.left(), y2), Pos2::new(active_crop.right(), y2)], Stroke::new(0.75, ACCENT.gamma_multiply(0.5)));
|
||||
|
||||
let crop_handles = [
|
||||
ImageHandle::TopLeft,
|
||||
ImageHandle::TopCenter,
|
||||
ImageHandle::TopRight,
|
||||
ImageHandle::RightCenter,
|
||||
ImageHandle::BottomRight,
|
||||
ImageHandle::BottomCenter,
|
||||
ImageHandle::BottomLeft,
|
||||
ImageHandle::LeftCenter,
|
||||
];
|
||||
for &h in &crop_handles {
|
||||
let c = handle_pos(active_crop, h);
|
||||
let sz = if matches!(h, ImageHandle::TopCenter | ImageHandle::BottomCenter) { egui::vec2(16.0, 7.0) } else { egui::vec2(7.0, 16.0) };
|
||||
let sz = match h {
|
||||
ImageHandle::TopCenter | ImageHandle::BottomCenter => egui::vec2(16.0, 6.0),
|
||||
ImageHandle::LeftCenter | ImageHandle::RightCenter => egui::vec2(6.0, 16.0),
|
||||
_ => egui::vec2(10.0, 10.0),
|
||||
};
|
||||
painter.rect(Rect::from_center_size(c, sz), CornerRadius::same(1), Color32::WHITE, Stroke::new(1.5, ACCENT), egui::StrokeKind::Middle);
|
||||
}
|
||||
|
||||
@@ -878,13 +887,21 @@ pub(crate) fn image_input(
|
||||
}
|
||||
|
||||
if allow_interaction {
|
||||
if ui.input(|inp| inp.key_pressed(egui::Key::Enter)) {
|
||||
let enter_pressed = ui.input_mut(|inp| inp.consume_key(egui::Modifiers::NONE, egui::Key::Enter));
|
||||
let dbl_click = resp.double_clicked() && hover_pos.is_some_and(|p| active_crop.contains(p));
|
||||
if enter_pressed || dbl_click {
|
||||
if active_crop != b {
|
||||
view.pending_edit = Some(Edit::EditPageImage {
|
||||
page,
|
||||
index: i,
|
||||
change: pdfcraft_engine::ImageEdit::Crop(user_box(xf, info, page, active_crop)),
|
||||
});
|
||||
} else if images.get(i).is_some_and(|im| im.visible_rect.is_some() && im.visible_rect != Some(im.rect)) {
|
||||
view.pending_edit = Some(Edit::EditPageImage {
|
||||
page,
|
||||
index: i,
|
||||
change: pdfcraft_engine::ImageEdit::RemoveCrop,
|
||||
});
|
||||
}
|
||||
if let Some(s) = view.image_selection.as_mut() {
|
||||
s.crop_mode = false;
|
||||
@@ -892,7 +909,7 @@ pub(crate) fn image_input(
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if ui.input(|inp| inp.key_pressed(egui::Key::Escape)) {
|
||||
if ui.input_mut(|inp| inp.consume_key(egui::Modifiers::NONE, egui::Key::Escape)) {
|
||||
if let Some(s) = view.image_selection.as_mut() {
|
||||
s.crop_mode = false;
|
||||
s.crop_rect = None;
|
||||
@@ -918,9 +935,13 @@ pub(crate) fn image_input(
|
||||
if is_crop {
|
||||
let active_crop = view.image_selection.as_ref().and_then(|s| s.crop_rect).unwrap_or(b);
|
||||
let crop_handles = [
|
||||
ImageHandle::TopLeft,
|
||||
ImageHandle::TopCenter,
|
||||
ImageHandle::TopRight,
|
||||
ImageHandle::RightCenter,
|
||||
ImageHandle::BottomRight,
|
||||
ImageHandle::BottomCenter,
|
||||
ImageHandle::BottomLeft,
|
||||
ImageHandle::LeftCenter,
|
||||
];
|
||||
let hit_edge = crop_handles.iter().copied().find(|&h| handle_pos(active_crop, h).distance(o) < 8.0);
|
||||
@@ -955,6 +976,26 @@ pub(crate) fn image_input(
|
||||
if is_crop {
|
||||
let mut new_crop = initial;
|
||||
match drag.target {
|
||||
DragTarget::CropHandle(ImageHandle::TopLeft) => {
|
||||
let x = (initial.left() + delta.x).clamp(b.left(), initial.right() - 10.0);
|
||||
let y = (initial.top() + delta.y).clamp(b.top(), initial.bottom() - 10.0);
|
||||
new_crop = Rect::from_min_max(Pos2::new(x, y), initial.max);
|
||||
}
|
||||
DragTarget::CropHandle(ImageHandle::TopRight) => {
|
||||
let x = (initial.right() + delta.x).clamp(initial.left() + 10.0, b.right());
|
||||
let y = (initial.top() + delta.y).clamp(b.top(), initial.bottom() - 10.0);
|
||||
new_crop = Rect::from_min_max(Pos2::new(initial.left(), y), Pos2::new(x, initial.bottom()));
|
||||
}
|
||||
DragTarget::CropHandle(ImageHandle::BottomLeft) => {
|
||||
let x = (initial.left() + delta.x).clamp(b.left(), initial.right() - 10.0);
|
||||
let y = (initial.bottom() + delta.y).clamp(initial.top() + 10.0, b.bottom());
|
||||
new_crop = Rect::from_min_max(Pos2::new(x, initial.top()), Pos2::new(initial.right(), y));
|
||||
}
|
||||
DragTarget::CropHandle(ImageHandle::BottomRight) => {
|
||||
let x = (initial.right() + delta.x).clamp(initial.left() + 10.0, b.right());
|
||||
let y = (initial.bottom() + delta.y).clamp(initial.top() + 10.0, b.bottom());
|
||||
new_crop = Rect::from_min_max(initial.min, Pos2::new(x, y));
|
||||
}
|
||||
DragTarget::CropHandle(ImageHandle::TopCenter) => {
|
||||
let y = (initial.top() + delta.y).clamp(b.top(), initial.bottom() - 10.0);
|
||||
new_crop = Rect::from_min_max(Pos2::new(initial.left(), y), initial.max);
|
||||
@@ -1072,8 +1113,15 @@ pub(crate) fn image_input(
|
||||
&& ui.input(|inp| inp.key_pressed(egui::Key::Delete) || inp.key_pressed(egui::Key::Backspace))
|
||||
&& !ui.ctx().egui_wants_keyboard_input()
|
||||
{
|
||||
view.image_selection = None;
|
||||
view.pending_edit = Some(Edit::EditPageImage { page, index: i, change: pdfcraft_engine::ImageEdit::Delete });
|
||||
if is_crop {
|
||||
if let Some(s) = view.image_selection.as_mut() {
|
||||
s.crop_mode = false;
|
||||
s.crop_rect = None;
|
||||
}
|
||||
} else {
|
||||
view.image_selection = None;
|
||||
view.pending_edit = Some(Edit::EditPageImage { page, index: i, change: pdfcraft_engine::ImageEdit::Delete });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -1083,6 +1131,7 @@ pub(crate) fn image_input(
|
||||
let Some(p) = hover_pos.filter(|p| xf.rect.contains(*p)) else { return false };
|
||||
let Some(hit) = boxes.iter().enumerate().rposition(|(i, b)| b.contains(p) && image_contains(i, p)) else {
|
||||
let over_popup = egui::Popup::is_any_open(ui.ctx())
|
||||
|| resp.context_menu_opened()
|
||||
|| ui.ctx().is_pointer_over_egui()
|
||||
|| ui.ctx().egui_wants_pointer_input();
|
||||
if (resp.secondary_clicked() || resp.clicked()) && !over_popup {
|
||||
@@ -1124,6 +1173,7 @@ pub(crate) fn image_context_menu(
|
||||
doc: &pdfcraft_engine::Document,
|
||||
page: usize,
|
||||
hit: usize,
|
||||
can_paste: bool,
|
||||
action: &mut Option<ImageAction>,
|
||||
) {
|
||||
let images = doc.page_images(page);
|
||||
@@ -1171,8 +1221,10 @@ pub(crate) fn image_context_menu(
|
||||
*action = Some(ImageAction::Copy(page, hit));
|
||||
ui.close();
|
||||
}
|
||||
// Paste item: disabled when no item can be pasted into the image layer
|
||||
ui.add_enabled(false, egui::Button::new(tl!("Paste")).shortcut_text("Ctrl+V"));
|
||||
if ui.add_enabled(can_paste, egui::Button::new(tl!("Paste")).shortcut_text("Ctrl+V")).clicked() {
|
||||
*action = Some(ImageAction::Paste(page, img.rect));
|
||||
ui.close();
|
||||
}
|
||||
|
||||
ui.separator();
|
||||
|
||||
|
||||
@@ -59,7 +59,7 @@ pub(crate) fn paint(painter: &egui::Painter, xf: &PageXform, page: usize, info:
|
||||
}
|
||||
|
||||
/// A file name without characters file systems refuse.
|
||||
fn safe_name(s: &str) -> String {
|
||||
pub(crate) fn safe_name(s: &str) -> String {
|
||||
let cleaned: String = s.chars().map(|c| if c.is_control() || r#"/\:*?"<>|"#.contains(c) { '_' } else { c }).take(80).collect();
|
||||
let cleaned = cleaned.trim().trim_matches('.').to_string();
|
||||
if cleaned.is_empty() { "PdfCraft".into() } else { cleaned }
|
||||
|
||||
@@ -704,6 +704,7 @@ pub struct PdfCraftApp {
|
||||
/// The last image copied in its original format (file name, bytes), also put on the
|
||||
/// system clipboard as a file when `system_clipboard` is on.
|
||||
pub last_copied_file: Option<(String, Vec<u8>)>,
|
||||
pub last_copied_image_rect: Option<[f64; 4]>,
|
||||
pub system_clipboard: bool,
|
||||
/// Attach file: the file to attach instead of asking (tests, automation).
|
||||
pub attach_override: Option<(String, Vec<u8>)>,
|
||||
@@ -992,6 +993,7 @@ impl PdfCraftApp {
|
||||
last_snapshot: None,
|
||||
last_copied_image: None,
|
||||
last_copied_file: None,
|
||||
last_copied_image_rect: None,
|
||||
system_clipboard: true,
|
||||
attach_override: None,
|
||||
duplicate_draft: None,
|
||||
@@ -2682,7 +2684,7 @@ impl PdfCraftApp {
|
||||
object_ui::nudge_keys(ctx, &mut self.views[i], doc);
|
||||
}
|
||||
canvas::shortcuts(&mut self.views[i], ctx);
|
||||
self.copy_key(i, ctx);
|
||||
self.clipboard_keys(i, ctx);
|
||||
self.image_pick_keys(i, ctx);
|
||||
}
|
||||
}
|
||||
@@ -2706,16 +2708,42 @@ impl PdfCraftApp {
|
||||
false
|
||||
}
|
||||
|
||||
/// ⌘C copies the selected text, unless a text field has the keyboard (it copies its own).
|
||||
fn copy_key(&mut self, i: usize, ctx: &egui::Context) {
|
||||
// ⌘C arrives as a Copy event on most platforms.
|
||||
let copy = ctx.input(|input| input.events.iter().any(|e| matches!(e, egui::Event::Copy)));
|
||||
if !copy || ctx.egui_wants_keyboard_input() {
|
||||
/// ⌘C / ⌘X / ⌘V copy, cut and paste text or images on canvas (unless a text field has focus).
|
||||
fn clipboard_keys(&mut self, i: usize, ctx: &egui::Context) {
|
||||
use egui::{Key, KeyboardShortcut, Modifiers};
|
||||
if ctx.egui_wants_keyboard_input() || self.views.get(i).is_some_and(|v| v.forms.focus.is_some() || v.organize) {
|
||||
return;
|
||||
}
|
||||
let Some((id, text)) = self.views.get(i).and_then(|v| Some((v.id, v.selected_text()?))) else { return };
|
||||
if self.may_copy(id) {
|
||||
ctx.copy_text(text);
|
||||
let copy = ctx.input(|input| input.events.iter().any(|e| matches!(e, egui::Event::Copy)))
|
||||
|| ctx.input_mut(|input| input.consume_shortcut(&KeyboardShortcut::new(Modifiers::COMMAND, Key::C)));
|
||||
let cut = ctx.input(|input| input.events.iter().any(|e| matches!(e, egui::Event::Cut)))
|
||||
|| ctx.input_mut(|input| input.consume_shortcut(&KeyboardShortcut::new(Modifiers::COMMAND, Key::X)));
|
||||
let paste = ctx.input(|input| input.events.iter().any(|e| matches!(e, egui::Event::Paste(_))))
|
||||
|| ctx.input_mut(|input| input.consume_shortcut(&KeyboardShortcut::new(Modifiers::COMMAND, Key::V)));
|
||||
|
||||
if copy {
|
||||
if let Some(text) = self.views.get(i).and_then(|v| v.selected_text())
|
||||
&& let Some(id) = self.views.get(i).map(|v| v.id)
|
||||
&& self.may_copy(id)
|
||||
{
|
||||
ctx.copy_text(text);
|
||||
} else if let Some(sel) = self.views.get(i).and_then(|v| v.image_selection.clone())
|
||||
&& let Some(id) = self.views.get(i).map(|v| v.id)
|
||||
&& self.may_copy(id)
|
||||
{
|
||||
self.copy_page_image(sel.page, sel.index);
|
||||
}
|
||||
}
|
||||
if cut
|
||||
&& let Some(sel) = self.views.get(i).and_then(|v| v.image_selection.clone())
|
||||
&& let Some(id) = self.views.get(i).map(|v| v.id)
|
||||
&& self.may_copy(id)
|
||||
{
|
||||
self.cut_page_image(i, sel.page, sel.index);
|
||||
}
|
||||
if paste {
|
||||
let page = self.views.get(i).map_or(0, |v| v.current);
|
||||
self.paste_image(page, None);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -160,6 +160,9 @@ pub(crate) fn page_input(ui: &egui::Ui, resp: &egui::Response, xf: &PageXform, p
|
||||
if view.objects.input_blocked {
|
||||
return true;
|
||||
}
|
||||
if resp.context_menu_opened() || egui::Popup::is_any_open(ui.ctx()) {
|
||||
return false;
|
||||
}
|
||||
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())
|
||||
|
||||
@@ -1039,7 +1039,7 @@ fn hovering_highlight_without_comment_respects_display_author_option() {
|
||||
assert!(json.contains("\"display_author_hover_no_comment\":false"));
|
||||
let mut app2 = PdfCraftApp::new();
|
||||
app2.restore(&json);
|
||||
assert_eq!(app2.comment_prefs.display_author_hover_no_comment, false);
|
||||
assert!(!app2.comment_prefs.display_author_hover_no_comment);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -2654,6 +2654,8 @@ fn default_page_context_menu_when_not_in_edit_mode() {
|
||||
assert!(h.query_all_by_label("Add comment").next().is_some());
|
||||
assert!(h.query_all_by_label("Add text").next().is_some());
|
||||
assert!(h.query_all_by_label("Add image").next().is_some());
|
||||
assert!(h.query_all_by_label_contains("Copy").next().is_some());
|
||||
assert!(h.query_all_by_label_contains("Paste").next().is_some());
|
||||
|
||||
// Clicking "Edit this PDF" enters edit mode
|
||||
h.get_by_label("Edit this PDF").click();
|
||||
@@ -2694,10 +2696,30 @@ fn image_edge_handle_stretch_and_crop_mode() {
|
||||
h.run_steps(2);
|
||||
assert!(h.state().views[0].image_selection.as_ref().is_some_and(|s| s.crop_mode));
|
||||
|
||||
// Set smaller crop rect
|
||||
let smaller = egui::Rect::from_center_size(c, egui::vec2(50.0, 50.0));
|
||||
h.state_mut().views[0].image_selection.as_mut().unwrap().crop_rect = Some(smaller);
|
||||
|
||||
// Confirm crop with Enter key
|
||||
h.key_press(egui::Key::Enter);
|
||||
h.run_steps(2);
|
||||
h.run_steps(4);
|
||||
assert!(!h.state().views[0].image_selection.as_ref().is_some_and(|s| s.crop_mode));
|
||||
let doc_id = h.state().views[0].id;
|
||||
let images = h.state().session.get(doc_id).unwrap().page_images(0);
|
||||
assert_eq!(images.len(), 1, "image still exists: {images:?}");
|
||||
assert!(images[0].visible_rect.is_some(), "cropped image is visible");
|
||||
assert_ne!(images[0].visible_rect, Some(images[0].rect), "image is cropped");
|
||||
|
||||
// Remove crop via context menu
|
||||
h.hover_at(c);
|
||||
h.run_steps(1);
|
||||
h.event(egui::Event::PointerButton { pos: c, button: egui::PointerButton::Secondary, pressed: true, modifiers: Default::default() });
|
||||
h.event(egui::Event::PointerButton { pos: c, button: egui::PointerButton::Secondary, pressed: false, modifiers: Default::default() });
|
||||
h.run_steps(2);
|
||||
h.get_by_label("Remove crop").click();
|
||||
h.run_steps(4);
|
||||
let images = h.state().session.get(doc_id).unwrap().page_images(0);
|
||||
assert_eq!(images[0].visible_rect, Some(images[0].rect), "crop is removed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -4341,3 +4363,67 @@ fn private_new_chinese_keyboard_input_preserves_font_layout_and_saved_pixels() {
|
||||
assert_eq!(std::fs::read(path).unwrap(), original);
|
||||
println!("verified Chinese keyboard insertion/deletion and save/reopen rendering on {pages} pages");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn image_copy_cut_paste_keyboard_and_context_menu() {
|
||||
let mut png = Vec::new();
|
||||
image::RgbImage::from_pixel(60, 40, image::Rgb([180, 50, 50]))
|
||||
.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png)
|
||||
.unwrap();
|
||||
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.system_clipboard = false;
|
||||
app.open_bytes("picture.png", None, png.clone()).expect("opens");
|
||||
app
|
||||
});
|
||||
h.run_steps(4);
|
||||
assert!(h.state_mut().execute("edit.edit_text"));
|
||||
h.run_steps(2);
|
||||
|
||||
let id = h.state().views[0].id;
|
||||
let initial_count = h.state().session.get(id).unwrap().page_images(0).len();
|
||||
assert_eq!(initial_count, 1, "starts with 1 image");
|
||||
|
||||
let r = h.state().views[0].page_screen_rect(0).expect("on screen");
|
||||
let c = r.center();
|
||||
|
||||
// 1. Select the image
|
||||
h.hover_at(c);
|
||||
h.run_steps(1);
|
||||
h.drag_at(c);
|
||||
h.run_steps(1);
|
||||
h.drop_at(c);
|
||||
h.run_steps(2);
|
||||
assert!(h.state().views[0].image_selection.is_some(), "image selected");
|
||||
|
||||
// 2. Copy via keyboard shortcut (COMMAND+C / Event::Copy)
|
||||
h.event(egui::Event::Copy);
|
||||
h.run_steps(2);
|
||||
assert!(h.state().last_copied_image.is_some(), "image copied to clipboard buffer");
|
||||
|
||||
// 3. Paste via keyboard shortcut (COMMAND+V / Event::Paste)
|
||||
h.event(egui::Event::Paste("".into()));
|
||||
h.run_steps(3);
|
||||
let after_paste = h.state().session.get(id).unwrap().page_images(0).len();
|
||||
assert_eq!(after_paste, 2, "now has 2 images");
|
||||
assert_eq!(h.state().views[0].image_selection.as_ref().map(|s| s.index), Some(1), "new image is selected");
|
||||
|
||||
// 4. Cut via keyboard shortcut (COMMAND+X / Event::Cut)
|
||||
h.event(egui::Event::Cut);
|
||||
h.run_steps(3);
|
||||
let after_cut = h.state().session.get(id).unwrap().page_images(0).len();
|
||||
assert_eq!(after_cut, 1, "cut deleted the pasted image");
|
||||
assert!(h.state().views[0].image_selection.is_none(), "selection cleared after cut");
|
||||
|
||||
// 5. Paste again via context menu on the page
|
||||
h.hover_at(c);
|
||||
h.run_steps(1);
|
||||
h.event(egui::Event::PointerButton { pos: c, button: egui::PointerButton::Secondary, pressed: true, modifiers: Default::default() });
|
||||
h.event(egui::Event::PointerButton { pos: c, button: egui::PointerButton::Secondary, pressed: false, modifiers: Default::default() });
|
||||
h.run_steps(2);
|
||||
h.get_by_label_contains("Paste").click();
|
||||
h.run_steps(3);
|
||||
let after_paste2 = h.state().session.get(id).unwrap().page_images(0).len();
|
||||
assert_eq!(after_paste2, 2, "pasted via context menu");
|
||||
}
|
||||
Reference in new issue
Block a user