mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-11 16:33:22 -06:00
802 lines
38 KiB
Rust
802 lines
38 KiB
Rust
//! Artboard tool (Shift+O).
|
||
//!
|
||
//! Click an object to create an artboard fitted to its visible bounds; drag still moves the
|
||
//! containing artboard or draws one on the pasteboard. Click an empty part of an artboard to
|
||
//! make it active (dashed bounds, 8 handles and its name); Shift-click adds
|
||
//! artboards to the selection or takes them out (dashed bounds), and a drag or Delete then acts on
|
||
//! all of them (#1139). Drag inside moves
|
||
//! it (with its artwork when the `moveArt` option is on, Shift constrains; Alt moves a copy and
|
||
//! leaves the artboard where it was), drag a handle resizes it
|
||
//! (Shift proportional, Alt from centre; with the `scaleArt` option on, Scale Artwork with Artboard,
|
||
//! always proportional, and the art fully inside scales with it), drag on the pasteboard draws a new artboard, Delete removes
|
||
//! the active one (Copy, Cut and Paste take it with its art: `artboard.copy`) and Escape returns to
|
||
//! the Selection tool. Moving and resizing snap like drawing
|
||
//! does: to whole pixels, to the grid, or with Smart Guides to other artboards, their bleed and
|
||
//! objects (never to the dragged artboard or the art moving or scaling with it); the artboard's own bleed edges
|
||
//! snap too. A new artboard's corners snap as drawn points do ([`DrawSnap`]).
|
||
|
||
use serde_json::{Value, json};
|
||
use vectorcraft_geom::{Affine, Point, Rect, Vec2};
|
||
|
||
use super::{BLUE, polygon, rect_corners};
|
||
use crate::bbox::{Handle, hit_handle, move_delta, scale_for_drag};
|
||
use crate::guides::{DrawSnap, Leave, Targets};
|
||
use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey};
|
||
|
||
#[derive(Clone, Copy, Debug)]
|
||
enum Drag {
|
||
/// `rect` is the artboard as it was when the drag started: during the drag the document
|
||
/// already shows the preview, so it can't be read back from there. `copy`: Alt was held at the
|
||
/// last drag event (the step under way is a duplicate).
|
||
Move {
|
||
index: usize,
|
||
/// Several artboards move together (the selected ones, `index` among them).
|
||
many: bool,
|
||
start: Point,
|
||
rect: Rect,
|
||
began: bool,
|
||
copy: bool,
|
||
},
|
||
/// `grab` is the handle's offset from the pointer, so the handle itself follows and snaps.
|
||
Resize {
|
||
index: usize,
|
||
handle: Handle,
|
||
rect: Rect,
|
||
grab: Vec2,
|
||
began: bool,
|
||
},
|
||
Create {
|
||
start: Point,
|
||
cur: Point,
|
||
},
|
||
}
|
||
|
||
pub struct ArtboardTool {
|
||
/// Index of the active artboard.
|
||
pub active: usize,
|
||
/// The other selected artboards (Shift-click), in order.
|
||
pub others: Vec<usize>,
|
||
/// Move artwork with the artboard.
|
||
pub move_art: bool,
|
||
/// Scale artwork with the artboard: a resize takes the art fully inside it along, and keeps the
|
||
/// artboard's proportions.
|
||
pub scale_art: bool,
|
||
drag: Option<Drag>,
|
||
/// The pressed object can be fitted on release; a drag, modifier or cancellation takes over.
|
||
click_art: Option<(Point, Rect)>,
|
||
preview: Option<Rect>,
|
||
/// Smart Guide targets, gathered when a move or resize starts.
|
||
targets: Option<Targets>,
|
||
/// The targets for an Alt-drag copy: the original artboard and its art stay where they are.
|
||
copy_targets: Option<Targets>,
|
||
/// Smart Guides shown while dragging.
|
||
guides: Vec<Overlay>,
|
||
/// Smart Guides for the corners of a new artboard.
|
||
draw: DrawSnap,
|
||
}
|
||
|
||
impl Default for ArtboardTool {
|
||
fn default() -> Self {
|
||
Self {
|
||
active: 0,
|
||
others: vec![],
|
||
move_art: true,
|
||
scale_art: false,
|
||
drag: None,
|
||
click_art: None,
|
||
preview: None,
|
||
targets: None,
|
||
copy_targets: None,
|
||
guides: vec![],
|
||
draw: DrawSnap::default(),
|
||
}
|
||
}
|
||
}
|
||
|
||
fn rect_json(index: usize, r: Rect) -> Value {
|
||
json!({ "index": index, "x": r.x0, "y": r.y0, "width": r.width(), "height": r.height() })
|
||
}
|
||
|
||
impl ArtboardTool {
|
||
fn active_rect(&self, cx: &ToolContext) -> Option<Rect> {
|
||
cx.doc.artboards.get(self.active).map(|a| a.rect)
|
||
}
|
||
|
||
/// The selected artboards that are still in the document: the active one and the others,
|
||
/// in order.
|
||
fn selected(&self, cx: &ToolContext) -> Vec<usize> {
|
||
let mut all: Vec<usize> = std::iter::once(self.active).chain(self.others.iter().copied()).filter(|i| *i < cx.doc.artboards.len()).collect();
|
||
all.sort_unstable();
|
||
all.dedup();
|
||
all
|
||
}
|
||
|
||
/// Shift-click on artboard `i`: into the selection, active, or out of it (another becomes
|
||
/// active).
|
||
fn toggle(&mut self, cx: &ToolContext, i: usize) {
|
||
let mut sel = self.selected(cx);
|
||
if let Some(at) = sel.iter().position(|j| *j == i) {
|
||
if sel.len() > 1 {
|
||
sel.remove(at);
|
||
self.active = if i == self.active { sel.first().copied().unwrap_or(0) } else { self.active };
|
||
}
|
||
} else {
|
||
sel.push(i);
|
||
self.active = i;
|
||
}
|
||
self.others = sel.into_iter().filter(|j| *j != self.active).collect();
|
||
}
|
||
|
||
/// Gather the Smart Guide targets for dragging artboard `index` (call before the first preview,
|
||
/// while the document still shows where everything started).
|
||
fn begin_snapping(&mut self, cx: &ToolContext, index: usize, art_moves: bool) {
|
||
self.guides.clear();
|
||
self.targets = cx.smart_guides.then(|| {
|
||
let art = match cx.doc.artboards.get(index) {
|
||
Some(a) if art_moves => cx.doc.art_on_artboard(a.rect, cx.move_locked_with_artboard),
|
||
_ => vec![],
|
||
};
|
||
Targets::for_artboard(cx.doc, index, &art).styled(cx)
|
||
});
|
||
// Nothing to skip: no artboard has this index.
|
||
self.copy_targets = cx.smart_guides.then(|| Targets::for_artboard(cx.doc, usize::MAX, &[]).styled(cx));
|
||
}
|
||
|
||
/// Snap a dragged handle: to whole pixels, the grid, or Smart Guides (in that order, as when
|
||
/// drawing). With Smart Guides the bleed edge `bleed` beyond the handle may snap instead.
|
||
fn snap_handle(&mut self, cx: &ToolContext, p: Point, bleed: Vec2) -> Point {
|
||
self.guides.clear();
|
||
if cx.snap_to_pixel {
|
||
return Point::new(p.x.round(), p.y.round());
|
||
}
|
||
if cx.snap_to_grid {
|
||
return vectorcraft_geom::snap::snap_point_to_grid(p, cx.grid_step());
|
||
}
|
||
let Some(t) = &self.targets else { return p };
|
||
let offsets: &[Vec2] = if bleed == Vec2::ZERO { &[] } else { &[bleed] };
|
||
let (q, ov) = t.snap_point_with(p, offsets, cx.snap_tol());
|
||
self.guides = ov;
|
||
q
|
||
}
|
||
|
||
/// Snap a move (or an Alt-drag `copy`) of `rect` by `d`: its top-left to whole pixels or the
|
||
/// grid, or any of its edges and centre to Smart Guides. Returns the snapped delta.
|
||
fn snap_move(&mut self, cx: &ToolContext, rect: Rect, d: Vec2, copy: bool) -> Vec2 {
|
||
self.guides.clear();
|
||
let tl = Point::new(rect.x0, rect.y0);
|
||
if cx.snap_to_pixel {
|
||
return Vec2::new((tl.x + d.x).round() - tl.x, (tl.y + d.y).round() - tl.y);
|
||
}
|
||
if cx.snap_to_grid {
|
||
return vectorcraft_geom::snap::snap_point_to_grid(tl + d, cx.grid_step()) - tl;
|
||
}
|
||
let Some(t) = (if copy { &self.copy_targets } else { &self.targets }) else { return d };
|
||
let moved = rect + d;
|
||
let (adj, ov) = if cx.doc.setup.has_bleed() {
|
||
t.snap_rects(&[moved, cx.doc.setup.bleed_rect(moved)], cx.snap_tol())
|
||
} else {
|
||
t.snap_rect(moved, cx.snap_tol())
|
||
};
|
||
self.guides = ov;
|
||
d + adj
|
||
}
|
||
|
||
fn end_drag(&mut self) {
|
||
self.drag = None;
|
||
self.click_art = None;
|
||
self.preview = None;
|
||
self.targets = None;
|
||
self.copy_targets = None;
|
||
self.guides.clear();
|
||
self.draw.clear();
|
||
}
|
||
}
|
||
|
||
/// An artboard's shortest side (as `artboard.setProps` keeps it).
|
||
const MIN_SIDE: f64 = 1.0;
|
||
|
||
fn create_artboard(r: Rect) -> Vec<Action> {
|
||
vec![
|
||
Action::Exec("artboard.new".into(), json!({ "x": r.x0, "y": r.y0, "width": r.width(), "height": r.height() })),
|
||
Action::Notify("created".into()),
|
||
]
|
||
}
|
||
|
||
/// `r` grown about `anchor` until both sides are at least [`MIN_SIDE`], by one factor, so a
|
||
/// proportional resize keeps its proportions where the command would lengthen only the short side.
|
||
fn keep_min_side(r: Rect, anchor: Point) -> Rect {
|
||
let short = r.width().min(r.height());
|
||
if short >= MIN_SIDE || short <= 0.0 || !short.is_finite() {
|
||
return r;
|
||
}
|
||
let k = MIN_SIDE / short;
|
||
(Affine::translate(anchor.to_vec2()) * Affine::scale(k) * Affine::translate(-anchor.to_vec2())).transform_rect_bbox(r)
|
||
}
|
||
|
||
/// Where the bleed edge lies beyond a handle, along the axes the handle moves.
|
||
fn bleed_offset(cx: &ToolContext, rect: Rect, handle: Handle) -> Vec2 {
|
||
let d = handle.pos(cx.doc.setup.bleed_rect(rect)) - handle.pos(rect);
|
||
let (ax, ay) = handle.axes();
|
||
Vec2::new(if ax { d.x } else { 0.0 }, if ay { d.y } else { 0.0 })
|
||
}
|
||
|
||
/// The grid's snapping step (gridline spacing over subdivisions), as `guides::snap_draw` uses.
|
||
impl Tool for ArtboardTool {
|
||
fn id(&self) -> &'static str {
|
||
"artboard"
|
||
}
|
||
|
||
fn busy(&self) -> bool {
|
||
self.drag.is_some()
|
||
}
|
||
|
||
fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec<Action> {
|
||
let p = ev.pos;
|
||
let m = ev.mods;
|
||
if ev.kind == PointerKind::Drag && self.click_art.is_some_and(|(start, _)| m != Mods::default() || p.distance(start) >= cx.tol(3.0)) {
|
||
self.click_art = None;
|
||
}
|
||
match (ev.kind, self.drag) {
|
||
(PointerKind::Down, _) => {
|
||
self.click_art = None;
|
||
if let Some(r) = self.active_rect(cx)
|
||
&& let Some(handle) = hit_handle(r, p, cx.tol(5.0))
|
||
{
|
||
self.drag = Some(Drag::Resize { index: self.active, handle, rect: r, grab: handle.pos(r) - p, began: false });
|
||
return vec![];
|
||
}
|
||
if m == Mods::default() {
|
||
self.click_art = vectorcraft_doc::hit::hit_test(cx.doc, p, cx.hit_options())
|
||
.and_then(|h| cx.doc.bounds_of(&[h.top_object(cx.isolation)], true))
|
||
// Match Fit to Selected Art, and do not stretch zero-sized art to the
|
||
// artboard command's minimum side or pass non-finite bounds to it.
|
||
.filter(|r| r.width() >= MIN_SIDE && r.height() >= MIN_SIDE && [r.x0, r.y0, r.x1, r.y1].iter().all(|n| n.is_finite()))
|
||
.map(|r| (p, r));
|
||
}
|
||
if let Some(i) = cx.doc.artboard_at(p)
|
||
&& let Some(a) = cx.doc.artboards.get(i)
|
||
{
|
||
if m.shift {
|
||
self.toggle(cx, i);
|
||
} else if !self.selected(cx).contains(&i) {
|
||
// A plain click on an artboard that isn't selected selects just it; on a
|
||
// selected one, the selection stays, so the drag moves them all.
|
||
self.others.clear();
|
||
self.active = i;
|
||
}
|
||
let many = self.selected(cx).len() > 1 && self.selected(cx).contains(&i);
|
||
self.drag = Some(Drag::Move { index: i, many, start: p, rect: a.rect, began: false, copy: false });
|
||
} else {
|
||
self.others.clear();
|
||
let p = self.draw.press(cx, p, &[], None);
|
||
self.drag = Some(Drag::Create { start: p, cur: p });
|
||
}
|
||
vec![]
|
||
}
|
||
(PointerKind::Drag, Some(Drag::Move { index, many, start, rect, began, copy })) => {
|
||
let mut out = vec![];
|
||
let label = |copy: bool| if copy { "Duplicate Artboard" } else { "Move Artboard" };
|
||
if !began {
|
||
if p.distance(start) < cx.tol(3.0) {
|
||
return out;
|
||
}
|
||
out.push(Action::Begin(label(m.alt).into()));
|
||
self.begin_snapping(cx, index, self.move_art);
|
||
} else if m.alt != copy {
|
||
// Alt pressed or released mid-drag: start over as a copy or a move.
|
||
out.push(Action::Cancel);
|
||
out.push(Action::Begin(label(m.alt).into()));
|
||
}
|
||
self.drag = Some(Drag::Move { index, many, start, rect, began: true, copy: m.alt });
|
||
let d = self.snap_move(cx, rect, move_delta(start, p, m.shift), m.alt);
|
||
self.preview = Some(rect + d);
|
||
let mut v = json!({ "index": index, "dx": d.x, "dy": d.y, "moveArt": self.move_art, "copy": m.alt });
|
||
if many {
|
||
v["indices"] = json!(self.selected(cx));
|
||
}
|
||
out.push(Action::Preview("artboard.move".into(), v));
|
||
out
|
||
}
|
||
(PointerKind::Drag, Some(Drag::Resize { index, handle, rect, grab, began })) => {
|
||
let mut out = vec![];
|
||
if !began {
|
||
out.push(Action::Begin("Resize Artboard".into()));
|
||
self.begin_snapping(cx, index, self.scale_art);
|
||
}
|
||
self.drag = Some(Drag::Resize { index, handle, rect, grab, began: true });
|
||
let h = self.snap_handle(cx, p + grab, bleed_offset(cx, rect, handle));
|
||
let proportional = m.shift || self.scale_art;
|
||
let mut nr = scale_for_drag(rect, handle, h, proportional, m.alt).transform_rect_bbox(rect);
|
||
if proportional {
|
||
nr = keep_min_side(nr, if m.alt { rect.center() } else { handle.opposite().pos(rect) });
|
||
}
|
||
self.preview = Some(nr);
|
||
let mut v = rect_json(index, nr);
|
||
if self.scale_art {
|
||
v["scaleArt"] = json!(true);
|
||
}
|
||
out.push(Action::Preview("artboard.setProps".into(), v));
|
||
out
|
||
}
|
||
(PointerKind::Drag, Some(Drag::Create { start, .. })) => {
|
||
let cur = self.draw.drag(cx, p, Some(&Leave::diagonal(start, m.shift)));
|
||
self.drag = Some(Drag::Create { start, cur });
|
||
vec![]
|
||
}
|
||
(PointerKind::Up, Some(d)) => {
|
||
let click_art = self.click_art;
|
||
self.end_drag();
|
||
if m == Mods::default()
|
||
&& let Some((start, r)) = click_art
|
||
&& p.distance(start) < cx.tol(3.0)
|
||
{
|
||
return create_artboard(r);
|
||
}
|
||
match d {
|
||
Drag::Move { began: true, copy, many, .. } => {
|
||
// The copies, last in the previewed document, become the selection (the
|
||
// last one active).
|
||
if copy {
|
||
let n = cx.doc.artboards.len();
|
||
let k = if many { self.selected(cx).len().min(n) } else { 1 };
|
||
self.active = n.saturating_sub(1);
|
||
self.others = (n.saturating_sub(k)..n.saturating_sub(1)).collect();
|
||
}
|
||
vec![Action::Commit]
|
||
}
|
||
Drag::Resize { began: true, .. } => vec![Action::Commit],
|
||
Drag::Create { start, cur } => {
|
||
let r = Rect::from_points(start, cur);
|
||
if r.width() < cx.tol(3.0) || r.height() < cx.tol(3.0) {
|
||
return vec![];
|
||
}
|
||
create_artboard(r)
|
||
}
|
||
_ => vec![],
|
||
}
|
||
}
|
||
(PointerKind::DoubleClick, _) => {
|
||
self.end_drag();
|
||
match cx.doc.artboards.get(self.active) {
|
||
Some(a) => {
|
||
let mut v = rect_json(self.active, a.rect);
|
||
v["name"] = json!(a.name);
|
||
vec![Action::Dialog("artboardOptions".into(), v)]
|
||
}
|
||
None => vec![],
|
||
}
|
||
}
|
||
_ => vec![],
|
||
}
|
||
}
|
||
|
||
/// Escape owns the request to return to Selection (or cancel an active drag), so the UI
|
||
/// applies the tool's request instead of falling through to its general Escape behaviour.
|
||
/// Delete/Backspace belong to the artboards even when deletion is protected or a drag is
|
||
/// active: falling through to Clear would delete selected artwork instead.
|
||
fn claims_key(&self, _cx: &ToolContext, key: ToolKey) -> bool {
|
||
matches!(key, ToolKey::Escape | ToolKey::Delete | ToolKey::Backspace)
|
||
}
|
||
|
||
fn key(&mut self, cx: &ToolContext, key: ToolKey, _mods: Mods) -> Vec<Action> {
|
||
match key {
|
||
ToolKey::Delete | ToolKey::Backspace => {
|
||
let mut sel = self.selected(cx);
|
||
if self.drag.is_some() || cx.doc.artboards.len() <= 1 || self.active >= cx.doc.artboards.len() {
|
||
return vec![];
|
||
}
|
||
// With every artboard selected, keep the first one in the current order.
|
||
if sel.len() == cx.doc.artboards.len() {
|
||
sel.retain(|i| *i != 0);
|
||
}
|
||
self.active = sel.first().copied().unwrap_or(0).saturating_sub(1);
|
||
self.others.clear();
|
||
let params = match sel.as_slice() {
|
||
[i] => json!({ "index": i }),
|
||
_ => json!({ "indices": sel }),
|
||
};
|
||
vec![Action::Exec("artboard.delete".into(), params)]
|
||
}
|
||
ToolKey::Escape => {
|
||
let busy = matches!(self.drag, Some(Drag::Move { began: true, .. } | Drag::Resize { began: true, .. }));
|
||
self.end_drag();
|
||
if busy { vec![Action::Cancel] } else { vec![Action::SwitchTool("selection".into())] }
|
||
}
|
||
_ => vec![],
|
||
}
|
||
}
|
||
|
||
fn notify(&mut self, cx: &ToolContext, what: &str) {
|
||
if what == "created" {
|
||
self.active = cx.doc.artboards.len().saturating_sub(1);
|
||
self.others.clear();
|
||
}
|
||
}
|
||
|
||
fn overlays(&self, cx: &ToolContext) -> Vec<Overlay> {
|
||
let mut o = vec![];
|
||
if let Some(Drag::Create { start, cur }) = self.drag {
|
||
o.extend_from_slice(self.draw.guides());
|
||
let r = Rect::from_points(start, cur);
|
||
o.push(Overlay::Marquee(r));
|
||
if cx.measurement_labels {
|
||
o.push(Overlay::Measure { p: cur + Vec2::new(cx.tol(12.0), cx.tol(12.0)), text: cx.size_label(r.width(), r.height()) });
|
||
}
|
||
return o;
|
||
}
|
||
// The other selected artboards: dashed bounds (in a drag the document shows them moved).
|
||
for r in self.others.iter().filter_map(|i| cx.doc.artboards.get(*i)).map(|a| a.rect) {
|
||
o.push(Overlay::Path { path: polygon(&rect_corners(r), true), color: BLUE, width: 1.0, dashed: true });
|
||
}
|
||
let Some(ab) = cx.doc.artboards.get(self.active) else { return o };
|
||
let r = self.preview.unwrap_or(ab.rect);
|
||
o.push(Overlay::Path { path: polygon(&rect_corners(r), true), color: BLUE, width: 1.0, dashed: true });
|
||
for h in Handle::ALL {
|
||
o.push(Overlay::Anchor { p: h.pos(r), color: BLUE, filled: false, size: 7.0 });
|
||
}
|
||
// Where the canvas names the other artboards: on the top edge, flush with the left one
|
||
// (labels are drawn 8 px right of and 14 px above their point).
|
||
let at = Point::new(r.x0 - cx.tol(8.0), r.y0 - cx.tol(4.0));
|
||
o.push(Overlay::Label { p: at, text: format!("{:02} - {}", self.active + 1, ab.name), color: BLUE });
|
||
if let (Some(Drag::Resize { began: true, .. }), true) = (self.drag, cx.measurement_labels) {
|
||
o.push(Overlay::Measure {
|
||
p: Point::new(r.x1, r.y1) + Vec2::new(cx.tol(12.0), cx.tol(12.0)),
|
||
text: cx.size_label(r.width(), r.height()),
|
||
});
|
||
}
|
||
if self.drag.is_some() {
|
||
o.extend(self.guides.iter().cloned());
|
||
}
|
||
o
|
||
}
|
||
|
||
fn cursor(&self, cx: &ToolContext, p: Point, _m: Mods) -> Cursor {
|
||
if let Some(r) = self.active_rect(cx)
|
||
&& let Some(h) = hit_handle(r, p, cx.tol(5.0))
|
||
{
|
||
return match h {
|
||
Handle::Top | Handle::Bottom => Cursor::ResizeV,
|
||
Handle::Left | Handle::Right => Cursor::ResizeH,
|
||
Handle::TopLeft | Handle::BottomRight => Cursor::ResizeNwSe,
|
||
Handle::TopRight | Handle::BottomLeft => Cursor::ResizeNeSw,
|
||
};
|
||
}
|
||
if cx.doc.artboards.iter().any(|a| a.rect.contains(p)) { Cursor::Move } else { Cursor::Crosshair }
|
||
}
|
||
|
||
fn options(&self) -> Value {
|
||
json!({ "active": self.active, "selected": std::iter::once(self.active).chain(self.others.iter().copied()).collect::<Vec<_>>(), "moveArt": self.move_art, "scaleArt": self.scale_art })
|
||
}
|
||
|
||
fn set_option(&mut self, key: &str, value: &Value) {
|
||
match key {
|
||
"active" => {
|
||
self.active = value.as_u64().unwrap_or(0) as usize;
|
||
self.others.clear();
|
||
}
|
||
// The selected artboards, the first active.
|
||
"selected" => {
|
||
let all: Vec<usize> =
|
||
value.as_array().into_iter().flatten().filter_map(Value::as_u64).filter_map(|i| usize::try_from(i).ok()).collect();
|
||
if let Some((first, rest)) = all.split_first() {
|
||
self.active = *first;
|
||
self.others = rest.iter().copied().filter(|i| i != first).collect();
|
||
}
|
||
}
|
||
"moveArt" => self.move_art = value.as_bool().unwrap_or(true),
|
||
"scaleArt" => self.scale_art = value.as_bool().unwrap_or(false),
|
||
_ => {}
|
||
}
|
||
}
|
||
|
||
fn deactivate(&mut self, _cx: &ToolContext) -> Vec<Action> {
|
||
let busy = matches!(self.drag, Some(Drag::Move { began: true, .. } | Drag::Resize { began: true, .. }));
|
||
self.end_drag();
|
||
if busy { vec![Action::Cancel] } else { vec![] }
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use crate::testutil::*;
|
||
use vectorcraft_doc::Selection;
|
||
|
||
fn ev(kind: PointerKind, x: f64, y: f64) -> PointerEvent {
|
||
PointerEvent::new(kind, x, y)
|
||
}
|
||
|
||
/// [`doc_with_rect`] (artboard 1 at 0–500, a rectangle at 100–200) plus Artboard 2 at x 600–800,
|
||
/// y 0–200.
|
||
fn two_boards() -> vectorcraft_doc::Document {
|
||
let (mut d, _) = doc_with_rect();
|
||
let mut ab = d.artboards[0].clone();
|
||
ab.rect = Rect::new(600.0, 0.0, 800.0, 200.0);
|
||
ab.name = "Artboard 2".into();
|
||
d.artboards.push(ab);
|
||
d
|
||
}
|
||
|
||
fn preview_params(a: &[Action]) -> Value {
|
||
a.iter()
|
||
.find_map(|a| match a {
|
||
Action::Preview(_, p) => Some(p.clone()),
|
||
_ => None,
|
||
})
|
||
.unwrap()
|
||
}
|
||
|
||
/// Drag artboard 2 from (700, 100) by (dx, dy) and return the preview's params.
|
||
fn drag_board2(cx: &ToolContext, t: &mut ArtboardTool, dx: f64, dy: f64) -> Value {
|
||
t.pointer(cx, &ev(PointerKind::Down, 700.0, 100.0));
|
||
preview_params(&t.pointer(cx, &ev(PointerKind::Drag, 700.0 + dx, 100.0 + dy)))
|
||
}
|
||
|
||
#[test]
|
||
fn bounds_follow_the_artboard_while_the_document_shows_the_preview() {
|
||
let mut d = two_boards();
|
||
let (s, p) = (Selection::default(), paint());
|
||
let mut t = ArtboardTool::default();
|
||
assert_eq!(drag_board2(&cx(&d, &s, &p), &mut t, 10.0, 0.0)["dx"], 10.0);
|
||
// The engine applies the preview to the document, as it does during a drag.
|
||
d.artboards[1].rect = Rect::new(610.0, 0.0, 810.0, 200.0);
|
||
let cx = cx(&d, &s, &p);
|
||
assert_eq!(preview_params(&t.pointer(&cx, &ev(PointerKind::Drag, 720.0, 100.0)))["dx"], 20.0);
|
||
let corners: Vec<Point> = t
|
||
.overlays(&cx)
|
||
.iter()
|
||
.filter_map(|o| match o {
|
||
Overlay::Anchor { p, .. } => Some(*p),
|
||
_ => None,
|
||
})
|
||
.collect();
|
||
assert_eq!(corners[0], Point::new(620.0, 0.0), "the handles sit on the moved artboard, not 10 pt past it");
|
||
}
|
||
|
||
#[test]
|
||
fn moving_snaps_to_other_artboards_with_smart_guides() {
|
||
let d = two_boards();
|
||
let (s, p) = (Selection::default(), paint());
|
||
let mut t = ArtboardTool::default();
|
||
let mut c = cx(&d, &s, &p);
|
||
// Left edge 3 pt right of artboard 1's right edge (500) → lands on it.
|
||
assert_eq!(drag_board2(&c, &mut t, -97.0, 0.0)["dx"], -100.0);
|
||
assert!(t.overlays(&c).iter().any(|o| matches!(o, Overlay::Line { color, .. } if *color == crate::guides::MAGENTA)));
|
||
t.pointer(&c, &ev(PointerKind::Up, 603.0, 100.0));
|
||
assert!(!t.overlays(&c).iter().any(|o| matches!(o, Overlay::Line { .. })), "guides go away on release");
|
||
c.smart_guides = false;
|
||
assert_eq!(drag_board2(&c, &mut t, -97.0, 0.0)["dx"], -97.0);
|
||
}
|
||
|
||
#[test]
|
||
fn moving_never_snaps_to_itself_or_the_art_it_carries() {
|
||
let mut d = two_boards();
|
||
let l = d.layers[0].id;
|
||
let id = d.alloc_id();
|
||
let art = vectorcraft_geom::shapes::rectangle(Rect::new(650.0, 50.0, 660.0, 60.0));
|
||
d.insert(Some(l), 0, vectorcraft_doc::Node::path(id, art, vectorcraft_doc::Appearance::default_art())).unwrap();
|
||
let (s, p) = (Selection::default(), paint());
|
||
let c = cx(&d, &s, &p);
|
||
let mut t = ArtboardTool::default();
|
||
// Its own old left edge (600) isn't a target.
|
||
assert_eq!(drag_board2(&c, &mut t, 4.0, 0.0)["dx"], 4.0);
|
||
t.pointer(&c, &ev(PointerKind::Up, 704.0, 100.0));
|
||
// The rectangle moves along: its left edge (650) isn't a target either…
|
||
assert_eq!(drag_board2(&c, &mut t, 52.0, 0.0)["dx"], 52.0);
|
||
t.pointer(&c, &ev(PointerKind::Up, 752.0, 100.0));
|
||
// …unless it stays behind.
|
||
t.move_art = false;
|
||
assert_eq!(drag_board2(&c, &mut t, 52.0, 0.0)["dx"], 50.0);
|
||
}
|
||
|
||
#[test]
|
||
fn moving_snaps_to_grid_and_pixels() {
|
||
let d = two_boards();
|
||
let (s, p) = (Selection::default(), paint());
|
||
let mut t = ArtboardTool::default();
|
||
let mut c = cx(&d, &s, &p);
|
||
c.snap_to_grid = true;
|
||
// Grid step 72 / 8 = 9: x 610 → 612, y 4 → 0.
|
||
let v = drag_board2(&c, &mut t, 10.0, 4.0);
|
||
assert_eq!((v["dx"].as_f64(), v["dy"].as_f64()), (Some(12.0), Some(0.0)));
|
||
t.pointer(&c, &ev(PointerKind::Up, 710.0, 104.0));
|
||
c.snap_to_grid = false;
|
||
c.snap_to_pixel = true;
|
||
let v = drag_board2(&c, &mut t, 10.4, 3.6);
|
||
assert_eq!((v["dx"].as_f64(), v["dy"].as_f64()), (Some(10.0), Some(4.0)));
|
||
}
|
||
|
||
#[test]
|
||
fn resizing_snaps_the_dragged_handle() {
|
||
let d = two_boards();
|
||
let (s, p) = (Selection::default(), paint());
|
||
let mut t = ArtboardTool { active: 1, ..Default::default() };
|
||
let c = cx(&d, &s, &p);
|
||
// Left handle to 3 pt off artboard 1's right edge.
|
||
t.pointer(&c, &ev(PointerKind::Down, 600.0, 100.0));
|
||
let v = preview_params(&t.pointer(&c, &ev(PointerKind::Drag, 503.0, 100.0)));
|
||
assert_eq!((v["x"].as_f64(), v["width"].as_f64()), (Some(500.0), Some(300.0)));
|
||
t.pointer(&c, &ev(PointerKind::Up, 503.0, 100.0));
|
||
// The handle, not the pointer, follows: grabbed 2 pt left of it, dragged 10 pt.
|
||
t.pointer(&c, &ev(PointerKind::Down, 798.0, 100.0));
|
||
let v = preview_params(&t.pointer(&c, &ev(PointerKind::Drag, 808.0, 100.0)));
|
||
assert_eq!(v["width"].as_f64(), Some(210.0));
|
||
}
|
||
|
||
/// Shift-click selects several artboards (#1139): a drag moves them all, Shift-click takes one
|
||
/// out again, a plain click on an unselected one selects just it, and Delete removes all the
|
||
/// selected ones (one artboard is always left).
|
||
#[test]
|
||
fn shift_click_selects_several_artboards_that_move_and_delete_together() {
|
||
let mut d = two_boards();
|
||
let mut third = d.artboards[0].clone();
|
||
third.rect = Rect::new(0.0, 600.0, 200.0, 800.0);
|
||
d.artboards.push(third);
|
||
let (s, p) = (Selection::default(), paint());
|
||
let c = cx(&d, &s, &p);
|
||
let shift = Mods { shift: true, ..Default::default() };
|
||
let click = |t: &mut ArtboardTool, x: f64, y: f64, m: Mods| {
|
||
t.pointer(&c, &ev(PointerKind::Down, x, y).with_mods(m));
|
||
t.pointer(&c, &ev(PointerKind::Up, x, y).with_mods(m));
|
||
};
|
||
let mut t = ArtboardTool::default();
|
||
click(&mut t, 450.0, 450.0, Mods::default());
|
||
click(&mut t, 700.0, 100.0, shift);
|
||
assert_eq!(t.options()["selected"], json!([1, 0]), "the Shift-clicked one is active");
|
||
// A drag from either moves both.
|
||
t.pointer(&c, &ev(PointerKind::Down, 450.0, 450.0));
|
||
let v = preview_params(&t.pointer(&c, &ev(PointerKind::Drag, 470.0, 450.0)));
|
||
assert_eq!((v["indices"].clone(), v["dx"].as_f64()), (json!([0, 1]), Some(20.0)));
|
||
assert_eq!(t.pointer(&c, &ev(PointerKind::Up, 470.0, 450.0)), vec![Action::Commit]);
|
||
// Delete removes both; with all three selected the keys retain the original first board.
|
||
assert_eq!(t.key(&c, ToolKey::Delete, Mods::default()), vec![Action::Exec("artboard.delete".into(), json!({"indices": [0, 1]}))]);
|
||
for key in [ToolKey::Delete, ToolKey::Backspace] {
|
||
t.set_option("selected", &json!([2, 0, 1]));
|
||
assert!(t.claims_key(&c, key));
|
||
assert_eq!(t.key(&c, key, Mods::default()), vec![Action::Exec("artboard.delete".into(), json!({"indices": [1, 2]}))]);
|
||
assert_eq!(t.options()["selected"], json!([0]));
|
||
}
|
||
t.set_option("selected", &json!([0, 1, 2]));
|
||
// Shift-click takes one out; a plain click on an unselected one selects just it.
|
||
click(&mut t, 100.0, 700.0, shift);
|
||
assert_eq!(t.options()["selected"], json!([0, 1]));
|
||
click(&mut t, 100.0, 700.0, Mods::default());
|
||
assert_eq!(t.options()["selected"], json!([2]));
|
||
}
|
||
|
||
/// Scale Artwork with Artboard (#602): the resize asks for the art to scale, keeps the artboard's
|
||
/// proportions, and never snaps to the art scaling with it.
|
||
#[test]
|
||
fn scale_art_resizes_proportionally_without_snapping_to_its_art() {
|
||
let mut d = two_boards();
|
||
let l = d.layers[0].id;
|
||
let id = d.alloc_id();
|
||
let art = vectorcraft_geom::shapes::rectangle(Rect::new(700.0, 50.0, 710.0, 60.0));
|
||
d.insert(Some(l), 0, vectorcraft_doc::Node::path(id, art, vectorcraft_doc::Appearance::default_art())).unwrap();
|
||
let (s, p) = (Selection::default(), paint());
|
||
let c = cx(&d, &s, &p);
|
||
// Off: the right handle 3 pt past the art's right edge (710) lands on it; the height stays.
|
||
let mut t = ArtboardTool { active: 1, ..Default::default() };
|
||
t.pointer(&c, &ev(PointerKind::Down, 800.0, 100.0));
|
||
let v = preview_params(&t.pointer(&c, &ev(PointerKind::Drag, 713.0, 100.0)));
|
||
assert_eq!((v["width"].as_f64(), v["height"].as_f64(), v.get("scaleArt")), (Some(110.0), Some(200.0), None));
|
||
t.pointer(&c, &ev(PointerKind::Up, 713.0, 100.0));
|
||
// On: the art scales along, so it's no target, and the height follows the width.
|
||
t.set_option("scaleArt", &json!(true));
|
||
assert_eq!(t.options()["scaleArt"], true);
|
||
t.pointer(&c, &ev(PointerKind::Down, 800.0, 100.0));
|
||
let v = preview_params(&t.pointer(&c, &ev(PointerKind::Drag, 713.0, 100.0)));
|
||
assert_eq!((v["width"].as_f64(), v["scaleArt"].as_bool()), (Some(113.0), Some(true)));
|
||
assert!((v["height"].as_f64().unwrap() - 113.0).abs() < 1e-9);
|
||
}
|
||
|
||
/// A proportional resize never asks for a side under 1 pt, which the command would lengthen
|
||
/// alone (scaling the art unevenly): both sides grow by one factor instead.
|
||
#[test]
|
||
fn a_proportional_resize_keeps_its_proportions_at_the_smallest_size() {
|
||
let mut d = two_boards();
|
||
d.artboards[1].rect = Rect::new(600.0, 0.0, 800.0, 20.0);
|
||
let (s, p) = (Selection::default(), paint());
|
||
let mut c = cx(&d, &s, &p);
|
||
c.smart_guides = false;
|
||
let mut t = ArtboardTool { active: 1, scale_art: true, ..Default::default() };
|
||
// The right handle to 2.5 % of the width: 5 × 0.5, grown to 10 × 1 about the left middle.
|
||
t.pointer(&c, &ev(PointerKind::Down, 800.0, 10.0));
|
||
let v = preview_params(&t.pointer(&c, &ev(PointerKind::Drag, 605.0, 10.0)));
|
||
let got = ["x", "y", "width", "height"].map(|k| v[k].as_f64().unwrap());
|
||
assert!(got.iter().zip([600.0, 9.5, 10.0, 1.0]).all(|(a, b)| (a - b).abs() < 1e-9), "{got:?}");
|
||
}
|
||
|
||
#[test]
|
||
fn bleed_edges_snap_to_bleed_edges() {
|
||
let mut d = two_boards();
|
||
d.setup.bleed = [10.0; 4];
|
||
let (s, p) = (Selection::default(), paint());
|
||
let c = cx(&d, &s, &p);
|
||
let mut t = ArtboardTool::default();
|
||
// Artboard 2's bleed (left edge 590) lands on artboard 1's bleed (right edge 510): dragged
|
||
// 77 pt left it's 3 pt off, nothing else is within reach.
|
||
assert_eq!(drag_board2(&c, &mut t, -77.0, 0.0)["dx"], -80.0);
|
||
t.pointer(&c, &ev(PointerKind::Up, 623.0, 100.0));
|
||
// Resizing: the left handle's bleed edge snaps the same way.
|
||
t.pointer(&c, &ev(PointerKind::Down, 600.0, 100.0));
|
||
let v = preview_params(&t.pointer(&c, &ev(PointerKind::Drag, 523.0, 100.0)));
|
||
assert_eq!((v["x"].as_f64(), v["width"].as_f64()), (Some(520.0), Some(280.0)));
|
||
}
|
||
|
||
#[test]
|
||
fn move_resize_create_delete() {
|
||
let (mut d, _) = doc_with_rect();
|
||
let mut ab = d.artboards[0].clone();
|
||
ab.rect = Rect::new(600.0, 0.0, 800.0, 200.0);
|
||
ab.name = "Artboard 2".into();
|
||
d.artboards.push(ab);
|
||
let s = Selection::default();
|
||
let p = paint();
|
||
let cx = cx(&d, &s, &p);
|
||
let mut t = ArtboardTool::default();
|
||
// Click the second artboard → active; drag moves it.
|
||
t.pointer(&cx, &ev(PointerKind::Down, 700.0, 100.0));
|
||
assert_eq!(t.active, 1);
|
||
let a = t.pointer(&cx, &ev(PointerKind::Drag, 710.0, 120.0));
|
||
assert_eq!(a[0], Action::Begin("Move Artboard".into()));
|
||
assert_eq!(a[1], Action::Preview("artboard.move".into(), json!({"index": 1, "dx": 10.0, "dy": 20.0, "moveArt": true, "copy": false})));
|
||
assert_eq!(t.pointer(&cx, &ev(PointerKind::Up, 710.0, 120.0)), vec![Action::Commit]);
|
||
assert!(t.overlays(&cx).iter().any(|o| matches!(o, Overlay::Label { text, .. } if text == "02 - Artboard 2")));
|
||
// Handle drag resizes.
|
||
t.pointer(&cx, &ev(PointerKind::Down, 800.0, 100.0));
|
||
let a = t.pointer(&cx, &ev(PointerKind::Drag, 900.0, 100.0));
|
||
assert_eq!(a[1], Action::Preview("artboard.setProps".into(), json!({"index": 1, "x": 600.0, "y": 0.0, "width": 300.0, "height": 200.0})));
|
||
t.pointer(&cx, &ev(PointerKind::Up, 900.0, 100.0));
|
||
// Drag on the pasteboard creates one.
|
||
t.pointer(&cx, &ev(PointerKind::Down, 1000.0, 1000.0));
|
||
t.pointer(&cx, &ev(PointerKind::Drag, 1100.0, 1050.0));
|
||
assert!(matches!(t.overlays(&cx)[0], Overlay::Marquee(_)));
|
||
let a = t.pointer(&cx, &ev(PointerKind::Up, 1100.0, 1050.0));
|
||
assert_eq!(a[0], Action::Exec("artboard.new".into(), json!({"x": 1000.0, "y": 1000.0, "width": 100.0, "height": 50.0})));
|
||
assert_eq!(a[1], Action::Notify("created".into()));
|
||
// Delete removes the active artboard, ahead of the Clear shortcut.
|
||
assert!(t.claims_key(&cx, ToolKey::Delete) && t.claims_key(&cx, ToolKey::Backspace));
|
||
assert_eq!(t.key(&cx, ToolKey::Delete, Mods::default()), vec![Action::Exec("artboard.delete".into(), json!({"index": 1}))]);
|
||
assert_eq!(t.key(&cx, ToolKey::Escape, Mods::default()), vec![Action::SwitchTool("selection".into())]);
|
||
}
|
||
|
||
#[test]
|
||
fn an_alt_drag_copy_snaps_to_the_artboard_it_leaves_behind() {
|
||
let d = two_boards();
|
||
let (s, p) = (Selection::default(), paint());
|
||
let c = cx(&d, &s, &p);
|
||
let alt = Mods { alt: true, ..Mods::default() };
|
||
let mut t = ArtboardTool::default();
|
||
// A copy of artboard 2 dragged 203 pt right: its left edge 3 pt off the original's right
|
||
// edge (800), which stays put and is a target.
|
||
t.pointer(&c, &ev(PointerKind::Down, 700.0, 100.0).with_mods(alt));
|
||
let v = preview_params(&t.pointer(&c, &ev(PointerKind::Drag, 903.0, 100.0).with_mods(alt)));
|
||
assert_eq!((v["dx"].as_f64(), v["copy"].as_bool()), (Some(200.0), Some(true)));
|
||
// Released Alt: a plain move, and the artboard's own old edge is no target.
|
||
let v = preview_params(&t.pointer(&c, &ev(PointerKind::Drag, 903.0, 100.0)));
|
||
assert_eq!((v["dx"].as_f64(), v["copy"].as_bool()), (Some(203.0), Some(false)));
|
||
}
|
||
|
||
#[test]
|
||
fn alt_drag_moves_a_copy_of_the_artboard() {
|
||
let (d, _) = doc_with_rect();
|
||
let s = Selection::default();
|
||
let p = paint();
|
||
let cx = cx(&d, &s, &p);
|
||
let alt = Mods { alt: true, ..Mods::default() };
|
||
let moved =
|
||
|dx: f64, copy: bool| Action::Preview("artboard.move".into(), json!({"index": 0, "dx": dx, "dy": 0.0, "moveArt": true, "copy": copy}));
|
||
let mut t = ArtboardTool::default();
|
||
t.pointer(&cx, &ev(PointerKind::Down, 100.0, 100.0).with_mods(alt));
|
||
let a = t.pointer(&cx, &ev(PointerKind::Drag, 150.0, 100.0).with_mods(alt));
|
||
assert_eq!(a, vec![Action::Begin("Duplicate Artboard".into()), moved(50.0, true)]);
|
||
// Alt released mid-drag: a plain move after all, and back.
|
||
let a = t.pointer(&cx, &ev(PointerKind::Drag, 160.0, 100.0));
|
||
assert_eq!(a, vec![Action::Cancel, Action::Begin("Move Artboard".into()), moved(60.0, false)]);
|
||
let a = t.pointer(&cx, &ev(PointerKind::Drag, 170.0, 100.0).with_mods(alt));
|
||
assert_eq!(a, vec![Action::Cancel, Action::Begin("Duplicate Artboard".into()), moved(70.0, true)]);
|
||
assert_eq!(t.pointer(&cx, &ev(PointerKind::Up, 170.0, 100.0).with_mods(alt)), vec![Action::Commit]);
|
||
}
|
||
}
|