mirror of
https://github.com/storytold/designcraft.git
synced 2026-11-02 06:36:51 -06:00
Direct Selection: select anchor points with a marquee across paths and move them together
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
8dff7ecdb0
commit
061c4faaf0
@@ -348,6 +348,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m
|
||||
|
||||
Newest first.
|
||||
|
||||
- **2026-10-20** · Direct Selection selects anchor points: a marquee selects the points inside it on every path it reaches, a click selects one point and Shift+click adds or removes one, selected points show solid, and dragging one (Shift: at 45° steps) or pressing the arrow keys moves them all in one step (`selection.set {anchors}`, `path.moveAnchors {points}`). No readiness percentage change.
|
||||
- **2026-10-16** · Type › Reload Fonts (`font.reload`): fonts installed, edited or removed while DesignCraft runs are read again without a restart, and the open documents recompose with them (#328, #547). An edited font file is noticed by its length and modification time; bundled, added and document fonts stay as they are. InDesign does this by itself, so noticing changes automatically is still open (gap 6). No readiness percentage change.
|
||||
- **2026-10-16** · Parents can be deleted from their Pages-panel context menu or `layout.parents.delete {id}`. Applied-page and based-on references are cleared, local override copies survive, and Undo restores the parent. Addresses the deletion portion of #956; no readiness percentage change.
|
||||
- **2026-10-17** · File › Package writes the document's and the IDML's links relative to the package (`Links/logo.png`, `file:Links/logo.png`), so a package holds no path from the machine that made it; opening a document resolves them to the files beside it. Links › Copy Link(s) To still links by full path. (#340). No readiness percentage change.
|
||||
|
||||
@@ -8,6 +8,21 @@ use serde_json::{Value, json};
|
||||
use super::{CommandSpec, bool_or, cmd, has_doc, has_selection, ids_param, ok};
|
||||
use crate::{HistoryEntry, Result, Session};
|
||||
|
||||
/// `anchors` of `selection.set`: the points that exist, once each.
|
||||
fn anchors_param(doc: &designcraft_doc::Document, p: &Value) -> Vec<(ItemId, usize, usize)> {
|
||||
let mut out: Vec<(ItemId, usize, usize)> = Vec::new();
|
||||
for v in p.get("anchors").and_then(Value::as_array).into_iter().flatten() {
|
||||
let n = |i: usize| v.get(i).and_then(Value::as_u64);
|
||||
let (Some(id), Some(si), Some(ai)) = (n(0), n(1), n(2)) else { continue };
|
||||
let (Ok(si), Ok(ai)) = (usize::try_from(si), usize::try_from(ai)) else { continue };
|
||||
let a = (ItemId(id), si, ai);
|
||||
if doc.item(a.0).is_some_and(|it| it.path.subpaths.get(si).is_some_and(|sp| ai < sp.anchors.len())) && !out.contains(&a) {
|
||||
out.push(a);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn can_undo(s: &Session) -> std::result::Result<(), String> {
|
||||
s.active().filter(|d| !d.history.undo.is_empty()).map(|_| ()).ok_or_else(|| "nothing to undo".into())
|
||||
}
|
||||
@@ -193,11 +208,17 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
s.typing_format = None;
|
||||
Ok(json!({"redone": e.label}))
|
||||
}),
|
||||
cmd!(noundo "selection.set", "Select", [], None, "{ids: [id], add?: bool, content?: bool}", has_doc, |s, p| {
|
||||
let ids = ids_param(p, "ids").unwrap_or_default();
|
||||
cmd!(noundo "selection.set", "Select", [], None, "{ids: [id], add?: bool, content?: bool, anchors?: [[id, subpath, index]] (Direct Selection: the selected anchor points; their paths are selected too)}", has_doc, |s, p| {
|
||||
let mut ids = ids_param(p, "ids").unwrap_or_default();
|
||||
let add = bool_or(p, "add", false);
|
||||
let content = bool_or(p, "content", false);
|
||||
let st = s.doc_mut()?;
|
||||
let anchors = anchors_param(&st.doc, p);
|
||||
for (id, _, _) in &anchors {
|
||||
if !ids.contains(id) {
|
||||
ids.push(*id);
|
||||
}
|
||||
}
|
||||
let ids: Vec<ItemId> = ids.into_iter().filter(|i| st.doc.item(*i).is_some()).collect();
|
||||
if add {
|
||||
for i in ids {
|
||||
@@ -205,8 +226,13 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
st.selection.items.push(i);
|
||||
}
|
||||
}
|
||||
for a in anchors {
|
||||
if !st.selection.anchors.contains(&a) {
|
||||
st.selection.anchors.push(a);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
st.selection = Selection { items: ids, content, ..Default::default() };
|
||||
st.selection = Selection { items: ids, content, anchors, ..Default::default() };
|
||||
}
|
||||
st.selection.text = None;
|
||||
st.selection.cells = None;
|
||||
|
||||
@@ -680,7 +680,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
path_reverse
|
||||
),
|
||||
cmd!("path.close", "Close Path", ["Object", "Paths"], None, "{id, subpath?: index (default every subpath)}", has_doc, path_close),
|
||||
cmd!("path.moveAnchors", "Move Anchors", [], None, "{id, anchors: [[subpath, index]], dx, dy, handle?: in|out}", has_doc, path_move_anchors),
|
||||
cmd!(
|
||||
"path.moveAnchors",
|
||||
"Move Anchors",
|
||||
[],
|
||||
None,
|
||||
"{id, anchors: [[subpath, index]], dx, dy, handle?: in|out} | {points: [[id, subpath, index]], dx, dy} (anchors of several paths, as Direct Selection drags them: one step)",
|
||||
has_doc,
|
||||
path_move_anchors
|
||||
),
|
||||
cmd!(
|
||||
"object.align",
|
||||
"Align",
|
||||
@@ -3338,38 +3346,59 @@ fn path_close(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
fn path_move_anchors(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let id = super::id_param(p, "id").ok_or_else(|| bad("path.moveAnchors", "missing id"))?;
|
||||
let which: Vec<(usize, usize)> = p.get("anchors").and_then(|v| serde_json::from_value(v.clone()).ok()).unwrap_or_default();
|
||||
// The anchors to move, by path: `points` (several paths) or `id` with `anchors`.
|
||||
let mut targets: Vec<(ItemId, Vec<(usize, usize)>)> = Vec::new();
|
||||
if let Some(points) = p.get("points").and_then(Value::as_array) {
|
||||
for v in points {
|
||||
let n = |i: usize| v.get(i).and_then(Value::as_u64).and_then(|x| usize::try_from(x).ok());
|
||||
let (Some(id), Some(si), Some(ai)) = (v.get(0).and_then(Value::as_u64), n(1), n(2)) else {
|
||||
return Err(bad("path.moveAnchors", "each point is [id, subpath, index]"));
|
||||
};
|
||||
match targets.iter_mut().find(|(i, _)| i.0 == id) {
|
||||
Some((_, which)) => which.push((si, ai)),
|
||||
None => targets.push((ItemId(id), vec![(si, ai)])),
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let id = super::id_param(p, "id").ok_or_else(|| bad("path.moveAnchors", "missing id"))?;
|
||||
let which: Vec<(usize, usize)> = p.get("anchors").and_then(|v| serde_json::from_value(v.clone()).ok()).unwrap_or_default();
|
||||
targets.push((id, which));
|
||||
}
|
||||
let d0 = Vec2::new(f64_or(p, "dx", 0.0), f64_or(p, "dy", 0.0));
|
||||
let handle = str_param(p, "handle").map(str::to_string);
|
||||
s.edit(|d, _| {
|
||||
path_editable(d, id, "path.moveAnchors")?;
|
||||
let loc = d.find(id).ok_or(designcraft_doc::DocError::NoItem(id))?;
|
||||
let m = d.parent_xf(&loc) * d.item_at(&loc).map(|i| i.xf).unwrap_or_default();
|
||||
// Spread-space delta → inner-space delta (linear part only).
|
||||
let inv = m.inverse();
|
||||
let dv = (inv * Point::new(d0.x, d0.y)) - (inv * Point::ZERO);
|
||||
let it = d.item_mut(id).ok_or(designcraft_doc::DocError::NoItem(id))?;
|
||||
for (si, ai) in &which {
|
||||
let Some(a) = it.path.anchor_mut(*si, *ai) else { continue };
|
||||
match handle.as_deref() {
|
||||
Some("in") => {
|
||||
a.h_in += dv;
|
||||
if a.kind == designcraft_geom::AnchorKind::Smooth {
|
||||
a.h_out = a.p - (a.h_in - a.p);
|
||||
}
|
||||
}
|
||||
Some("out") => {
|
||||
a.h_out += dv;
|
||||
if a.kind == designcraft_geom::AnchorKind::Smooth {
|
||||
a.h_in = a.p - (a.h_out - a.p);
|
||||
}
|
||||
}
|
||||
_ => a.translate(dv),
|
||||
}
|
||||
for (id, _) in &targets {
|
||||
path_editable(d, *id, "path.moveAnchors")?;
|
||||
}
|
||||
if it.shape == Shape::Rectangle || it.shape == Shape::Oval || it.shape == Shape::Polygon {
|
||||
it.shape = Shape::Path;
|
||||
for (id, which) in &targets {
|
||||
let id = *id;
|
||||
let loc = d.find(id).ok_or(designcraft_doc::DocError::NoItem(id))?;
|
||||
let m = d.parent_xf(&loc) * d.item_at(&loc).map(|i| i.xf).unwrap_or_default();
|
||||
// Spread-space delta → inner-space delta (linear part only).
|
||||
let inv = m.inverse();
|
||||
let dv = (inv * Point::new(d0.x, d0.y)) - (inv * Point::ZERO);
|
||||
let it = d.item_mut(id).ok_or(designcraft_doc::DocError::NoItem(id))?;
|
||||
for (si, ai) in which {
|
||||
let Some(a) = it.path.anchor_mut(*si, *ai) else { continue };
|
||||
match handle.as_deref() {
|
||||
Some("in") => {
|
||||
a.h_in += dv;
|
||||
if a.kind == designcraft_geom::AnchorKind::Smooth {
|
||||
a.h_out = a.p - (a.h_in - a.p);
|
||||
}
|
||||
}
|
||||
Some("out") => {
|
||||
a.h_out += dv;
|
||||
if a.kind == designcraft_geom::AnchorKind::Smooth {
|
||||
a.h_in = a.p - (a.h_out - a.p);
|
||||
}
|
||||
}
|
||||
_ => a.translate(dv),
|
||||
}
|
||||
}
|
||||
if it.shape == Shape::Rectangle || it.shape == Shape::Oval || it.shape == Shape::Polygon {
|
||||
it.shape = Shape::Path;
|
||||
}
|
||||
}
|
||||
ok()
|
||||
})
|
||||
|
||||
@@ -1650,3 +1650,38 @@ fn a_line_fits_when_its_baseline_does() {
|
||||
assert!(!overset(&mut s, 78.5), "the line is set; its descenders hang below the frame");
|
||||
assert!(overset(&mut s, 50.0), "a baseline below the frame still oversets the line");
|
||||
}
|
||||
|
||||
/// Direct Selection: a marquee over the top edges of two frames selects their top points, and a
|
||||
/// drag on one of them slants both frames in one undo step (InDesign's way to make a row of
|
||||
/// parallelograms).
|
||||
#[test]
|
||||
fn direct_selection_moves_the_points_of_several_frames_at_once() {
|
||||
use designcraft_tools::{Mods, PointerEvent, PointerKind};
|
||||
let mut s = session();
|
||||
let v = ViewInfo::at_zoom(1.0);
|
||||
let a = s.execute("frame.create", &json!({"rect": [100, 300, 200, 400]})).unwrap()["id"].as_u64().unwrap();
|
||||
let b = s.execute("frame.create", &json!({"rect": [200, 300, 300, 400]})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("selection.set", &json!({"ids": []})).unwrap();
|
||||
s.set_tool("directSelection");
|
||||
s.pointer(&PointerEvent::new(PointerKind::Down, 80.0, 280.0), v).unwrap();
|
||||
s.pointer(&PointerEvent::new(PointerKind::Drag, 320.0, 320.0), v).unwrap();
|
||||
s.pointer(&PointerEvent::new(PointerKind::Up, 320.0, 320.0), v).unwrap();
|
||||
let sel = s.doc().unwrap().selection.clone();
|
||||
assert_eq!(sel.anchors.len(), 4, "{:?}", sel.anchors);
|
||||
assert!(sel.items.contains(&designcraft_doc::ItemId(a)) && sel.items.contains(&designcraft_doc::ItemId(b)));
|
||||
let shift = Mods { shift: true, ..Default::default() };
|
||||
s.pointer(&PointerEvent::new(PointerKind::Down, 100.0, 300.0), v).unwrap();
|
||||
s.pointer(&PointerEvent::new(PointerKind::Drag, 136.0, 302.0).with_mods(shift), v).unwrap();
|
||||
s.pointer(&PointerEvent::new(PointerKind::Up, 136.0, 302.0).with_mods(shift), v).unwrap();
|
||||
let corners = |s: &Session, id: u64| -> Vec<(f64, f64)> {
|
||||
let st = s.doc().unwrap();
|
||||
let it = st.doc.item(designcraft_doc::ItemId(id)).unwrap();
|
||||
it.path.subpaths[0].anchors.iter().map(|an| ((it.xf * an.p).x.round(), (it.xf * an.p).y.round())).collect()
|
||||
};
|
||||
// Both frames: top points 36 pt to the right, bottom points where they were.
|
||||
assert_eq!(corners(&s, a), vec![(136.0, 300.0), (236.0, 300.0), (200.0, 400.0), (100.0, 400.0)]);
|
||||
assert_eq!(corners(&s, b), vec![(236.0, 300.0), (336.0, 300.0), (300.0, 400.0), (200.0, 400.0)]);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(corners(&s, a), vec![(100.0, 300.0), (200.0, 300.0), (200.0, 400.0), (100.0, 400.0)]);
|
||||
assert_eq!(corners(&s, b), vec![(200.0, 300.0), (300.0, 300.0), (300.0, 400.0), (200.0, 400.0)]);
|
||||
}
|
||||
|
||||
+123
-5
@@ -90,6 +90,8 @@ enum Drag {
|
||||
/// None when that point could not be read. The live document during a drag is the
|
||||
/// previous preview, so the snap must not read the anchor from it again.
|
||||
at: Option<(SpreadRef, Point)>,
|
||||
/// The other selected anchor points, which move with it: (item, subpath, anchor).
|
||||
others: Vec<(u64, usize, usize)>,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -179,6 +181,60 @@ fn spread_delta_to_canvas(xf: Affine, d: Vec2) -> Vec2 {
|
||||
(xf * Point::new(d.x, d.y)) - (xf * Point::ORIGIN)
|
||||
}
|
||||
|
||||
/// The anchor points inside `r` (canvas) of the unlocked, visible paths, groups' members included.
|
||||
fn anchors_in(cx: &ToolContext, r: Rect) -> Vec<(ItemId, usize, usize)> {
|
||||
fn walk(cx: &ToolContext, items: &[std::sync::Arc<Item>], r: Rect, out: &mut Vec<(ItemId, usize, usize)>) {
|
||||
for it in items {
|
||||
let locked = it.locked || cx.doc.layer(it.layer).is_some_and(|l| l.locked || !l.visible);
|
||||
if locked || it.hidden {
|
||||
continue;
|
||||
}
|
||||
if let Content::Group { items } = &it.content {
|
||||
walk(cx, items, r, out);
|
||||
continue;
|
||||
}
|
||||
let Some(xf) = cx.item_canvas_xf(it.id) else { continue };
|
||||
let m = xf * it.xf;
|
||||
for (si, ai, a) in it.path.anchors() {
|
||||
let q = m * a.p;
|
||||
if q.x >= r.x0 && q.x <= r.x1 && q.y >= r.y0 && q.y <= r.y1 {
|
||||
out.push((it.id, si, ai));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
for slot in &cx.layout.slots {
|
||||
if let Some(sp) = cx.doc.spread(slot.spread) {
|
||||
walk(cx, &sp.items, r, &mut out);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// `selection.set` params selecting `points`: their paths, and those points.
|
||||
fn anchor_selection(cx: &ToolContext, points: &[(ItemId, usize, usize)]) -> Value {
|
||||
let mut ids: Vec<u64> = cx.selection.items.iter().map(|i| i.0).collect();
|
||||
for (i, _, _) in points {
|
||||
if !ids.contains(&i.0) {
|
||||
ids.push(i.0);
|
||||
}
|
||||
}
|
||||
let anchors: Vec<Value> = points.iter().map(|(i, s, a)| json!([i.0, s, a])).collect();
|
||||
json!({"ids": ids, "anchors": anchors})
|
||||
}
|
||||
|
||||
/// `d` held to the nearest multiple of 45°, as Shift does.
|
||||
fn constrain_45(d: Vec2) -> Vec2 {
|
||||
if d.hypot() < 1e-9 {
|
||||
return d;
|
||||
}
|
||||
let step = std::f64::consts::FRAC_PI_4;
|
||||
let a = (d.y.atan2(d.x) / step).round() * step;
|
||||
let proj = d.x * a.cos() + d.y * a.sin();
|
||||
Vec2::new(proj * a.cos(), proj * a.sin())
|
||||
}
|
||||
|
||||
pub struct SelectionTool {
|
||||
direct: bool,
|
||||
drag: Drag,
|
||||
@@ -709,8 +765,40 @@ impl Tool for SelectionTool {
|
||||
&& let Some((id, si, ai, handle)) = anchor_at(cx, p)
|
||||
{
|
||||
let at = anchor_spread_point(cx, id, si, ai);
|
||||
self.drag = Drag::Anchor { id, si, ai, handle, start: p, at };
|
||||
return vec![Action::Begin(if handle.is_some() { "Move Direction Handle".into() } else { "Move Anchor".into() })];
|
||||
let mut out = vec![];
|
||||
let mut others = vec![];
|
||||
if handle.is_none() {
|
||||
// InDesign's anchor point selection: a click selects the point, Shift+click
|
||||
// adds it or takes it away, and dragging a selected point moves all of them.
|
||||
let me = (ItemId(id), si, ai);
|
||||
let mut points = cx.selection.anchors.clone();
|
||||
let was = points.contains(&me);
|
||||
if ev.mods.shift && was {
|
||||
points.retain(|a| *a != me);
|
||||
} else if ev.mods.shift {
|
||||
points.push(me);
|
||||
} else if !was {
|
||||
points = vec![me];
|
||||
}
|
||||
if points != cx.selection.anchors {
|
||||
out.push(Action::Exec("selection.set".into(), anchor_selection(cx, &points)));
|
||||
}
|
||||
if ev.mods.shift && was {
|
||||
self.drag = Drag::None;
|
||||
return out;
|
||||
}
|
||||
others = points.iter().filter(|a| **a != me).map(|(i, s, a)| (i.0, *s, *a)).collect();
|
||||
}
|
||||
let label = if handle.is_some() {
|
||||
"Move Direction Handle"
|
||||
} else if others.is_empty() {
|
||||
"Move Anchor"
|
||||
} else {
|
||||
"Move Anchors"
|
||||
};
|
||||
self.drag = Drag::Anchor { id, si, ai, handle, start: p, at, others };
|
||||
out.push(Action::Begin(label.into()));
|
||||
return out;
|
||||
}
|
||||
// A selected placed graphic's own handles resize it inside its frame (the
|
||||
// frame's anchors, under Direct Selection, come first).
|
||||
@@ -1035,8 +1123,12 @@ impl Tool for SelectionTool {
|
||||
// Screen y points down: a positive screen angle is clockwise.
|
||||
vec![Action::Preview("transform.rotate".into(), json!({"angle": -a}))]
|
||||
}
|
||||
Drag::Anchor { id, si, ai, handle, start, at } => {
|
||||
let d = p - start;
|
||||
Drag::Anchor { id, si, ai, handle, start, at, others } => {
|
||||
let mut d = p - start;
|
||||
// Shift: horizontal, vertical or at 45°.
|
||||
if ev.mods.shift {
|
||||
d = constrain_45(d);
|
||||
}
|
||||
let mut dx = d.x;
|
||||
let mut dy = d.y;
|
||||
self.guides.clear();
|
||||
@@ -1075,6 +1167,9 @@ impl Tool for SelectionTool {
|
||||
let mut params = json!({"id": id, "anchors": [[si, ai]], "dx": dx, "dy": dy});
|
||||
if let Some(h) = handle {
|
||||
params["handle"] = json!(h);
|
||||
} else if !others.is_empty() {
|
||||
let points: Vec<Value> = std::iter::once((id, si, ai)).chain(others).map(|(i, s, a)| json!([i, s, a])).collect();
|
||||
params = json!({"points": points, "dx": dx, "dy": dy});
|
||||
}
|
||||
vec![Action::Preview("path.moveAnchors".into(), params)]
|
||||
}
|
||||
@@ -1100,6 +1195,21 @@ impl Tool for SelectionTool {
|
||||
},
|
||||
Drag::Marquee { start, cur } => {
|
||||
let r = Rect::from_points(start, cur);
|
||||
// Direct Selection: the anchor points inside the marquee, on every path it
|
||||
// reaches (InDesign's way to select points of several frames at once).
|
||||
if self.direct {
|
||||
let mut points = anchors_in(cx, r);
|
||||
if !points.is_empty() {
|
||||
if ev.mods.shift {
|
||||
for a in &cx.selection.anchors {
|
||||
if !points.contains(a) {
|
||||
points.push(*a);
|
||||
}
|
||||
}
|
||||
}
|
||||
return vec![Action::Exec("selection.set".into(), anchor_selection(cx, &points))];
|
||||
}
|
||||
}
|
||||
// Items whose bounds intersect the marquee.
|
||||
let mut ids: Vec<Value> = Vec::new();
|
||||
for slot in &cx.layout.slots {
|
||||
@@ -1131,7 +1241,15 @@ impl Tool for SelectionTool {
|
||||
if key == ToolKey::Escape && self.live.take().is_some_and(|id| cx.selection.items == [id]) {
|
||||
return vec![Action::Handled];
|
||||
}
|
||||
let mv = |dx: f64, dy: f64| vec![Action::Exec("transform.move".into(), json!({"dx": dx, "dy": dy, "copy": mods.alt}))];
|
||||
let points: Vec<Value> = cx.selection.anchors.iter().map(|(i, s, a)| json!([i.0, s, a])).collect();
|
||||
let direct_points = self.direct && !points.is_empty();
|
||||
let mv = |dx: f64, dy: f64| {
|
||||
// Direct Selection with anchor points selected: the arrow keys move the points.
|
||||
if direct_points {
|
||||
return vec![Action::Exec("path.moveAnchors".into(), json!({"points": points.clone(), "dx": dx, "dy": dy}))];
|
||||
}
|
||||
vec![Action::Exec("transform.move".into(), json!({"dx": dx, "dy": dy, "copy": mods.alt}))]
|
||||
};
|
||||
if cx.selection.items.is_empty() {
|
||||
return vec![];
|
||||
}
|
||||
|
||||
@@ -1792,7 +1792,13 @@ fn direct_selection_drags_the_end_anchors_of_type_on_a_path() {
|
||||
let p = Point::new(x + off.x, 300.0 + off.y);
|
||||
let mut direct = create("directSelection");
|
||||
assert_ne!(direct.cursor(&cx, p, Mods::default()), Cursor::PathBracket, "anchor at {x}");
|
||||
assert_eq!(direct.pointer(&cx, &PointerEvent::new(PointerKind::Down, p.x, p.y)), vec![Action::Begin("Move Anchor".into())]);
|
||||
// The press selects that anchor point and starts moving it.
|
||||
let a = direct.pointer(&cx, &PointerEvent::new(PointerKind::Down, p.x, p.y));
|
||||
assert!(
|
||||
matches!(a.first(), Some(Action::Exec(cmd, p)) if cmd == "selection.set" && p["anchors"].as_array().map(Vec::len) == Some(1)),
|
||||
"{a:?}"
|
||||
);
|
||||
assert_eq!(a.last(), Some(&Action::Begin("Move Anchor".into())));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1936,3 +1942,52 @@ fn loaded_text_cursor_threads_into_a_frame_inside_a_group() {
|
||||
assert_eq!(cx2.hit_text_frame(q).map(|(_, i)| i), None, "no text frame in that group");
|
||||
assert_eq!(t2.cursor(&cx2, q, Mods::default()), Cursor::NotAllowed);
|
||||
}
|
||||
|
||||
/// Direct Selection selects anchor points with a marquee across several frames, and dragging one of
|
||||
/// them moves them all, as InDesign does when the top points of a row of frames are dragged to slant
|
||||
/// them (Zero2Graphic's page layout tutorial, 1:15).
|
||||
#[test]
|
||||
fn direct_selection_marquee_selects_points_of_several_frames_and_drags_them_together() {
|
||||
let mut d = Document::new(&NewDocument::default());
|
||||
let a = add_rect(&mut d, Rect::new(100.0, 100.0, 200.0, 200.0));
|
||||
let b = add_rect(&mut d, Rect::new(200.0, 100.0, 300.0, 200.0));
|
||||
let (c, l) = (Cache::new(), CanvasLayout::new(&d, false));
|
||||
let none = Selection::default();
|
||||
let mut t = create("directSelection");
|
||||
// A marquee around the top edges: the four top points, not the frames' bottom ones.
|
||||
let (p0, p1) = (spread_pt(&l, 80.0, 90.0), spread_pt(&l, 320.0, 110.0));
|
||||
t.pointer(&ctx(&d, &none, &c, &l), &PointerEvent::new(PointerKind::Down, p0.x, p0.y));
|
||||
t.pointer(&ctx(&d, &none, &c, &l), &PointerEvent::new(PointerKind::Drag, p1.x, p1.y));
|
||||
let up = t.pointer(&ctx(&d, &none, &c, &l), &PointerEvent::new(PointerKind::Up, p1.x, p1.y));
|
||||
let Some(Action::Exec(cmd, p)) = up.last() else { panic!("{up:?}") };
|
||||
assert_eq!(cmd, "selection.set");
|
||||
assert_eq!(p["ids"], serde_json::json!([a.0, b.0]));
|
||||
let mut got: Vec<(u64, u64, u64)> =
|
||||
p["anchors"].as_array().unwrap().iter().map(|v| (v[0].as_u64().unwrap(), v[1].as_u64().unwrap(), v[2].as_u64().unwrap())).collect();
|
||||
got.sort();
|
||||
assert_eq!(got, vec![(a.0, 0, 0), (a.0, 0, 1), (b.0, 0, 0), (b.0, 0, 1)]);
|
||||
// Those points selected: dragging one moves all four, in one preview.
|
||||
let sel = Selection { items: vec![a, b], anchors: vec![(a, 0, 0), (a, 0, 1), (b, 0, 0), (b, 0, 1)], ..Default::default() };
|
||||
let cx = ctx(&d, &sel, &c, &l);
|
||||
let at = spread_pt(&l, 200.0, 100.0);
|
||||
let down = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, at.x, at.y));
|
||||
assert_eq!(down, vec![Action::Begin("Move Anchors".into())]);
|
||||
let shift = Mods { shift: true, ..Default::default() };
|
||||
let drag = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, at.x + 36.0, at.y + 3.0).with_mods(shift));
|
||||
let Some(Action::Preview(cmd, p)) = drag.last() else { panic!("{drag:?}") };
|
||||
assert_eq!(cmd, "path.moveAnchors");
|
||||
assert_eq!(p["points"].as_array().map(Vec::len), Some(4), "{p}");
|
||||
// Shift keeps the move horizontal.
|
||||
assert_eq!((p["dx"].as_f64(), p["dy"].as_f64()), (Some(36.0), Some(0.0)));
|
||||
assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, at.x + 36.0, at.y)), vec![Action::Commit]);
|
||||
// Shift+click on a selected point takes it out of the selection; the arrow keys move the points.
|
||||
let out = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, at.x, at.y).with_mods(shift));
|
||||
let Some(Action::Exec(cmd, p)) = out.first() else { panic!("{out:?}") };
|
||||
assert_eq!(cmd, "selection.set");
|
||||
assert_eq!(p["anchors"].as_array().map(Vec::len), Some(3), "{p}");
|
||||
let keys = t.key(&cx, ToolKey::Right, Mods::default());
|
||||
assert!(
|
||||
matches!(keys.as_slice(), [Action::Exec(cmd, p)] if cmd == "path.moveAnchors" && p["points"].as_array().map(Vec::len) == Some(4)),
|
||||
"{keys:?}"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1229,9 +1229,11 @@ fn draw_selection(app: &DesignApp, painter: &egui::Painter, xf: &Xf, doc: &Docum
|
||||
let (Some(it), Some((a, _))) = (doc.item(*id), item_canvas_xf(doc, layout, *id)) else { continue };
|
||||
let m = a * it.xf;
|
||||
let col = layer_color(doc, it);
|
||||
for sp in &it.path.subpaths {
|
||||
for an in &sp.anchors {
|
||||
for (si, sp) in it.path.subpaths.iter().enumerate() {
|
||||
for (ai, an) in sp.anchors.iter().enumerate() {
|
||||
let p = xf.to_screen(m * an.p);
|
||||
// A selected anchor point is solid, the others hollow (InDesign).
|
||||
let chosen = sel.anchors.contains(&(*id, si, ai));
|
||||
for (h, has) in [(an.h_in, an.has_in()), (an.h_out, an.has_out())] {
|
||||
if has {
|
||||
let hp = xf.to_screen(m * h);
|
||||
@@ -1240,7 +1242,7 @@ fn draw_selection(app: &DesignApp, painter: &egui::Painter, xf: &Xf, doc: &Docum
|
||||
}
|
||||
}
|
||||
let r = Rect::from_center_size(p, vec2(5.0, 5.0));
|
||||
painter.rect_filled(r, 0.0, Color32::WHITE);
|
||||
painter.rect_filled(r, 0.0, if chosen { col } else { Color32::WHITE });
|
||||
painter.rect_stroke(r, 0.0, Stroke::new(1.0, col), StrokeKind::Inside);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user