mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-10 18:10:01 -06:00
M4.89: Slice and Slice Selection tools draw, pick, move, resize and delete slices
The Slice tool (Shift+K) drags out a user slice (Shift square, Alt from the centre, snapping to ruler guides) as one undo step through object.slice.create. The Slice Selection tool selects slices (Shift toggles, object.slice.select), moves them (object.slice.move; object slices move their objects), resizes a user slice by its handles (object.slice.setRect), opens Slice Options on a double click and deletes with Delete (object.slice.delete). Hidden or locked slices can't be picked. Both tools have cursors drawn in code. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
271b0c6c25
commit
fbc7549c31
@@ -129,6 +129,51 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
list
|
||||
),
|
||||
cmd!("select.object.slices", "Slices", ["Select", "Object"], None, "{} select every user and object slice → {count}", has_doc, select_all),
|
||||
cmd!(
|
||||
"object.slice.create",
|
||||
"Create Slice",
|
||||
[],
|
||||
None,
|
||||
"{x, y, width, height} a user slice over that rectangle, selected (the Slice tool's drag) → {id}",
|
||||
has_doc,
|
||||
create
|
||||
),
|
||||
cmd!(
|
||||
"object.slice.setRect",
|
||||
"Set Slice Rectangle",
|
||||
[],
|
||||
None,
|
||||
"{id: user slice, x, y, width, height} move or resize a user slice (an object slice follows its object) → {id}",
|
||||
has_doc,
|
||||
set_rect
|
||||
),
|
||||
cmd!(
|
||||
"object.slice.move",
|
||||
"Move Slices",
|
||||
[],
|
||||
None,
|
||||
"{slices?: [id…] (default: the selected slices), dx, dy} move the slices: user slices move, object slices move their objects → {ids}",
|
||||
has_doc,
|
||||
move_slices
|
||||
),
|
||||
cmd!(
|
||||
"object.slice.delete",
|
||||
"Delete Slices",
|
||||
[],
|
||||
None,
|
||||
"{slices?: [id…] (default: the selected slices)} delete the user slices and release the object slices (their objects stay) → {count}",
|
||||
has_doc,
|
||||
delete
|
||||
),
|
||||
cmd!(
|
||||
"object.slice.select",
|
||||
"Select Slices",
|
||||
[],
|
||||
None,
|
||||
"{slices?: [id…] (user slice ids or ids of objects with an object slice; none: deselect the slices), toggle?: bool (Shift: add the unselected ones, drop the selected ones)} select slices as the Slice Selection tool does; the objects are deselected → {selected: [id…]}",
|
||||
has_doc,
|
||||
select
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
@@ -145,6 +190,18 @@ impl Session {
|
||||
|
||||
// ---------- targets and params ----------
|
||||
|
||||
/// The slices a command acts on: `slices` (ids that must all be slices), else the selected ones.
|
||||
fn slice_targets(s: &Session, p: &Value, cmd: &str) -> Result<Vec<NodeId>> {
|
||||
let st = s.doc()?;
|
||||
match ids_param(p, "slices") {
|
||||
Some(ids) => match ids.iter().find(|id| !st.doc.is_slice(**id)) {
|
||||
Some(id) => Err(bad(cmd, format!("{id} is not a slice"))),
|
||||
None => Ok(dedup(ids)),
|
||||
},
|
||||
None => Ok(selected_slices(&st.doc, &st.selection)),
|
||||
}
|
||||
}
|
||||
|
||||
/// The selected slices: those the Slice Selection tool selected and the selected objects that are
|
||||
/// object slices.
|
||||
fn selected(s: &Session) -> Result<Vec<NodeId>> {
|
||||
@@ -565,3 +622,96 @@ fn select_all(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
s.select(|_, sel| sel.set_slices(ids))?;
|
||||
Ok(json!({ "count": n }))
|
||||
}
|
||||
|
||||
// ---------- the Slice and Slice Selection tools ----------
|
||||
|
||||
/// `{x, y, width, height}` as a rectangle a slice can have.
|
||||
fn rect_param(p: &Value, cmd: &str) -> Result<Rect> {
|
||||
let (x, y) = (f64_req(p, "x", cmd)?, f64_req(p, "y", cmd)?);
|
||||
let r = Rect::new(x, y, x + f64_req(p, "width", cmd)?, y + f64_req(p, "height", cmd)?).abs();
|
||||
if slice_rect_ok(r) { Ok(r) } else { Err(bad(cmd, "the rectangle needs a size and must lie on the canvas")) }
|
||||
}
|
||||
|
||||
fn create(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let r = rect_param(p, "object.slice.create")?;
|
||||
let id = s.edit("Slice", |d, sel| {
|
||||
let id = add_slice(d, r, SliceOptions::default());
|
||||
sel.set_slices([id]);
|
||||
Ok(id)
|
||||
})?;
|
||||
Ok(json!({ "id": id.0 }))
|
||||
}
|
||||
|
||||
fn set_rect(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "object.slice.setRect";
|
||||
let id = id_param(p, "id").ok_or_else(|| bad(C, "missing `id`"))?;
|
||||
let r = rect_param(p, C)?;
|
||||
let d = &s.doc()?.doc;
|
||||
if d.slice(id).is_none() {
|
||||
return Err(bad(C, if d.is_slice(id) { format!("{id} is an object slice: it follows its object") } else { format!("{id} is not a slice") }));
|
||||
}
|
||||
s.edit("Resize Slice", |d, _| {
|
||||
if let Some(sl) = d.slice_mut(id) {
|
||||
sl.rect = r;
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(json!({ "id": id.0 }))
|
||||
}
|
||||
|
||||
fn move_slices(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "object.slice.move";
|
||||
let targets = slice_targets(s, p, C)?;
|
||||
let v = vectorcraft_geom::Vec2::new(f64_req(p, "dx", C)?, f64_req(p, "dy", C)?);
|
||||
if !(v.x.is_finite() && v.y.is_finite()) {
|
||||
return Err(bad(C, "dx and dy must be numbers"));
|
||||
}
|
||||
s.edit("Move Slice", |d, _| {
|
||||
for id in &targets {
|
||||
match d.slice_mut(*id) {
|
||||
Some(sl) if slice_rect_ok(sl.rect + v) => sl.rect = sl.rect + v,
|
||||
Some(_) => return Err(bad(C, "the slice would leave the canvas")),
|
||||
None => {
|
||||
if let Some(n) = d.node_mut(*id) {
|
||||
n.transform(vectorcraft_geom::Affine::translate(v), vectorcraft_doc::Scaling::default());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(json!({ "ids": ids_json(&targets) }))
|
||||
}
|
||||
|
||||
fn delete(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let targets = slice_targets(s, p, "object.slice.delete")?;
|
||||
if targets.is_empty() {
|
||||
return Ok(json!({ "count": 0 }));
|
||||
}
|
||||
s.edit("Delete Slice", |d, _| {
|
||||
for id in &targets {
|
||||
remove_slice(d, *id);
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(json!({ "count": targets.len() }))
|
||||
}
|
||||
|
||||
fn select(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ids = slice_targets(s, &json!({ "slices": p.get("slices").cloned().unwrap_or(json!([])) }), "object.slice.select")?;
|
||||
let toggle = bool_or(p, "toggle", false);
|
||||
s.select(|_, sel| {
|
||||
let mut now = if toggle { sel.slices.clone() } else { vec![] };
|
||||
for id in ids {
|
||||
match now.iter().position(|x| *x == id) {
|
||||
Some(i) if toggle => {
|
||||
now.remove(i);
|
||||
}
|
||||
Some(_) => {}
|
||||
None => now.push(id),
|
||||
}
|
||||
}
|
||||
sel.set_slices(now);
|
||||
})?;
|
||||
Ok(json!({ "selected": ids_json(&s.doc()?.selection.slices) }))
|
||||
}
|
||||
|
||||
@@ -250,3 +250,80 @@ fn slices_survive_a_native_round_trip() {
|
||||
run(&mut s, "edit.duplicate", json!({}));
|
||||
assert_eq!(s.doc().unwrap().doc.object_slices().len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slice_tool_gestures_are_one_undo_step_each() {
|
||||
use vectorcraft_tools::{Mods, PointerEvent, PointerKind as K, ToolKey};
|
||||
let mut s = session();
|
||||
let view = ViewInfo { smart_guides: false, ..Default::default() };
|
||||
let undo = |s: &Session| s.doc().unwrap().history.undo.len();
|
||||
let gesture = |s: &mut Session, pts: &[(K, f64, f64)]| {
|
||||
for (k, x, y) in pts {
|
||||
s.pointer(&PointerEvent::new(*k, *x, *y), view).unwrap();
|
||||
}
|
||||
};
|
||||
s.select_tool("slice", view).unwrap();
|
||||
let n = undo(&s);
|
||||
gesture(&mut s, &[(K::Down, 10.0, 10.0), (K::Drag, 50.0, 30.0), (K::Drag, 110.0, 60.0), (K::Up, 110.0, 60.0)]);
|
||||
assert_eq!(undo(&s), n + 1);
|
||||
let id = s.doc().unwrap().doc.slices[0].id;
|
||||
assert_eq!(s.doc().unwrap().doc.slices.len(), 1, "the previews left one slice");
|
||||
assert_eq!(rect_of(&mut s, id.0), [10.0, 10.0, 100.0, 50.0]);
|
||||
assert_eq!(s.doc().unwrap().selection.slices, vec![id]);
|
||||
|
||||
// Slice Selection: drag the slice, then its bottom-right handle, then Delete.
|
||||
s.select_tool("sliceSelection", view).unwrap();
|
||||
gesture(&mut s, &[(K::Down, 20.0, 20.0), (K::Drag, 30.0, 40.0), (K::Drag, 40.0, 50.0), (K::Up, 40.0, 50.0)]);
|
||||
assert_eq!(undo(&s), n + 2);
|
||||
assert_eq!(rect_of(&mut s, id.0), [30.0, 40.0, 100.0, 50.0]);
|
||||
gesture(&mut s, &[(K::Down, 130.0, 90.0), (K::Drag, 140.0, 95.0), (K::Drag, 150.0, 100.0), (K::Up, 150.0, 100.0)]);
|
||||
assert_eq!(undo(&s), n + 3);
|
||||
assert_eq!(rect_of(&mut s, id.0), [30.0, 40.0, 120.0, 60.0]);
|
||||
assert!(s.tool_claims_key(ToolKey::Delete, view));
|
||||
s.tool_key(ToolKey::Delete, Mods::default(), view).unwrap();
|
||||
assert!(s.doc().unwrap().doc.slices.is_empty());
|
||||
assert_eq!(undo(&s), n + 4);
|
||||
// Undo walks the gestures back one at a time.
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(rect_of(&mut s, id.0), [30.0, 40.0, 120.0, 60.0]);
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(rect_of(&mut s, id.0), [30.0, 40.0, 100.0, 50.0]);
|
||||
|
||||
// An object slice moves its object; locked slices stay put.
|
||||
let a = rect(&mut s, 200.0, 200.0, 40.0, 40.0);
|
||||
run(&mut s, "object.slice.make", json!({}));
|
||||
gesture(&mut s, &[(K::Down, 220.0, 220.0), (K::Drag, 230.0, 220.0), (K::Drag, 240.0, 230.0), (K::Up, 240.0, 230.0)]);
|
||||
assert_eq!(s.doc().unwrap().doc.node(NodeId(a)).unwrap().geometric_bounds().unwrap().x0, 220.0);
|
||||
run(&mut s, "view.slices.lock", json!({"locked": true}));
|
||||
let before = undo(&s);
|
||||
gesture(&mut s, &[(K::Down, 250.0, 240.0), (K::Drag, 280.0, 260.0), (K::Up, 280.0, 260.0)]);
|
||||
assert_eq!(undo(&s), before);
|
||||
// The journal replays the gestures as commands.
|
||||
let journal: Vec<String> = s.journal.iter().map(|(c, _)| c.clone()).collect();
|
||||
for c in ["object.slice.create", "object.slice.move", "object.slice.setRect", "object.slice.delete", "object.slice.select"] {
|
||||
assert!(journal.iter().any(|j| j == c), "{c} journaled: {journal:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slice_commands_check_their_params() {
|
||||
let mut s = session();
|
||||
assert!(s.execute("object.slice.create", &json!({"x": 0, "y": 0, "width": 0, "height": 10})).is_err(), "no area");
|
||||
assert!(s.execute("object.slice.create", &json!({"x": 1e9, "y": 0, "width": 10, "height": 10})).is_err(), "off the canvas");
|
||||
let id = run(&mut s, "object.slice.create", json!({"x": 50, "y": 60, "width": -20, "height": 10}))["id"].as_u64().unwrap();
|
||||
assert_eq!(rect_of(&mut s, id), [30.0, 60.0, 20.0, 10.0], "a negative size draws the other way");
|
||||
run(&mut s, "object.slice.setRect", json!({"id": id, "x": 0, "y": 0, "width": 5, "height": 5}));
|
||||
assert_eq!(rect_of(&mut s, id), [0.0, 0.0, 5.0, 5.0]);
|
||||
let a = rect(&mut s, 100.0, 100.0, 10.0, 10.0);
|
||||
run(&mut s, "object.slice.make", json!({}));
|
||||
assert!(
|
||||
s.execute("object.slice.setRect", &json!({"id": a, "x": 0, "y": 0, "width": 5, "height": 5})).is_err(),
|
||||
"object slices follow their object"
|
||||
);
|
||||
assert!(s.execute("object.slice.move", &json!({"slices": [12345], "dx": 1, "dy": 1})).is_err(), "not a slice");
|
||||
assert_eq!(run(&mut s, "object.slice.select", json!({"slices": [id, a]}))["selected"], json!([id, a]));
|
||||
assert_eq!(run(&mut s, "object.slice.select", json!({"slices": [a], "toggle": true}))["selected"], json!([id]));
|
||||
assert_eq!(run(&mut s, "object.slice.delete", json!({"slices": [a]}))["count"], 1);
|
||||
assert!(s.doc().unwrap().doc.node(NodeId(a)).is_some(), "deleting an object slice keeps its object");
|
||||
assert_eq!(run(&mut s, "object.slice.select", json!({}))["selected"], json!([]));
|
||||
}
|
||||
|
||||
@@ -89,6 +89,8 @@ impl Session {
|
||||
unit,
|
||||
stroke_unit,
|
||||
paste_plain_text: self.prefs.paste_text_formatting == "plain",
|
||||
slices_hidden: self.menu.slices_hidden,
|
||||
slices_locked: self.menu.slices_locked,
|
||||
};
|
||||
let tool = &mut self.tool;
|
||||
match crate::guard::catch_panic(|| f(tool.as_mut(), &cx)) {
|
||||
|
||||
Reference in New Issue
Block a user