From 33c670671cf5f259dc8ede12901bc97e6e13b583 Mon Sep 17 00:00:00 2001 From: pepen Date: Wed, 14 Oct 2026 03:16:00 +0200 Subject: [PATCH] fix(snap): objects snap to the bleed guide Moving, resizing and drawing snapped to page edges, margins, columns and ruler guides but never to the bleed guide. `Spread::bleed_rect` now holds the bleed rectangle (shared by the canvas, which drew it with its own copy of the rule), and the guide pass offers its four sides. Co-Authored-By: Claude Fable 5.1 (cherry picked from commit ad7a78d7439a906ab3545ff98801ee37b15be127) --- ROADMAP.md | 2 ++ crates/doc/src/page.rs | 14 ++++++++++++ crates/tools/src/snap.rs | 47 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 63 insertions(+) diff --git a/ROADMAP.md b/ROADMAP.md index d60a6bf49..a09b583e5 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -410,6 +410,8 @@ Newest first. - **2026-10-13** · Text recomposes when the document baseline grid changes, when a layer holding a text-wrap object is shown or hidden, or when a section's start number, numbering style, prefix or marker changes; the composition cache keyed only on the section count and item identities and kept the old layout. No readiness percentage change. - **2026-10-13** · Reflowable EPUB and HTML export include tables (header and footer rows, merged cells), footnotes (a linked note after the paragraph that refers to it) and endnotes (a linked section at the end of the book). No readiness percentage change. - **2026-10-13** · Word import: table cells read text by the same rules as body text, so deleted text, moved-away text and field codes stay out (#721). Word's Normal comes in as its own paragraph style "Normal" over the document defaults, so text formatted only by Normal keeps its look; it conflicts only with a document's own Normal, and the style map can send it to [Basic Paragraph] (#722). No readiness percentage change. +- **2026-10-14** · Three fixes from a real job: objects snap to the bleed guide's four sides while moving, resizing or drawing (`Spread::bleed_rect` is shared by the canvas and `snap.rs`); a selected group shows the in/out ports of the text frames inside it, so an overset frame in a group shows its red mark; the status bar's error count is a menu whose Preflight Panel… entry opens the panel. No readiness percentage change. +- **2026-10-14** · Direct Selection (A) moves a placed graphic inside its frame: `transform.move` honours the content selection (or `content: true`) and translates the graphic in the frame's inner space, also for rotated frames; the frame stays. Text frames, copies and moves to another spread move as a whole. Before, the frame moved with the graphic. No readiness percentage change. - **2026-10-12** · macOS Dock Quit uses the same unsaved-document prompts as window and menu Quit. Repeated requests keep the current answers; Cancel retains every tab and unsaved recovery checkpoint, and completed Save/Don't Save answers exit through the ordinary event loop. No readiness percentage change. - **2026-10-12** · Text strokes can sit behind the fill: the stroke lies wholly outside the letters at its full weight on screen, in PNG and in PDF, set from the Properties panel or Character Style Options. IDML `StrokeAlignment` on text imports and exports as this setting, so imported text with outside-aligned strokes keeps its letter shapes (#619). No readiness percentage change. - **2026-10-12** · Placed Word documents keep their bullets and numbering (#626): `numbering.xml` levels become bullet or numbered paragraphs with Word's number formats, label text, start numbers, restarts and list indents, also when a paragraph style carries the list. No readiness percentage change. diff --git a/crates/doc/src/page.rs b/crates/doc/src/page.rs index 845d1a725..6bc0a5bb0 100644 --- a/crates/doc/src/page.rs +++ b/crates/doc/src/page.rs @@ -293,6 +293,20 @@ impl Spread { pub fn bounds(&self) -> Rect { self.pages.iter().map(Page::bounds).reduce(|a, b| a.union(b)).unwrap_or(Rect::ZERO) } + /// The bleed guide: one rectangle around the spread. `bleed` is the document's top, bottom, + /// inside, outside; the inside value applies only at an outer edge on the spine side (a lone + /// left page's right edge). None without pages or without any bleed. + pub fn bleed_rect(&self, bleed: [f64; 4]) -> Option { + let (first, last) = (self.pages.first()?, self.pages.last()?); + if !bleed.iter().any(|v| *v > 0.0) { + return None; + } + let [top, bottom, inside, outside] = bleed; + let l = if first.side == PageSide::Left { outside } else { inside }; + let r = if matches!(last.side, PageSide::Right | PageSide::Single) { outside } else { inside }; + let b = self.bounds(); + Some(Rect::new(b.x0 - l, b.y0 - top, b.x1 + r, b.y1 + bottom)) + } /// x of the spine (between left and right pages) or the page centre for single pages. pub fn spine_x(&self) -> f64 { match self.pages.iter().position(|p| p.side == PageSide::Right) { diff --git a/crates/tools/src/snap.rs b/crates/tools/src/snap.rs index a0370491a..0cdfdb019 100644 --- a/crates/tools/src/snap.rs +++ b/crates/tools/src/snap.rs @@ -1127,6 +1127,18 @@ fn pass_guides(cx: &ToolContext, req: &SnapRequest<'_>, axis: Axis, tol: f64) -> } } } + // The bleed guide's four sides, like InDesign's bleed guides. + if let Some(bleed) = sp.bleed_rect(cx.doc.settings.bleed) { + let (range_lo, range_hi) = perp_ends(bleed, axis); + if ranges_overlap(moving_lo, moving_hi, range_lo, range_hi) { + let (a, b) = match axis { + Axis::X => (bleed.x0, bleed.x1), + Axis::Y => (bleed.y0, bleed.y1), + }; + offer_line(&mut best, req.rect, flags, axis, a, tol); + offer_line(&mut best, req.rect, flags, axis, b, tol); + } + } best.map(|b| b.hit) } @@ -1546,6 +1558,41 @@ mod tests { } } + /// The bleed guide attracts like a ruler guide: a top edge 2 pt below the top bleed line + /// snaps up to it; with no bleed there is nothing there to snap to. + #[test] + fn bleed_guide_is_a_snap_target() { + let far = Rect::new(300.0, 300.0, 320.0, 320.0); + for (bleed, expected) in [([9.0, 9.0, 9.0, 9.0], -2.0), ([0.0; 4], 0.0)] { + let (mut doc, moving, _other) = doc_with_items(Rect::new(80.0, 100.0, 100.0, 140.0), far, 500.0); + doc.settings.bleed = bleed; + let top = doc.spreads[0].bounds().y0 - 9.0; + let rect = Rect::new(80.0, top + 2.0, 100.0, top + 40.0); + let cache = Cache::new(); + let layout = CanvasLayout::new(&doc, false); + let sel = Selection::default(); + let mut cx = ctx_on(&doc, &sel, &cache, &layout); + cx.snap.zone_px = 4.0; + let hit = snap( + &cx, + SnapRequest { + spread: SpreadRef::Doc(0), + gesture: Gesture::Move, + rect, + x_edges: [false, false, false], + y_edges: [true, true, true], + exclude: &[moving], + copying: false, + lengths: [None, None], + angle: None, + radius: 0.0, + pointer: Point::new(0.0, 0.0), + }, + ); + assert!((hit.delta.y - expected).abs() < 1e-6, "bleed {bleed:?}: {:?}", hit.delta); + } + } + #[test] fn guide_beats_a_closer_object_edge() { // Right edge at 100.5. Object edge at 100 (0.5 away). Guide at 104 (3.5 away). Zone is 4.