mirror of
https://github.com/storytold/designcraft.git
synced 2026-10-28 12:16:53 -06:00
271 lines
12 KiB
Rust
271 lines
12 KiB
Rust
//! Rotate (R), Scale (S) and Shear (O) tools: drag around the selection centre (Alt-click sets
|
|
//! the reference point elsewhere in a later iteration); Eyedropper (I): click an object to apply
|
|
//! its attributes to the selection.
|
|
|
|
use designcraft_doc::SpreadRef;
|
|
use designcraft_geom::{Point, Rect};
|
|
use serde_json::json;
|
|
|
|
use crate::{Action, Cursor, Gesture, Mods, Overlay, PointerEvent, PointerKind, SnapRequest, Tool, ToolContext};
|
|
|
|
pub struct XformTool {
|
|
id: &'static str,
|
|
drag: Option<(Point, Point)>,
|
|
guides: Vec<Overlay>,
|
|
/// First selected item's rotation at pointer down. Rotate only. Later samples see the preview.
|
|
start_rotation: Option<f64>,
|
|
}
|
|
|
|
impl XformTool {
|
|
pub fn new(id: &'static str) -> Self {
|
|
Self { id, drag: None, guides: Vec::new(), start_rotation: None }
|
|
}
|
|
}
|
|
|
|
/// Top-level selection on one spread, in that spread's coordinates.
|
|
fn selection_spread_bounds(cx: &ToolContext) -> Option<(SpreadRef, Rect)> {
|
|
let mut spread = None;
|
|
let mut rect: Option<Rect> = None;
|
|
for id in &cx.selection.items {
|
|
let loc = cx.doc.find(*id)?;
|
|
if loc.path.len() != 1 {
|
|
return None;
|
|
}
|
|
if let Some(have) = spread
|
|
&& have != loc.spread
|
|
{
|
|
return None;
|
|
}
|
|
spread = Some(loc.spread);
|
|
let b = cx.doc.item(*id)?.bounds();
|
|
if !(b.x0.is_finite() && b.y0.is_finite() && b.x1.is_finite() && b.y1.is_finite()) {
|
|
return None;
|
|
}
|
|
rect = Some(match rect {
|
|
Some(r) => r.union(b),
|
|
None => b,
|
|
});
|
|
}
|
|
Some((spread?, rect?))
|
|
}
|
|
|
|
fn edge_toward(pointer: f64, center: f64) -> [bool; 3] {
|
|
if pointer >= center { [false, false, true] } else { [true, false, false] }
|
|
}
|
|
|
|
impl Tool for XformTool {
|
|
fn id(&self) -> &'static str {
|
|
self.id
|
|
}
|
|
fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec<Action> {
|
|
match ev.kind {
|
|
PointerKind::Down => {
|
|
self.guides.clear();
|
|
self.start_rotation = None;
|
|
let Some(b) = cx.selection_bounds() else { return vec![] };
|
|
if self.id == "rotate" {
|
|
self.start_rotation = crate::snap::reference_rotation(cx);
|
|
}
|
|
self.drag = Some((b.center(), ev.pos));
|
|
vec![Action::Begin(match self.id {
|
|
"rotate" => "Rotate".into(),
|
|
"scale" => "Scale".into(),
|
|
_ => "Shear".into(),
|
|
})]
|
|
}
|
|
PointerKind::Drag => {
|
|
let Some((c, start)) = self.drag else { return vec![] };
|
|
let (v0, v1) = (start - c, ev.pos - c);
|
|
self.guides.clear();
|
|
match self.id {
|
|
"rotate" => {
|
|
let mut a = (v1.atan2() - v0.atan2()).to_degrees();
|
|
if ev.mods.shift {
|
|
// 45 degree steps. The rotation pass does not run while Shift is held.
|
|
a = (a / 45.0).round() * 45.0;
|
|
} else if let Some(start) = self.start_rotation {
|
|
let raw = -a;
|
|
if let Some(resulting) = crate::snap::folded_rotation(start + raw) {
|
|
let spread =
|
|
cx.selection.items.first().and_then(|id| cx.doc.find(*id)).map(|loc| loc.spread).unwrap_or(SpreadRef::Doc(0));
|
|
let exclude = cx.selection.items.clone();
|
|
// The rect is unused by the angle pass. It only has to be finite.
|
|
let hit = crate::snap::snap(
|
|
cx,
|
|
SnapRequest {
|
|
spread,
|
|
gesture: Gesture::Rotate,
|
|
rect: Rect::new(0.0, 0.0, 1.0, 1.0),
|
|
x_edges: [false, false, false],
|
|
y_edges: [false, false, false],
|
|
exclude: &exclude,
|
|
copying: false,
|
|
lengths: [None, None],
|
|
angle: Some(resulting),
|
|
radius: (ev.pos - c).hypot(),
|
|
pointer: cx.layout.to_spread(spread, ev.pos),
|
|
},
|
|
);
|
|
if let Some(snapped) = hit.angle
|
|
&& let Some(delta) = crate::snap::rotation_command_delta(start, snapped, raw)
|
|
{
|
|
a = -delta;
|
|
}
|
|
self.guides = hit.guides;
|
|
}
|
|
}
|
|
vec![Action::Preview("transform.rotate".into(), json!({"angle": -a}))]
|
|
}
|
|
"scale" => {
|
|
let (sx0, sy0) = (v1.x / v0.x.abs().max(1e-6) * v0.x.signum(), v1.y / v0.y.abs().max(1e-6) * v0.y.signum());
|
|
let x_drives = !ev.mods.shift || sx0.abs() >= sy0.abs();
|
|
let (mut sx, mut sy) = (sx0, sy0);
|
|
if ev.mods.shift {
|
|
let s = sx.abs().max(sy.abs());
|
|
sx = s;
|
|
sy = s;
|
|
}
|
|
if cx.snap.any()
|
|
&& sx.is_finite()
|
|
&& sy.is_finite()
|
|
&& let Some((spread, from)) = selection_spread_bounds(cx)
|
|
{
|
|
let (sign_x, sign_y) = (if sx < 0.0 { -1.0 } else { 1.0 }, if sy < 0.0 { -1.0 } else { 1.0 });
|
|
let (w0, h0) = (from.width().abs(), from.height().abs());
|
|
if w0 > 1e-9 && h0 > 1e-9 {
|
|
let center = from.center();
|
|
let (mut w, mut h) = (w0 * sx.abs(), h0 * sy.abs());
|
|
let pre = Rect::new(center.x - w / 2.0, center.y - h / 2.0, center.x + w / 2.0, center.y + h / 2.0);
|
|
let pointer = cx.layout.to_spread(spread, ev.pos);
|
|
let x_edges = if x_drives { edge_toward(pointer.x, center.x) } else { [false, false, false] };
|
|
let y_edges = if x_drives && ev.mods.shift { [false, false, false] } else { edge_toward(pointer.y, center.y) };
|
|
let exclude = cx.selection.items.clone();
|
|
let (pad_x, pad_y) = scale_stroke_pad(cx);
|
|
let mut hit = crate::snap::snap(
|
|
cx,
|
|
SnapRequest {
|
|
spread,
|
|
gesture: Gesture::Resize,
|
|
rect: pre,
|
|
x_edges,
|
|
y_edges,
|
|
exclude: &exclude,
|
|
copying: false,
|
|
lengths: [length_on(x_edges, w, pad_x), length_on(y_edges, h, pad_y)],
|
|
angle: None,
|
|
radius: 0.0,
|
|
pointer,
|
|
},
|
|
);
|
|
if let Some(diff) = hit.length_delta[0] {
|
|
w += diff;
|
|
} else if x_edges[2] {
|
|
w += 2.0 * hit.delta.x;
|
|
} else if x_edges[0] {
|
|
w -= 2.0 * hit.delta.x;
|
|
}
|
|
if let Some(diff) = hit.length_delta[1] {
|
|
h += diff;
|
|
} else if y_edges[2] {
|
|
h += 2.0 * hit.delta.y;
|
|
} else if y_edges[0] {
|
|
h -= 2.0 * hit.delta.y;
|
|
}
|
|
if ev.mods.shift {
|
|
if x_drives {
|
|
h = h0 * (w / w0);
|
|
} else {
|
|
w = w0 * (h / h0);
|
|
}
|
|
}
|
|
if w.is_finite() && h.is_finite() {
|
|
let committed = Rect::new(center.x - w / 2.0, center.y - h / 2.0, center.x + w / 2.0, center.y + h / 2.0);
|
|
crate::snap::lay_dimension_guides(
|
|
&mut hit.guides,
|
|
cx.layout.xf(spread),
|
|
pre,
|
|
committed,
|
|
x_edges,
|
|
y_edges,
|
|
hit.length_delta,
|
|
);
|
|
if w.abs() > 1e-9 && h.abs() > 1e-9 {
|
|
sx = (w / w0) * sign_x;
|
|
sy = (h / h0) * sign_y;
|
|
}
|
|
}
|
|
self.guides = hit.guides;
|
|
}
|
|
}
|
|
vec![Action::Preview("transform.scale".into(), json!({"sx": sx, "sy": sy}))]
|
|
}
|
|
_ => {
|
|
let a = ((v1.x - v0.x) / v0.y.abs().max(20.0)).atan().to_degrees();
|
|
vec![Action::Preview("transform.shear".into(), json!({"angle": a}))]
|
|
}
|
|
}
|
|
}
|
|
PointerKind::Up => {
|
|
self.guides.clear();
|
|
self.start_rotation = None;
|
|
if self.drag.take().is_some() { vec![Action::Commit] } else { vec![] }
|
|
}
|
|
_ => vec![],
|
|
}
|
|
}
|
|
fn overlays(&self, _cx: &ToolContext) -> Vec<Overlay> {
|
|
self.guides.clone()
|
|
}
|
|
fn cursor(&self, _cx: &ToolContext, _p: Point, _m: Mods) -> Cursor {
|
|
if self.id == "rotate" { Cursor::Rotate } else { Cursor::Crosshair }
|
|
}
|
|
fn busy(&self) -> bool {
|
|
self.drag.is_some()
|
|
}
|
|
}
|
|
|
|
fn length_on(edges: [bool; 3], size: f64, pad: f64) -> Option<f64> {
|
|
if edges == [false, false, false] {
|
|
return None;
|
|
}
|
|
let len = size.abs() + pad;
|
|
len.is_finite().then_some(len)
|
|
}
|
|
|
|
fn scale_stroke_pad(cx: &ToolContext) -> (f64, f64) {
|
|
if cx.selection.items.len() != 1 {
|
|
return (0.0, 0.0);
|
|
}
|
|
let Some(id) = cx.selection.items.first() else { return (0.0, 0.0) };
|
|
let Some(it) = cx.doc.item(*id) else { return (0.0, 0.0) };
|
|
let path = it.bounds();
|
|
let vis = it.visible_bounds();
|
|
(positive_pad(vis.width() - path.width()), positive_pad(vis.height() - path.height()))
|
|
}
|
|
|
|
fn positive_pad(v: f64) -> f64 {
|
|
if v.is_finite() && v > 0.0 { v } else { 0.0 }
|
|
}
|
|
|
|
#[derive(Default)]
|
|
pub struct EyedropperTool;
|
|
|
|
impl Tool for EyedropperTool {
|
|
fn id(&self) -> &'static str {
|
|
"eyedropper"
|
|
}
|
|
fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec<Action> {
|
|
if ev.kind != PointerKind::Down {
|
|
return vec![];
|
|
}
|
|
let Some((_, id)) = cx.hit(ev.pos) else { return vec![] };
|
|
if cx.selection.items.is_empty() && cx.selection.text.is_none() {
|
|
return vec![Action::Exec("selection.set".into(), json!({"ids": [id.0]}))];
|
|
}
|
|
vec![Action::Exec("object.matchAttributes".into(), json!({"from": id.0}))]
|
|
}
|
|
fn cursor(&self, _cx: &ToolContext, _p: Point, _m: Mods) -> Cursor {
|
|
Cursor::Eyedropper
|
|
}
|
|
}
|