Slices: resizing a slice snaps as drawing one does

Dragging a user slice's edge or corner handle with the Slice Selection
tool never snapped, while drawing a slice with the Slice tool snaps to
the grid, to whole pixels, to the art and to the ruler guides.

The handle drag now gathers its targets when the handle is pressed and
snaps what the handle moves through PointSnap::snap_scale, as the
Selection tool's bounding-box handles do: to the nearest gridline with
Snap to Grid, to whole pixels with Snap to Pixel, to the art and the
artboards with Smart Guides, and with Smart Guides off to the art's
anchors and the ruler guides with Snap to Point. Then a shown, unlocked
ruler guide within 5 px moves the side or corner onto it, as the Slice
tool moves a drawn slice's corner onto a guide. Shift still keeps the
proportions, and Alt still resizes about the center.

Docs: docs/target-app-parity.md.
This commit is contained in:
dotCooCoo
2026-10-18 12:40:11 +07:00
committed by bflatastic
parent 034ef916ad
commit 282ed8955b
2 changed files with 135 additions and 5 deletions
+134 -5
View File
@@ -5,14 +5,16 @@
//! commits it as one undo step. The Slice Selection tool clicks a slice to select it (Shift adds or
//! drops it), drags the selected slices to move them (`object.slice.move`: an object slice moves
//! its object), drags a selected user slice's edge or corner handle to resize it
//! (`object.slice.setRect`), opens Slice Options on a double click and deletes the selected slices
//! with Delete. Hidden or locked slices (View menu) can't be picked.
//! (`object.slice.setRect`: what the handle moves snaps as the Slice tool's drag does, Shift keeps
//! the proportions and Alt resizes about the center), opens Slice Options on a double click and
//! deletes the selected slices with Delete. Hidden or locked slices (View menu) can't be picked.
use serde_json::{Value, json};
use vectorcraft_doc::{NodeId, SliceSource};
use vectorcraft_geom::{Point, Rect, Vec2};
use vectorcraft_doc::{NodeId, OrientedBox, SliceSource};
use vectorcraft_geom::{Affine, Point, Rect, Vec2};
use crate::bbox::{Handle, hit_handle, move_delta, scale_for_drag};
use crate::guides::{PointSnap, Targets};
use crate::shape::drag_rect;
use crate::xform::{BLUE, polygon, rect_corners};
use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey, json_ids};
@@ -150,10 +152,40 @@ impl Drag {
}
}
/// What a user slice's handle snaps to, gathered when it is pressed: what the Slice tool's drag
/// snaps to ([`snap`]), the art through [`PointSnap`] and the ruler guides on their own.
struct ResizeSnap {
art: PointSnap,
rulers: Targets,
}
impl ResizeSnap {
fn new(cx: &ToolContext) -> Self {
Self { art: PointSnap::new(cx, || Targets::collect(cx.doc, &[], None)), rulers: Targets::rulers(cx) }
}
/// Snap the resize `a` ([`scale_for_drag`]) of the slice `rect` by `handle` as [`snap`] snaps
/// a drawn slice's corner: the handle snaps as a bounding-box handle does
/// ([`PointSnap::snap_scale`]), then a ruler guide within 5 px of where that leaves it takes
/// what the handle moves on its axis. Returns the scale and the guides showing why.
fn scale(&self, cx: &ToolContext, rect: Rect, handle: Handle, a: Affine, m: Mods) -> (Affine, Vec<Overlay>) {
let bx = OrientedBox::aligned(rect);
let (a, ov) = self.art.snap_scale(cx, &bx, handle, a, m.shift, m.alt);
let (b, guide) = self.rulers.snap_scale(&bx, handle, a, m.shift, m.alt, cx.tol(5.0));
let (r, s) = (a.transform_rect_bbox(rect), b.transform_rect_bbox(rect));
let moved = [r.x0 - s.x0, r.y0 - s.y0, r.x1 - s.x1, r.y1 - s.y1].iter().any(|d| d.abs() > 1e-9);
if moved || !guide.is_empty() { (b, guide) } else { (a, ov) }
}
}
/// The Slice Selection tool: select, move, resize and delete slices.
#[derive(Default)]
pub struct SliceSelectionTool {
drag: Option<Drag>,
/// What the user slice handle being dragged snaps to.
snap: Option<ResizeSnap>,
/// The guides showing what the resize snapped to.
guides: Vec<Overlay>,
}
/// Can the slices be picked (not hidden or locked)?
@@ -194,11 +226,14 @@ impl Tool for SliceSelectionTool {
match (ev.kind, self.drag.clone()) {
(PointerKind::Down, _) => {
self.drag = None;
self.snap = None;
self.guides.clear();
if !pickable(cx) {
return vec![];
}
if let Some((id, handle, rect)) = handle_at(cx, p) {
self.drag = Some(Drag::Resize { id, handle, rect, began: false });
self.snap = Some(ResizeSnap::new(cx));
return vec![];
}
let selected = &cx.selection.slices;
@@ -235,7 +270,11 @@ impl Tool for SliceSelectionTool {
out.push(Action::Begin("Resize Slice".into()));
}
self.drag = Some(Drag::Resize { id, handle, rect, began: true });
let r = scale_for_drag(rect, handle, p, m.shift, m.alt).transform_rect_bbox(rect);
let mut a = scale_for_drag(rect, handle, p, m.shift, m.alt);
if let Some(s) = &self.snap {
(a, self.guides) = s.scale(cx, rect, handle, a, m);
}
let r = a.transform_rect_bbox(rect);
if has_area(r) {
let mut v = rect_json(r);
v["id"] = json!(id.0);
@@ -245,10 +284,14 @@ impl Tool for SliceSelectionTool {
}
(PointerKind::Up, Some(d)) => {
self.drag = None;
self.snap = None;
self.guides.clear();
if d.began() { vec![Action::Commit] } else { vec![] }
}
(PointerKind::DoubleClick, _) => {
self.drag = None;
self.snap = None;
self.guides.clear();
match slice_at(cx, p).filter(|_| pickable(cx)) {
Some(id) if cx.selection.slices == [id] => vec![Action::Dialog("sliceOptions".into(), json!({}))],
Some(id) => vec![Self::select(&[id]), Action::Dialog("sliceOptions".into(), json!({}))],
@@ -282,6 +325,7 @@ impl Tool for SliceSelectionTool {
o.extend(Handle::ALL.iter().map(|h| Overlay::Anchor { p: h.pos(r), color: BLUE, filled: false, size: 7.0 }));
}
}
o.extend(self.guides.iter().cloned());
// Smart Guides › Measurement Labels: the size readouts while resizing or moving slices.
match self.drag.as_ref().filter(|_| cx.measurement_labels) {
Some(Drag::Resize { id, began: true, .. }) => {
@@ -317,6 +361,8 @@ impl Tool for SliceSelectionTool {
}
fn deactivate(&mut self, _cx: &ToolContext) -> Vec<Action> {
self.snap = None;
self.guides.clear();
if self.drag.take().is_some_and(|d| d.began()) { vec![Action::Cancel] } else { vec![] }
}
}
@@ -437,4 +483,87 @@ mod tests {
assert!(!t.claims_key(&locked, ToolKey::Delete));
assert_eq!(t.cursor(&locked, Point::new(60.0, 40.0), Mods::default()), Cursor::SliceSelect);
}
/// The rectangle the last `object.slice.setRect` preview in `a` gives the slice.
fn set_rect(a: &[Action]) -> Rect {
let Some(Action::Preview(c, v)) = a.last() else { panic!("{a:?}") };
assert_eq!(c, "object.slice.setRect");
let n = |k: &str| v[k].as_f64().unwrap();
Rect::new(n("x"), n("y"), n("x") + n("width"), n("y") + n("height"))
}
fn near(a: Rect, b: Rect) -> bool {
[a.x0 - b.x0, a.y0 - b.y0, a.x1 - b.x1, a.y1 - b.y1].iter().all(|d| d.abs() < 1e-9)
}
/// Drag a handle of slice `a` (10–60 × 10–40, selected) from `from` to `to` with `mods`: the
/// rectangle the drag previews.
fn resize(c: &ToolContext, from: (f64, f64), to: (f64, f64), mods: Mods) -> Rect {
let mut t = SliceSelectionTool::default();
assert!(t.pointer(c, &with(PointerKind::Down, from.0, from.1, mods)).is_empty());
set_rect(&t.pointer(c, &with(PointerKind::Drag, to.0, to.1, mods)))
}
/// With View › Snap to Grid on, a slice's handle lands what it moves on the nearest gridline
/// (every 9 pt by default), as the Slice tool's drag and the Selection tool's handles do;
/// with Snap to Pixel on, on whole pixels.
#[test]
fn slice_handles_snap_to_the_grid_and_to_pixels() {
let (d, a, _) = doc_with_slices();
let p = paint();
let mut sel = Selection::default();
sel.set_slices([a]);
let grid = ToolContext { snap_to_grid: true, ..cx(&d, &sel, &p) };
let none = Mods::default();
// The right side, dragged to x = 64, lands on the gridline at 63.
assert!(near(resize(&grid, (60.0, 25.0), (64.0, 25.0), none), Rect::new(10.0, 10.0, 63.0, 40.0)));
// The bottom-right corner, dragged to (64, 46), lands on (63, 45).
assert!(near(resize(&grid, (60.0, 40.0), (64.0, 46.0), none), Rect::new(10.0, 10.0, 63.0, 45.0)));
// Shift keeps the proportions, with a moved side on a gridline.
let r = resize(&grid, (60.0, 40.0), (64.0, 46.0), Mods { shift: true, ..none });
assert!((r.width() / r.height() - 50.0 / 30.0).abs() < 1e-9 && (r.x0, r.y0) == (10.0, 10.0), "{r:?}");
assert!([r.x1, r.y1].iter().any(|v| (v / 9.0 - (v / 9.0).round()).abs() < 1e-9), "{r:?}");
// Alt: the right side lands on 63 and the left side mirrors it about the center (35).
assert!(near(resize(&grid, (60.0, 25.0), (64.0, 25.0), Mods { alt: true, ..none }), Rect::new(7.0, 10.0, 63.0, 40.0)));
let pixel = ToolContext { snap_to_pixel: true, ..cx(&d, &sel, &p) };
assert!(near(resize(&pixel, (60.0, 40.0), (72.4, 50.7), none), Rect::new(10.0, 10.0, 72.0, 51.0)));
}
/// A slice's handle snaps to the art as the Slice tool's drag does: with Smart Guides on it
/// lines up with the other objects' edges, and with them off View › Snap to Point lands it on
/// an anchor within 2 px. Then a ruler guide within 5 px moves the side or the corner onto
/// it, with Smart Guides on or off.
#[test]
fn slice_handles_snap_to_the_art_and_the_ruler_guides() {
let (mut d, a, _) = doc_with_slices();
let p = paint();
let mut sel = Selection::default();
sel.set_slices([a]);
let none = Mods::default();
let on = cx(&d, &sel, &p);
// The right side, 3 px from the rectangle's left edge (x = 100), lands on it, and the
// guide shows until the button goes up.
let mut t = SliceSelectionTool::default();
t.pointer(&on, &ev(PointerKind::Down, 60.0, 25.0));
let r = set_rect(&t.pointer(&on, &ev(PointerKind::Drag, 97.0, 25.0)));
assert!(near(r, Rect::new(10.0, 10.0, 100.0, 40.0)), "{r:?}");
assert!(t.overlays(&on).iter().any(|o| matches!(o, Overlay::Line { .. })));
t.pointer(&on, &ev(PointerKind::Up, 97.0, 25.0));
assert!(!t.overlays(&on).iter().any(|o| matches!(o, Overlay::Line { .. })));
// Smart Guides off: Snap to Point lands the corner, 1.4 px from the rectangle's corner, on it.
let off = ToolContext { smart_guides: false, ..cx(&d, &sel, &p) };
assert!(near(resize(&off, (60.0, 40.0), (99.0, 101.0), none), Rect::new(10.0, 10.0, 100.0, 100.0)));
// Snap to Point off too: the side follows the pointer.
let neither = ToolContext { smart_guides: false, snap_to_point: false, ..cx(&d, &sel, &p) };
assert!(near(resize(&neither, (60.0, 25.0), (97.0, 25.0), none), Rect::new(10.0, 10.0, 97.0, 40.0)));
// A ruler guide at x = 98 takes the side dragged 1 px from it, over the rectangle's edge
// 1 px the other way, with Smart Guides on or off.
d.guides.push(Guide::new(true, 98.0));
for c in [cx(&d, &sel, &p), ToolContext { smart_guides: false, snap_to_point: false, ..cx(&d, &sel, &p) }] {
assert!(near(resize(&c, (60.0, 25.0), (99.0, 25.0), none), Rect::new(10.0, 10.0, 98.0, 40.0)));
}
// With Snap to Grid on, the guide takes the corner's x and the grid still takes its y.
let grid = ToolContext { snap_to_grid: true, ..cx(&d, &sel, &p) };
assert!(near(resize(&grid, (60.0, 40.0), (99.0, 46.0), none), Rect::new(10.0, 10.0, 98.0, 45.0)));
}
}
+1
View File
@@ -574,6 +574,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
- **Drawing aids:** Smart Guides and snapping (moving, with the Selection, Group Selection or Direct Selection tool, and resizing by the bounding-box handles with the Selection or Free Transform tool, Shift and Alt too: the dragged corner or side lands on the other objects' anchors and centres ("anchor", "center") or lines up with their edges and centres (a line and "align"); with Smart Guides › Spacing Guides, a moved selection that lines up with nothing along an axis lands as far from its neighbour as two other objects of its row or column are apart, or halfway between its two neighbours, the equal gaps marked; with View › Snap to Grid on, a moved selection lands on the grid instead, by whichever of its edges or its centre is nearest a gridline on each axis, #740; the Rotate, Scale, Shear and Reflect tools drag from the anchor they grab and snap the pointer the same way, so a corner dragged onto another object's anchor scales exactly onto it, #482; anchors dragged with Direct Selection: the one pressed on lands on other anchors, its own path's that stay put among them, on path segments or in line with them, the others following, Shift keeps 45° steps, and with Smart Guides off Snap to Point pulls it onto anchors; the Artboard tool snaps artboards, and their bleed, to other artboards, bleeds and art, the grid or whole pixels while moving, copying and resizing them, and the Transform panel shows and edits the active artboard's X/Y/W/H; drawn points, #506: hovering shows where the press goes, the point lands on the other objects' anchors, centres and paths ("anchor", "center", "path") or lines up with their anchors, edges and centres ("align"), the Pen's on the path being drawn too, near a construction guide through the anchor before it on that guide (Preferences › Smart Guides › Construction Guides and Angles), and with Shift kept to its 45° step or a square's diagonal it slides along that way into line; the targets are gathered once per document state, from the art in the window, and the anchors, centres and lines near the pointer are found by bisection, so hovering never rescans the document), guides dragged out of the rulers (#451: they snap as moved guides do, Shift to the ruler's ticks; with the Artboard tool they are artboard guides, which run across their artboard only and move, copy and delete with it, `guide.add {artboard}`) that the Selection, Direct Selection and Group Selection tools then pick and drag (#414: the selected guides highlight and move together, Alt copies them, Shift snaps the dragged one to the ticks, otherwise it snaps to pixels, the grid or the edges, centres (so the sides' midpoints) and anchors of the art and the artboards, or with Smart Guides off Snap to Point pulls it onto anchors; Shift-clicked they join the selected art, and Shift-added art keeps them, so guides and art are selected together in either order, Delete then deleting both in one step; the arrow keys nudge them, Delete deletes them, dragging one back onto its ruler deletes it, Lock Guides and Hide Guides leave them alone; `guide.list`, `guide.select`, `guide.move` and `guide.remove` reach agents), a right-click on either ruler or the origin box to pick the document's units (the current one checked; `document.setUnits`, one undo step, #528), and Draw Normal / Behind / Inside modes. Smart Guides: a point drawn with the Pen or a shape tool, and an anchor dragged with Direct Selection, snaps to where paths and ruler guides cross, labelled "intersect" (#1658, part of #1602; a moved selection's own anchors don't).
A move that Shift keeps level, upright or diagonal stays on that line when it snaps, with the Selection, Group Selection or Direct Selection tool. It slides along the line until the selection lands on the grid or on whole pixels, lines up with the other objects or is spaced evenly with them, or until the line passes an anchor or crosses a ruler guide near the point the selection was grabbed by. A level or upright move snaps along its own axis only.
A bounding-box handle dragged with the Selection or Free Transform tool snaps what it moves as a dragged point snaps (#2054). With View › Snap to Grid on, it snaps to the nearest gridline, and with Snap to Pixel on, it snaps to whole pixels. With Smart Guides off and Snap to Point on, it snaps to the other objects' anchors and the ruler guides within the Snap to Point distance. With Smart Guides and Snap to Point both on, it lines up with the ruler guides as well as the other objects. Shift still keeps the proportions, and Alt still scales about the center.
A user slice's edge or corner handle dragged with the Slice Selection tool snaps as the Slice tool's drag does. With View › Snap to Grid on, it snaps to the nearest gridline, and with Snap to Pixel on, it snaps to whole pixels. Otherwise, with Smart Guides on, it lands on or lines up with the anchors, centers and edges of the art and the artboards, and with Smart Guides off and Snap to Point on, it snaps to the art's anchors and the ruler guides within the Snap to Point distance. Then a ruler guide within 5 px, shown and unlocked, moves the side or corner onto it. Shift still keeps the proportions, and Alt still resizes about the center.
- **Artboard click-to-fit (#1227):** a plain click fits the topmost editable object or group to a new artboard, including its stroke like Fit to Selected Art, and makes the new artboard active. Art and its selection stay unchanged; creation is one undo step. Handles, modified clicks, drags, Escape and tool changes retain their meaning. Tested inside an artboard and on the pasteboard (`crates/engine/tests/artboard_click.rs`); the public [CS6 Printing Guide](https://helpx.adobe.com/content/dam/help/en/indesign/pdf/CS6_PrintGuide.pdf) documents the object-click gesture. This legacy gesture adds evidence without regrading the 2026 canvas controls.
- **Rotated views:** with the view rotated, Fit Artboard in Window, Fit All in Window and the status bar's artboard navigator fit the artboard or the art as the view turns it, and a Zoom tool marquee (Animated Zoom or GPU Performance off) zooms to the area dragged across. Ruler guides on no artboard turn with the view. The canvas draws the selection marquee where the tools select, Show Hidden Characters draws the marks of type partly inside the canvas, the Contextual Task Bar stays under the selection, and the Navigator's red box outlines what the window shows.
- **Views:** the status bar's artboard navigator (first, previous, next, last; `view.goToArtboard`) goes to an artboard, fits it and synchronizes the Artboard tool selection when that tool is active; ordinary object selection is preserved. Actual Next-button regressions cover keyboard/Properties deletion and Undo in `chrome.rs`. Fit Artboard in Window and Actual Size show the navigator's artboard. The Artboards panel highlights that active artboard: clicking a row makes it active (the Artboard tool's too, while it is chosen) as does clicking an artboard with the Artboard tool, double-clicking its number also fits it in the window and double-clicking its name renames it. Duplicate Artboards, or a row dragged onto New Artboard, copies the artboard with its art (#446). Escape leaves Presentation Mode, `view.screenMode` from it goes back to Normal Screen Mode, and the screen mode isn't saved with the preferences: the app always starts in Normal Screen Mode with its menus and panels (#472). View › Pixel Preview shows the art as the document pixels it rasterizes to, one per point and hard-edged when magnified, turning with a rotated view (it used to draw the smooth vectors, with only the pixel grid over them). In Outline view a template layer keeps drawing in Preview, dimmed, so a picture placed to be traced stays visible under the outlines (#1505, fixes #1486).