mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-31 15:49:36 +00:00
A moved comment is drawn where it goes while it is dragged
Moving a rectangle, an oval or any other comment showed only its blue frame going, the shape staying where it was until release. The comment is now rendered alone once as the drag starts (Document::comment_layer: a read-only copy with only that comment, no page content) and laid over the page where it goes; the page beneath keeps showing it where it was until the move is applied, as a CAD program's move does. Rendering the page without it would cost a whole page's render at every drag on a heavy plan. Stamps and Fill & Sign marks keep their own layers. annot: isolate_annotations for several comments of a page, so a group can be drawn together. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
3109bb2359
commit
67b5b2dcf6
10 files changed
+330
-49
No files matched your search
@@ -220,6 +220,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
|
||||
The regression checks two distinct appearances, inherited resources, print/reopen pixels and unchanged source data.
|
||||
Estimates unchanged.
|
||||
- **2026-10-21 (M3, window resize and move on Wayland):** With PdfCraft's own title bar on GNOME/Wayland, a second resize by a window edge, or a second move by the empty tab strip, works straight after the first; it waited for a click inside the window, because the compositor keeps the button's release from egui. A unit test covers the release given back; checked by hand in a test build. Estimates unchanged.
|
||||
- **2026-10-24 (M5, move preview):** A comment being moved is drawn where it goes while it is dragged (a rectangle or oval showed only its blue frame): the comment is rendered alone once as the drag starts and laid over the page, which shows it where it was until the move is applied. Engine `Document::comment_layer`; synthetic engine and UI regressions. Estimates unchanged.
|
||||
- **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 (M10, #3752 paper):** CUPS jobs ask for the chosen paper in every size. The 13 sizes the Print dialog sent no paper for (A6, A0–A2, ARCH, ANSI C–E) use PWG 5101.1 names, checked against a synthetic PPD with `cupsfilter`, and `doc_print` to a printer sends the paper too. The macOS tray half (Auto Select instead of the PPD's default tray) is a craft-print change that needs a pin bump here. Estimates unchanged.
|
||||
- **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.
|
||||
|
||||
@@ -226,16 +226,31 @@ pub fn picture_rotation(doc: &Document, page: usize, index: usize) -> Result<i64
|
||||
/// over the page while it is dragged. `false`, with `doc` unchanged, when the annotation has no
|
||||
/// normal appearance (`/AP /N`) for that render to draw.
|
||||
pub fn isolate_annotation(doc: &mut Document, page: usize, index: usize) -> Result<bool, AnnotError> {
|
||||
isolate_annotations(doc, page, &[index])
|
||||
}
|
||||
|
||||
/// [`isolate_annotation`] for several annotations of one page: those of them with a normal
|
||||
/// appearance stay, in their order on the page. `false`, with `doc` unchanged, when none has one.
|
||||
pub fn isolate_annotations(doc: &mut Document, page: usize, indices: &[usize]) -> Result<bool, AnnotError> {
|
||||
let p = page_ref(doc, page)?;
|
||||
let entry = annots(doc, p).get(index).cloned().ok_or(AnnotError::NoSuchAnnotation { page, index })?;
|
||||
let has_normal = {
|
||||
let obj = doc.resolve(&entry);
|
||||
obj.as_dict().and_then(|d| d.get(b"AP")).is_some_and(|ap| doc.resolve(ap).as_dict().is_some_and(|ap| ap.contains(b"N")))
|
||||
};
|
||||
if !has_normal {
|
||||
let list = annots(doc, p);
|
||||
let mut kept = Vec::new();
|
||||
for (index, entry) in list.iter().enumerate() {
|
||||
if !indices.contains(&index) {
|
||||
continue;
|
||||
}
|
||||
let obj = doc.resolve(entry);
|
||||
if obj.as_dict().and_then(|d| d.get(b"AP")).is_some_and(|ap| doc.resolve(ap).as_dict().is_some_and(|ap| ap.contains(b"N"))) {
|
||||
kept.push(entry.clone());
|
||||
}
|
||||
}
|
||||
if let Some(&index) = indices.iter().find(|&&i| i >= list.len()) {
|
||||
return Err(AnnotError::NoSuchAnnotation { page, index });
|
||||
}
|
||||
if kept.is_empty() {
|
||||
return Ok(false);
|
||||
}
|
||||
set_annots(doc, p, vec![entry])?;
|
||||
set_annots(doc, p, kept)?;
|
||||
doc.update_dict(p, |d| {
|
||||
d.remove(b"Contents");
|
||||
})?;
|
||||
|
||||
@@ -66,7 +66,7 @@ pub use links::{
|
||||
};
|
||||
pub use signature_image::{ImageSignaturePreview, SignatureImage};
|
||||
pub use signature_scan::{PhotoRect, ScanError, SignaturePhoto, Turn};
|
||||
pub use stamp_preview::StampPreview;
|
||||
pub use stamp_preview::{CommentLayer, StampPreview};
|
||||
pub use text_preview::{TextEditPreview, UnmarkedPages};
|
||||
|
||||
pub use pdfcraft_organize::{BoxSpec, FieldConflicts, PageBox, SplitBy, Transition, TransitionSpec, mime_for_name, split_ranges};
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
//! A stamp while it is moved or resized: the page without it, and the stamp alone.
|
||||
//! A stamp while it is moved or resized: the page without it, and the stamp alone. Any other
|
||||
//! comment while it is moved: the comment alone.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -81,3 +82,59 @@ impl Document {
|
||||
})?
|
||||
}
|
||||
}
|
||||
|
||||
/// Comments alone on a transparent page, for drawing them where they go while they are moved.
|
||||
/// The page beneath keeps showing them where they were until the move is applied: rendering the page
|
||||
/// without it would cost a whole page's render at every drag on a heavy plan. Read-only, as
|
||||
/// [`StampPreview`] is.
|
||||
pub struct CommentLayer {
|
||||
page: usize,
|
||||
bytes: Arc<Vec<u8>>,
|
||||
config: RenderConfig,
|
||||
}
|
||||
|
||||
impl CommentLayer {
|
||||
/// One worker for the comments alone: their page has no content and no other annotations.
|
||||
pub fn renderer(&self) -> RenderPool {
|
||||
RenderPool::new(self.bytes.clone(), 1, self.transparent())
|
||||
}
|
||||
|
||||
/// The comments alone, rendered synchronously with the renderer's normal pixel limits.
|
||||
pub fn render(&self, scale: f32) -> Result<RenderedPage, String> {
|
||||
if !scale.is_finite() || scale <= 0.0 {
|
||||
return Err("preview scale must be finite and positive".into());
|
||||
}
|
||||
guard(|| {
|
||||
let out =
|
||||
PageRenderer::new(self.bytes.clone(), self.transparent()).render(RenderRequest { page: self.page, scale, ..Default::default() });
|
||||
if let Some(e) = &out.error {
|
||||
return Err(e.clone());
|
||||
}
|
||||
Ok(out)
|
||||
})?
|
||||
}
|
||||
|
||||
fn transparent(&self) -> RenderConfig {
|
||||
RenderConfig { transparent: true, ..self.config.clone() }
|
||||
}
|
||||
}
|
||||
|
||||
impl Document {
|
||||
/// The comments at `indices` on `page` alone (the ones moved together), drawn as on screen:
|
||||
/// one without an appearance of its own gets the one the page view draws. `None` when one
|
||||
/// of them doesn't exist or none has anything to draw.
|
||||
pub fn comment_layer(&self, page: usize, indices: &[usize]) -> Result<Option<CommentLayer>, String> {
|
||||
guard(|| {
|
||||
let editor = self.editor.as_ref().ok_or("the document can't be read")?;
|
||||
if indices.is_empty() || !indices.iter().all(|&i| self.info.annotations.iter().any(|a| a.page == page && a.index == i)) {
|
||||
return Ok(None);
|
||||
}
|
||||
let mut alone = pdfcraft_annot::with_missing_appearances(&editor.cos).unwrap_or_else(|| editor.cos.clone());
|
||||
if !pdfcraft_annot::isolate_annotations(&mut alone, page, indices).map_err(|e| e.to_string())? {
|
||||
return Ok(None);
|
||||
}
|
||||
let bytes = write_incremental(&alone, &SaveOptions::default()).map(Arc::new).map_err(|e| e.to_string())?;
|
||||
Ok(Some(CommentLayer { page, bytes, config: self.config.clone() }))
|
||||
})?
|
||||
}
|
||||
}
|
||||
@@ -3485,6 +3485,44 @@ trailer << /Root 1 0 R >>
|
||||
assert!(doc.stamp_preview(0, 1).unwrap().is_none(), "a stamp without an appearance");
|
||||
}
|
||||
|
||||
/// A moved comment's layer: the comment alone on a transparent page (no page text, no other
|
||||
/// comment), drawn as on screen, another app's comment without an appearance too. Preparing it
|
||||
/// changes nothing in the document.
|
||||
#[test]
|
||||
fn comment_layer_draws_the_comment_alone() {
|
||||
let (mut s, id) = session_with(1);
|
||||
s.apply(id, rect_comment(0, [140.0, 40.0, 190.0, 90.0])).unwrap();
|
||||
s.apply(id, rect_comment(0, [20.0, 20.0, 60.0, 60.0])).unwrap();
|
||||
let doc = s.get(id).unwrap();
|
||||
let (generation, dirty) = (doc.edit_generation(), doc.dirty);
|
||||
let layer = doc.comment_layer(0, &[0]).unwrap().expect("a rectangle").render(1.0).unwrap();
|
||||
let px = |p: &pdfcraft_render::RenderedPage, x: u32, y: u32| -> [u8; 4] {
|
||||
p.rgba[(((300 - y) * p.width + x) * 4) as usize..][..4].try_into().unwrap()
|
||||
};
|
||||
assert_eq!(px(&layer, 165, 65), [255, 0, 0, 255], "the rectangle is drawn");
|
||||
let near = |x: u32, y: u32| (137..=193).contains(&x) && (37..=93).contains(&y);
|
||||
let stray: Vec<(u32, u32)> =
|
||||
(1..300).flat_map(|y| (0..200).map(move |x| (x, y))).filter(|&(x, y)| !near(x, y) && px(&layer, x, y) != [0, 0, 0, 0]).collect();
|
||||
assert!(stray.is_empty(), "only the rectangle is on its layer: {:?}", &stray[..stray.len().min(8)]);
|
||||
assert!(doc.comment_layer(0, &[9]).unwrap().is_none(), "no such comment");
|
||||
let both = doc.comment_layer(0, &[0, 1]).unwrap().expect("both").render(1.0).unwrap();
|
||||
assert_eq!((px(&both, 165, 65), px(&both, 40, 40)), ([255, 0, 0, 255], [255, 0, 0, 255]), "moved together, drawn together");
|
||||
let doc = s.get(id).unwrap();
|
||||
assert_eq!((doc.edit_generation(), doc.dirty, doc.can_undo()), (generation, dirty, Some("Add rectangle")), "the document is unchanged");
|
||||
|
||||
let bare = b"%PDF-1.7
|
||||
1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj
|
||||
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 300] >> endobj
|
||||
3 0 obj << /Type /Page /Parent 2 0 R /Annots [4 0 R] >> endobj
|
||||
4 0 obj << /Type /Annot /Subtype /Square /Rect [140 40 190 90] /C [1 0 0] >> endobj
|
||||
trailer << /Root 1 0 R >>
|
||||
%%EOF";
|
||||
let mut s = Session::new();
|
||||
let id = s.open("bare.pdf", None, Arc::new(bare.to_vec()), None).unwrap();
|
||||
let layer = s.get(id).unwrap().comment_layer(0, &[0]).unwrap().expect("drawn as on screen").render(1.0).unwrap();
|
||||
assert!((138..=142).any(|x| px(&layer, x, 65)[..3] == [255, 0, 0]), "the square without an appearance is drawn");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flattening_fill_and_sign_keeps_other_comments_fields_and_undoes() {
|
||||
let (mut s, id) = session_with(1);
|
||||
|
||||
@@ -1743,6 +1743,11 @@ fn draw_page(ui: &mut egui::Ui, view: &mut DocView, frame: &mut PageFrame<'_>, i
|
||||
let preview_target = (matches!(tool, QuickTool::Select | QuickTool::Fill(_)) && !comments_hidden).then_some(view.comments.selected).flatten();
|
||||
view.signature_drag.prepare(ui.ctx(), doc, preview_target, scale);
|
||||
view.signature_drag.paint(painter, &pcx, &view.comments, view.pending_edit.as_ref());
|
||||
// Any other comment being moved: drawn where it goes, over the page that still shows it
|
||||
// where it was.
|
||||
let stale = view.signature_drag.moving_page().is_some_and(|p| view.page_out_of_date(p));
|
||||
view.signature_drag.prepare_move(ui.ctx(), doc, &view.comments, view.pending_edit.as_ref(), stale, scale);
|
||||
view.signature_drag.paint_move(painter, &pcx, &view.comments, view.pending_edit.as_ref());
|
||||
|
||||
// Any press on the page ends Select All of the document: text read or not, any tool,
|
||||
// on a link too.
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
//! Cached page and stamp layers: a stamp (an image signature, a Fill & Sign mark, any other stamp)
|
||||
//! follows each pointer frame, moved or resized, without editing the PDF.
|
||||
//! follows each pointer frame, moved or resized, without editing the PDF. Any other comment
|
||||
//! follows the pointer while it is moved: its own layer is drawn where it goes.
|
||||
|
||||
use egui::{ColorImage, TextureHandle, TextureOptions, pos2, vec2};
|
||||
use pdfcraft_engine::{Document, Edit};
|
||||
@@ -16,6 +17,16 @@ pub(crate) struct SignatureDrag {
|
||||
key: Option<(u64, usize, usize)>,
|
||||
aspect_ratio: Option<f32>,
|
||||
layers: Option<Layers>,
|
||||
/// The comment being moved that has no stamp layers: (edit generation, page, index), and
|
||||
/// the comment alone, drawn over the page where it goes.
|
||||
moving: Option<((u64, usize, usize), Moving)>,
|
||||
}
|
||||
|
||||
struct Moving {
|
||||
/// The comment's layer: `from` is its box when the move started.
|
||||
layer: StampLayer,
|
||||
/// Released and applied: keep it where it went until the page shows it there.
|
||||
settling: bool,
|
||||
}
|
||||
|
||||
struct Layers {
|
||||
@@ -109,29 +120,14 @@ impl SignatureDrag {
|
||||
let mut pending = layers.received_tag != Some(tag);
|
||||
// The stamp itself is small: it stays sharp at every zoom.
|
||||
if let Front::Stamp(stamp) = &mut layers.front {
|
||||
let Some((scale, tile)) = stamp_tile(p, stamp.from, scale) else {
|
||||
match stamp.update(ctx, page, p, scale) {
|
||||
Some(waiting) => pending |= waiting,
|
||||
// Nothing to lay over the page: the frame alone follows the pointer, as before.
|
||||
self.layers = None;
|
||||
return;
|
||||
};
|
||||
let tag = (scale * 1000.0) as u64;
|
||||
if stamp.tag != Some(tag) {
|
||||
stamp.renderer.set_queue(vec![RenderRequest { page, scale, tag, tile: Some(tile), ..Default::default() }]);
|
||||
stamp.tag = Some(tag);
|
||||
}
|
||||
if let Some(out) = stamp.renderer.try_recv()
|
||||
&& out.request.tag == tag
|
||||
{
|
||||
let (Some(t), None) = (out.request.tile, &out.error) else {
|
||||
None => {
|
||||
self.layers = None;
|
||||
return;
|
||||
};
|
||||
let s = out.request.scale;
|
||||
let area = [t.x as f32 / s, t.y as f32 / s, t.x.saturating_add(t.w) as f32 / s, t.y.saturating_add(t.h) as f32 / s];
|
||||
let image = crate::canvas::texture_image([out.width as usize, out.height as usize], out.rgba);
|
||||
stamp.pixels = Some((tag, ctx.load_texture("signature-drag-stamp", image, TextureOptions::LINEAR), area));
|
||||
}
|
||||
}
|
||||
pending |= stamp.pixels.as_ref().is_none_or(|(received, ..)| *received != tag);
|
||||
}
|
||||
if pending {
|
||||
ctx.request_repaint_after(std::time::Duration::from_millis(16));
|
||||
@@ -150,6 +146,14 @@ impl SignatureDrag {
|
||||
|
||||
/// Movement/resizing only change this stamp's geometry, so its layers are reusable.
|
||||
pub fn committed(&mut self, edit: &Edit, generation: u64) {
|
||||
if let Some((key, m)) = self.moving.as_mut()
|
||||
&& matches!(edit, Edit::MoveAnnotation { page, index, .. } if (*page, *index) == (key.1, key.2))
|
||||
{
|
||||
key.0 = generation;
|
||||
m.settling = true;
|
||||
} else {
|
||||
self.moving = None;
|
||||
}
|
||||
if let Edit::MoveAnnotation { page, index, .. } | Edit::ResizeAnnotation { page, index, .. } = edit
|
||||
&& self.contains(*page, *index)
|
||||
{
|
||||
@@ -158,7 +162,7 @@ impl SignatureDrag {
|
||||
layers.settling = true;
|
||||
}
|
||||
} else {
|
||||
*self = Self::default();
|
||||
*self = Self { moving: self.moving.take(), ..Self::default() };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -199,27 +203,114 @@ impl SignatureDrag {
|
||||
*turn,
|
||||
egui::Color32::from_white_alpha((opacity * 255.0).round() as u8),
|
||||
),
|
||||
Front::Stamp(stamp) => {
|
||||
let Some((_, texture, area)) = &stamp.pixels else { return };
|
||||
// Where the stamp's pixels were rendered, stretched as its box is from `from` to `rect`.
|
||||
let from = cx.xf.user_rect(cx.info, page, stamp.from);
|
||||
if from.width() < 0.01 || from.height() < 0.01 {
|
||||
return;
|
||||
}
|
||||
let stretch = vec2(rect.width() / from.width(), rect.height() / from.height());
|
||||
let mut mesh = egui::Mesh::with_texture(texture.id());
|
||||
for (u, v) in [(0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 1.0)] {
|
||||
let (x, y) = (area[0] + u * (area[2] - area[0]), area[1] + v * (area[3] - area[1]));
|
||||
let at = cx.xf.norm_to_screen(x / cx.xf.pw, y / cx.xf.ph);
|
||||
let pos = rect.min + (at - from.min) * stretch;
|
||||
mesh.vertices.push(egui::epaint::Vertex { pos, uv: pos2(u, v), color: egui::Color32::WHITE });
|
||||
}
|
||||
mesh.add_triangle(0, 1, 2);
|
||||
mesh.add_triangle(0, 2, 3);
|
||||
painter.add(egui::Shape::mesh(mesh));
|
||||
}
|
||||
Front::Stamp(stamp) => stamp.paint(&painter, cx, page, rect),
|
||||
}
|
||||
}
|
||||
|
||||
/// The page of the comment being moved, whose raster [`Self::prepare_move`] asks about.
|
||||
pub fn moving_page(&self) -> Option<usize> {
|
||||
self.moving.as_ref().map(|((_, page, _), _)| *page)
|
||||
}
|
||||
|
||||
/// While a comment without stamp layers is moved, its own layer: rendered when the move
|
||||
/// starts, kept after release until the page's raster shows it where it went (`stale`: the
|
||||
/// page is still drawn as before its last change).
|
||||
pub fn prepare_move(&mut self, ctx: &egui::Context, doc: &Document, cv: &CommentView, pending: Option<&Edit>, stale: bool, scale: f32) {
|
||||
let generation = doc.edit_generation();
|
||||
let dragged = match cv.gesture {
|
||||
Some(Gesture::Move { page, index, .. }) if !self.contains(page, index) => Some((generation, page, index)),
|
||||
_ => None,
|
||||
};
|
||||
let keep = |key: (u64, usize, usize), m: &Moving| {
|
||||
let (_, page, index) = key;
|
||||
dragged == Some(key)
|
||||
|| matches!(pending, Some(Edit::MoveAnnotation { page: p, index: i, .. }) if (*p, *i) == (page, index))
|
||||
|| m.settling && stale
|
||||
};
|
||||
if self.moving.as_ref().is_some_and(|(key, m)| !keep(*key, m)) {
|
||||
self.moving = None;
|
||||
}
|
||||
if self.moving.is_none()
|
||||
&& let Some(key @ (_, page, index)) = dragged
|
||||
&& let Some(a) = doc.info.annotations.iter().find(|a| a.page == page && a.index == index && !a.locked)
|
||||
&& let Ok(Some(alone)) = doc.comment_layer(page, &[index])
|
||||
{
|
||||
let layer = StampLayer { renderer: alone.renderer(), from: a.rect, tag: None, pixels: None };
|
||||
self.moving = Some((key, Moving { layer, settling: false }));
|
||||
}
|
||||
let Some(((_, page, _), m)) = self.moving.as_mut() else { return };
|
||||
let Some(p) = doc.info.pages.get(*page) else { return };
|
||||
match m.layer.update(ctx, *page, p, scale) {
|
||||
Some(pending) => {
|
||||
if pending {
|
||||
ctx.request_repaint_after(std::time::Duration::from_millis(16));
|
||||
}
|
||||
}
|
||||
// Nothing to lay over the page: the frame alone follows the pointer, as before.
|
||||
None => self.moving = None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The comment being moved, drawn where it goes (as far as its frame has gone).
|
||||
pub fn paint_move(&self, painter: &egui::Painter, cx: &PageCx<'_>, cv: &CommentView, pending: Option<&Edit>) {
|
||||
let Some(((_, page, index), m)) = &self.moving else { return };
|
||||
if *page != cx.page || cx.hidden {
|
||||
return;
|
||||
}
|
||||
let Some(a) = cx.get(*index) else { return };
|
||||
let (pointer, shift) = painter.ctx().input(|i| (i.pointer.hover_pos(), i.modifiers.shift));
|
||||
// As far as its frame has gone from where the move started (once applied, its box is
|
||||
// already where it went).
|
||||
let rect = cx.adjusted_rect(a, cv.gesture.as_ref(), pointer, shift, pending);
|
||||
let from = cx.xf.user_rect(cx.info, *page, m.layer.from);
|
||||
let painter = painter.with_clip_rect(painter.clip_rect().intersect(cx.xf.rect));
|
||||
m.layer.paint(&painter, cx, *page, from.translate(rect.min - from.min));
|
||||
}
|
||||
}
|
||||
|
||||
impl StampLayer {
|
||||
/// Render the layer's tile at `scale` (or less, within [`MAX_STAMP_SIDE`]) and take it when
|
||||
/// it arrives. `Some(true)` while the pixels at this scale are still on their way, `None`
|
||||
/// when there is nothing to draw.
|
||||
fn update(&mut self, ctx: &egui::Context, page: usize, p: &PageInfo, scale: f32) -> Option<bool> {
|
||||
let (scale, tile) = stamp_tile(p, self.from, scale)?;
|
||||
let tag = (scale * 1000.0) as u64;
|
||||
if self.tag != Some(tag) {
|
||||
self.renderer.set_queue(vec![RenderRequest { page, scale, tag, tile: Some(tile), ..Default::default() }]);
|
||||
self.tag = Some(tag);
|
||||
}
|
||||
if let Some(out) = self.renderer.try_recv()
|
||||
&& out.request.tag == tag
|
||||
{
|
||||
let (Some(t), None) = (out.request.tile, &out.error) else { return None };
|
||||
let s = out.request.scale;
|
||||
let area = [t.x as f32 / s, t.y as f32 / s, t.x.saturating_add(t.w) as f32 / s, t.y.saturating_add(t.h) as f32 / s];
|
||||
let image = crate::canvas::texture_image([out.width as usize, out.height as usize], out.rgba);
|
||||
self.pixels = Some((tag, ctx.load_texture("signature-drag-stamp", image, TextureOptions::LINEAR), area));
|
||||
}
|
||||
Some(self.pixels.as_ref().is_none_or(|(received, ..)| *received != tag))
|
||||
}
|
||||
|
||||
/// The layer's pixels where its box goes on screen, `rect`: moved, and stretched as its box
|
||||
/// is from `from` to `rect`.
|
||||
fn paint(&self, painter: &egui::Painter, cx: &PageCx<'_>, page: usize, rect: egui::Rect) {
|
||||
let Some((_, texture, area)) = &self.pixels else { return };
|
||||
let from = cx.xf.user_rect(cx.info, page, self.from);
|
||||
if from.width() < 0.01 || from.height() < 0.01 {
|
||||
return;
|
||||
}
|
||||
let stretch = vec2(rect.width() / from.width(), rect.height() / from.height());
|
||||
let mut mesh = egui::Mesh::with_texture(texture.id());
|
||||
for (u, v) in [(0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 1.0)] {
|
||||
let (x, y) = (area[0] + u * (area[2] - area[0]), area[1] + v * (area[3] - area[1]));
|
||||
let at = cx.xf.norm_to_screen(x / cx.xf.pw, y / cx.xf.ph);
|
||||
let pos = rect.min + (at - from.min) * stretch;
|
||||
mesh.vertices.push(egui::epaint::Vertex { pos, uv: pos2(u, v), color: egui::Color32::WHITE });
|
||||
}
|
||||
mesh.add_triangle(0, 1, 2);
|
||||
mesh.add_triangle(0, 2, 3);
|
||||
painter.add(egui::Shape::mesh(mesh));
|
||||
}
|
||||
}
|
||||
|
||||
impl Layers {
|
||||
|
||||
@@ -2657,3 +2657,64 @@ fn text_comments_can_save_a_zero_width_default() {
|
||||
assert_eq!(restored.comment_prefs.style(CommentTool::TextBox).width, 0.0);
|
||||
assert_eq!(restored.comment_prefs.style(CommentTool::Callout).width, 0.0);
|
||||
}
|
||||
|
||||
/// Moving a rectangle or an oval showed only its blue frame going while the shape stayed where
|
||||
/// it was until release. Now the comment itself is drawn where it goes (the page beneath shows it
|
||||
/// where it was until the move is applied), and the pointer frames change nothing.
|
||||
#[test]
|
||||
fn a_moved_comment_is_drawn_where_it_goes_before_release() {
|
||||
let _gpu = crate::gpu();
|
||||
let mut h = harness(|_| {});
|
||||
let id = h.state().views[0].id;
|
||||
for shape in
|
||||
[pdfcraft_engine::Shape::Rectangle { rect: [30.0, 30.0, 70.0, 70.0] }, pdfcraft_engine::Shape::Oval { rect: [230.0, 30.0, 270.0, 70.0] }]
|
||||
{
|
||||
let style = pdfcraft_engine::Style { color: [1.0, 0.0, 0.0], opacity: 1.0, width: 2.0, fill: Some([1.0, 0.0, 0.0]) };
|
||||
assert!(h.state_mut().apply_edit(pdfcraft_engine::Edit::AddAnnotation(pdfcraft_engine::NewAnnotation {
|
||||
page: 0,
|
||||
shape,
|
||||
style,
|
||||
contents: String::new(),
|
||||
author: "Tester".into(),
|
||||
})));
|
||||
}
|
||||
h.state_mut().views[0].comments.selected = None;
|
||||
crate::support::settle(&mut h);
|
||||
let red = |v: [u8; 4]| v[0] > 200 && v[1] < 80 && v[2] < 80;
|
||||
for ((x, y), (tx, ty)) in [((50.0, 50.0), (150.0, 120.0)), ((250.0, 50.0), (200.0, 150.0))] {
|
||||
let generation = h.state().session.get(id).unwrap().edit_generation();
|
||||
let (from, to) = (at(&h, x, y), at(&h, tx, ty));
|
||||
// Beside the pointer, whose arrow is drawn over the page.
|
||||
let (was, goes) = (at(&h, x - 10.0, y - 10.0), at(&h, tx - 10.0, ty - 10.0));
|
||||
let px = |pixels: &image::RgbaImage, p: Pos2| pixels.get_pixel(p.x.round() as u32, p.y.round() as u32).0;
|
||||
h.hover_at(from);
|
||||
h.run_steps(1);
|
||||
h.drag_at(from);
|
||||
h.run_steps(1);
|
||||
for k in 1..=4 {
|
||||
h.hover_at(from + (to - from) * (k as f32 / 4.0));
|
||||
h.run_steps(1);
|
||||
}
|
||||
let mut shown = false;
|
||||
for _ in 0..100 {
|
||||
h.run_steps(2);
|
||||
let pixels = h.render().unwrap();
|
||||
if red(px(&pixels, goes)) {
|
||||
shown = true;
|
||||
assert!(red(px(&pixels, was)), "the page still shows it where it was");
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(10));
|
||||
}
|
||||
assert!(shown, "the comment at ({x}, {y}) is drawn under the pointer before release");
|
||||
assert_eq!(h.state().session.get(id).unwrap().edit_generation(), generation, "pointer frames never edit the document");
|
||||
h.drop_at(to);
|
||||
h.run_steps(3);
|
||||
let doc = h.state().session.get(id).unwrap();
|
||||
assert_eq!(doc.can_undo(), Some("Move comment"));
|
||||
crate::support::settle(&mut h);
|
||||
let pixels = h.render().unwrap();
|
||||
assert!(!red(px(&pixels, was)), "once applied it is gone from where it was");
|
||||
assert!(red(px(&pixels, goes)), "and shown where it went");
|
||||
}
|
||||
}
|
||||
@@ -47,6 +47,7 @@ No dump of Acrobat's menu tree has been diffed against the registry yet (gap 24)
|
||||
| Hand tool, middle-button pan, autoscroll | yes | hand tool; Linux autoscroll | partial |
|
||||
| Text selection, column select | yes, Alt-drag columns | reading-order selection; Alt/Option-drag or Edit ▸ Column select (#740) | shipped |
|
||||
| Select and move several objects at once | yes | on one page: Shift/Ctrl/⌘-click and marquee, one undo step (#844); not across pages, no group resize | partial |
|
||||
| Moved comments drawn where they go while dragged | yes | yes: the comment's own pixels follow the pointer, the page shows it where it was until release; stamps and Fill & Sign marks also hide the original | shipped |
|
||||
| Object handles: corners keep proportion, edges free | yes | yes for added images and signatures; Shift on a corner keeps the proportions of comments, stamps and form fields (#2199); no Alt resize from the centre | shipped |
|
||||
| Arrange, align and distribute page objects | yes | fields only | partial |
|
||||
| Rulers, grids, snap to grid, guides | yes | no | planned |
|
||||
|
||||
@@ -4042,6 +4042,18 @@ tools = ["comment_edit"]
|
||||
evidence = ["crates/annot/src/tests.rs::lines_and_polygons_are_reshaped_by_their_points", "crates/ui-egui/tests/it/comments.rs::line_ends_are_dragged_to_change_length_and_angle", "crates/automation/tests/it/automation.rs::comments_are_reshaped_through_the_tool"]
|
||||
notes = "A selected line, arrow, connected lines, polygon or cloud has a handle on each end or corner (instead of the frame); dragging one changes the length and angle (Shift holds it to 45° from the point before), with the new shape drawn while dragging, as one undo step (Edit::ReshapeAnnotation). The drawing tools keep drawing from an end (a new line starts there); the Select tool reshapes. /Rect follows the points with room for the width and arrowheads, and the appearance is drawn anew. Headless: comment_edit `points`; comment_list reports them. Measurements keep the points they measured; locked comments keep their shape. Not yet: adding or removing a corner."
|
||||
|
||||
[[feature]]
|
||||
id = "comment.move-preview"
|
||||
title = "A moved comment is drawn where it goes while it is dragged"
|
||||
area = "E"
|
||||
tier = "P1"
|
||||
milestone = "M5"
|
||||
status = "shipped"
|
||||
commands = []
|
||||
tools = []
|
||||
evidence = ["crates/ui-egui/tests/it/comments.rs::a_moved_comment_is_drawn_where_it_goes_before_release", "crates/engine/src/tests.rs::comment_layer_draws_the_comment_alone"]
|
||||
notes = "Dragging a rectangle, oval, line, text box or any other comment with the Select tool draws the comment itself under the pointer, not only its blue frame: it is rendered alone once as the drag starts (Document::comment_layer, a read-only copy with only that comment and no page content) and laid over the page where it goes; the page beneath keeps showing it where it was until the move is applied, as a CAD program's move does (rendering the page without it would cost a whole page at every drag on a heavy plan). Stamps and Fill & Sign marks keep their own layers, which also hide the original. Pointer frames never edit the document. Not yet: resizing shows the frame only."
|
||||
|
||||
[[feature]]
|
||||
id = "comment.edit-comment-text"
|
||||
title = "Edit comment text"
|
||||
|
||||
Reference in new issue
Block a user