mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-22 19:08:39 +00:00
#1139: Shift-click selects several artboards, which move and delete together
The Artboard tool and the Artboards panel only ever had one artboard selected, so Shift-clicking another didn't add it. As in Illustrator: - Artboard tool: Shift-click adds an artboard to the selection (it becomes active) or takes one out; a plain click on a selected one keeps the selection, so a drag moves them all (Alt copies them all, the copies becoming the selection), and Delete removes them all, one artboard always staying. The other selected artboards show dashed bounds; `selected` is a tool option. - Artboards panel: with the Artboard tool, Shift- or Ctrl/Cmd-click on a row adds or removes it, every selected row is highlighted, and Delete Artboard removes them all. - `artboard.move` and `artboard.delete` take `indices` besides `index`: several artboards in one undo step, each with its art (an object moved once where artboards overlap). Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
65a4847eed
commit
423cb18f40
8 files changed
+310
-49
No files matched your search
+1
-1
@@ -127,7 +127,7 @@ Ranked; detail in [docs/roadmap.md](docs/roadmap.md) and [docs/gaps.md](docs/gap
|
||||
|
||||
| Date | What landed |
|
||||
|---|---|
|
||||
| 2026-10-11 | Effect › Brush Strokes: Accented Edges, Angled Strokes, Crosshatch, Dark Strokes, Ink Outlines, Spatter, Sprayed Strokes, Sumi-e (G2). Command parameters from agents and files checked before anything changes (paint, place, cuts, dashes, crop, asset ids, anchors, paths, distort; #984–#999); command dialogs keep whole numbers whole, so Create Object Mosaic takes the tiles typed (#1000); stepper arrows take Shift (×10) and Ctrl/Cmd (÷10) like the arrow keys (#991). Exports and Rasterize supersample Art Optimized edges, so shapes meeting edge to edge leave no seam (#983). Character and paragraph styles take leading, kerning, mojikumi and the other attributes left out at their defaults (#1001). Drawing, typing and pasting with no layer shown and unlocked is refused instead of adding art to a hidden or locked layer (#1002). MCP `add_text` gives type on or in a path the colour asked for (#1003). MCP `screenshot` of a connected app renders the artboard asked for (#1004). `vectorcraft-cli run` ends quietly when its reader closes early (#1005). Select › All on Active Artboard selects on the artboard made active, not always the first (#1006). Reverse Gradient keeps off-centre midpoints, mirrored (#1007). A polar grid with no radial dividers draws none (#1008). Use Precise Cursors also covers the Pen's join pointer (#1009). SVGs with one absolute and one unitless root side import at their size, unstretched (#1010). DXF US survey inches, yards and miles open at their size and name (#1011). A file placed into a document with no other links follows its first change even while the app sits idle (#1031). PDF import keeps the style a font name says when the file doesn't embed the font (#1023). Shift selects ruler guides and art together, and Delete removes both (#1022). Open filled paths are hit by their fill on every side, the rotate cursor bows around the corner it is at, the flip icons match their flips, the Color Picker previews on the selection, and Properties › Transform reaches Scale Corners and Scale Strokes & Effects (#1036). Offset Path and the other booleans finish on outlines where the sweep used to loop until memory ran out (linesweeper 0.5, #1028). Floating panel groups stack into sets that move together, collapse to their tabs and dock as extra dock columns that scroll (#1020). Ruler guides live on layers: listed in the Layers panel, shown, hidden, locked, duplicated, merged and deleted with their layer, and moved between layers (#1021). A `.ai` whose editing data uses global process colours (`Xk`/`XK`) or pattern fills keeps its sublayers: the colours come in as global swatches at their tint, the patterns as pattern swatches (#1025). A `.ai` in PostScript form opens through its layers, read from the program itself, also when it names the prolog defining its operators without including it, as Rhino and other CAD apps write it (#1027). A fill or stroke chosen while the Type tool has characters selected colours just those characters (#1063). Scaled type shows its scaled font size, leading, baseline shift and horizontal scale in the Character panel, and a size typed there is the size you get (#1034). Character panel menu › Proportional Metrics sets Japanese and Chinese full-width glyphs on the font's proportional widths in horizontal type (#966). Align, Distribute, Move, Arrange, Lock, Hide, Ungroup and other Object commands that would change nothing no longer record an undo step or mark the document changed (#1060). Spot colours defined in Lab open with their Lab values from `.ai` and PDF files, instead of as wrong RGB colours or their CMYK equivalents (#1032). Build scripts leave a generated source they wouldn't change untouched, so the next build doesn't compile most of the workspace again where file times lose their sub-second part (#1051). A fill colour recolours the gradient mesh point last clicked with the Mesh tool or Direct Selection, and a Mesh tool click on a mesh's point selects that mesh (#1052). The All Tools drawer stays two tools wide instead of filling a wide window (#1054). Text Wrap's Make, Release and Options refuse a list of object ids with a malformed or unknown id instead of skipping it (#1055). A freeform gradient line with a malformed point index is refused instead of being shortened to the indices that are valid (#1056). The Corners dialog (`ui.corners`) refuses a malformed object id or a listed anchor that isn't a corner instead of editing the selection or the other corners (#1057). New View refuses a centre that isn't exactly [x, y] and a rotation that isn't a number, instead of trimming the one and saving the other as 0° (#1058). The command sweeps try junk scale, spacing and artboard values too, which they had skipped since before those bugs were fixed (#1059). EPS and PostScript `arc` and `arcn` with huge angles no longer hang the import, and an arc whose angles are whole turns apart is empty, as the PostScript reference defines it (#1062). Align and DisLine truncated
|
||||
| 2026-10-11 | Effect › Brush Strokes: Accented Edges, Angled Strokes, Crosshatch, Dark Strokes, Ink Outlines, Spatter, Sprayed Strokes, Sumi-e (G2). Command parameters from agents and files checked before anything changes (paint, place, cuts, dashes, crop, asset ids, anchors, paths, distort; #984–#999); command dialogs keep whole numbers whole, so Create Object Mosaic takes the tiles typed (#1000); stepper arrows take Shift (×10) and Ctrl/Cmd (÷10) like the arrow keys (#991). Exports and Rasterize supersample Art Optimized edges, so shapes meeting edge to edge leave no seam (#983). Character and paragraph styles take leading, kerning, mojikumi and the other attributes left out at their defaults (#1001). Drawing, typing and pasting with no layer shown and unlocked is refused instead of adding art to a hidden or locked layer (#1002). MCP `add_text` gives type on or in a path the colour asked for (#1003). MCP `screenshot` of a connected app renders the artboard asked for (#1004). `vectorcraft-cli run` ends quietly when its reader closes early (#1005). Select › All on Active Artboard selects on the artboard made active, not always the first (#1006). Reverse Gradient keeps off-centre midpoints, mirrored (#1007). A polar grid with no radial dividers draws none (#1008). Use Precise Cursors also covers the Pen's join pointer (#1009). SVGs with one absolute and one unitless root side import at their size, unstretched (#1010). DXF US survey inches, yards and miles open at their size and name (#1011). A file placed into a document with no other links follows its first change even while the app sits idle (#1031). PDF import keeps the style a font name says when the file doesn't embed the font (#1023). Shift selects ruler guides and art together, and Delete removes both (#1022). Open filled paths are hit by their fill on every side, the rotate cursor bows around the corner it is at, the flip icons match their flips, the Color Picker previews on the selection, and Properties › Transform reaches Scale Corners and Scale Strokes & Effects (#1036). Offset Path and the other booleans finish on outlines where the sweep used to loop until memory ran out (linesweeper 0.5, #1028). Floating panel groups stack into sets that move together, collapse to their tabs and dock as extra dock columns that scroll (#1020). Ruler guides live on layers: listed in the Layers panel, shown, hidden, locked, duplicated, merged and deleted with their layer, and moved between layers (#1021). A `.ai` whose editing data uses global process colours (`Xk`/`XK`) or pattern fills keeps its sublayers: the colours come in as global swatches at their tint, the patterns as pattern swatches (#1025). A `.ai` in PostScript form opens through its layers, read from the program itself, also when it names the prolog defining its operators without including it, as Rhino and other CAD apps write it (#1027). A fill or stroke chosen while the Type tool has characters selected colours just those characters (#1063). Scaled type shows its scaled font size, leading, baseline shift and horizontal scale in the Character panel, and a size typed there is the size you get (#1034). Character panel menu › Proportional Metrics sets Japanese and Chinese full-width glyphs on the font's proportional widths in horizontal type (#966). Align, Distribute, Move, Arrange, Lock, Hide, Ungroup and other Object commands that would change nothing no longer record an undo step or mark the document changed (#1060). Spot colours defined in Lab open with their Lab values from `.ai` and PDF files, instead of as wrong RGB colours or their CMYK equivalents (#1032). Build scripts leave a generated source they wouldn't change untouched, so the next build doesn't compile most of the workspace again where file times lose their sub-second part (#1051). A fill colour recolours the gradient mesh point last clicked with the Mesh tool or Direct Selection, and a Mesh tool click on a mesh's point selects that mesh (#1052). The All Tools drawer stays two tools wide instead of filling a wide window (#1054). Text Wrap's Make, Release and Options refuse a list of object ids with a malformed or unknown id instead of skipping it (#1055). A freeform gradient line with a malformed point index is refused instead of being shortened to the indices that are valid (#1056). The Corners dialog (`ui.corners`) refuses a malformed object id or a listed anchor that isn't a corner instead of editing the selection or the other corners (#1057). New View refuses a centre that isn't exactly [x, y] and a rotation that isn't a number, instead of trimming the one and saving the other as 0° (#1058). The command sweeps try junk scale, spacing and artboard values too, which they had skipped since before those bugs were fixed (#1059). EPS and PostScript `arc` and `arcn` with huge angles no longer hang the import, and an arc whose angles are whole turns apart is empty, as the PostScript reference defines it (#1062). Align and DisLine truncated
|
||||
| 2026-10-10 | Initial live Revolve (Effect › 3D and Materials, #846, #605), in its own `three-d` crate. Progress docs re-measured and restructured to the craftrules standard (this page, `docs/target-app-parity.md`, `gaps.md`, `ui-parity.md`, the format, hardware, localization, effects and type checklists). Same day: v0.8.0; Effect › Distort, Pixelate and Texture filters (13); German interface; Variables (data merge); Graph Type value axes and tick marks; restart when the first frame never reaches the screen (#964) |
|
||||
| 2026-10-09 | Radial Blur, Smart Blur, Unsharp Mask (gap 2 begins); 340 commits, the busiest day |
|
||||
| 2026-10-08 | v0.5.0–v0.7.0; community contributions: Pen and shape-tool modifiers, Layers, Artboards, Japanese composition, Hebrew/Arabic type, interface languages, saved selections |
|
||||
|
||||
@@ -129,7 +129,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
has_doc,
|
||||
artboard_new
|
||||
),
|
||||
cmd!("artboard.delete", "Delete Artboard", ["Window", "Artboards"], None, "{index}", has_doc, artboard_delete),
|
||||
cmd!(
|
||||
"artboard.delete",
|
||||
"Delete Artboard",
|
||||
["Window", "Artboards"],
|
||||
None,
|
||||
"{index | indices: [index…]} delete artboards (with their guides) in one step; one is always left",
|
||||
has_doc,
|
||||
artboard_delete
|
||||
),
|
||||
cmd!(
|
||||
"artboard.setProps",
|
||||
"Artboard Options…",
|
||||
@@ -749,17 +757,16 @@ fn artboard_new(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
fn artboard_delete(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let i = p.get("index").and_then(Value::as_u64).ok_or_else(|| bad("artboard.delete", "missing index"))? as usize;
|
||||
let indices = super::panelcmds::artboard_indices(&s.doc()?.doc, p, "artboard.delete")?;
|
||||
s.edit("Delete Artboard", |d, sel| {
|
||||
if d.artboards.len() <= 1 {
|
||||
if indices.len() >= d.artboards.len() {
|
||||
return Err(EngineError::Other("a document needs at least one artboard".into()));
|
||||
}
|
||||
if i >= d.artboards.len() {
|
||||
return Err(EngineError::Other("no such artboard".into()));
|
||||
// The last first, so the others keep their places; their guides go with them.
|
||||
for &i in indices.iter().rev() {
|
||||
let id = d.artboards.remove(i).id;
|
||||
d.retain_guides(sel, |_, g| g.artboard != Some(id));
|
||||
}
|
||||
// Its guides go with it.
|
||||
let id = d.artboards.remove(i).id;
|
||||
d.retain_guides(sel, |_, g| g.artboard != Some(id));
|
||||
Ok(())
|
||||
})?;
|
||||
ok()
|
||||
|
||||
@@ -78,6 +78,21 @@ fn board_and_art(s: &Session, p: &Value) -> (usize, bool) {
|
||||
|
||||
/// The art that goes with artboard `i` when `art`: the objects fully inside it (locked and hidden
|
||||
/// ones only with prefs moveLockedWithArtboard), in paint order.
|
||||
/// The artboards `cmd` acts on: `indices` (several), else `index` (one), each an artboard of `d`,
|
||||
/// in order and once each.
|
||||
pub(crate) fn artboard_indices(d: &vectorcraft_doc::Document, p: &Value, cmd: &str) -> Result<Vec<usize>> {
|
||||
let listed = match (p.get("indices"), p.get("index")) {
|
||||
(Some(v), _) => point_indices(v),
|
||||
(None, Some(i)) => point_indices(&json!([i])),
|
||||
(None, None) => return Err(bad(cmd, "missing index")),
|
||||
};
|
||||
let mut indices =
|
||||
listed.filter(|v| !v.is_empty() && v.iter().all(|i| *i < d.artboards.len())).ok_or_else(|| EngineError::Other("no such artboard".into()))?;
|
||||
indices.sort_unstable();
|
||||
indices.dedup();
|
||||
Ok(indices)
|
||||
}
|
||||
|
||||
pub(crate) fn artboard_art(s: &Session, i: usize, art: bool) -> Result<Vec<NodeId>> {
|
||||
let d = &s.doc()?.doc;
|
||||
let rect = d.artboards.get(i).map(|a| a.rect).ok_or_else(|| EngineError::Other("no such artboard".into()))?;
|
||||
|
||||
@@ -45,7 +45,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Move Artboard",
|
||||
[],
|
||||
None,
|
||||
"{index, dx, dy, moveArt?: bool, copy?: bool} move an artboard (and the art fully inside it; locked and hidden art only with prefs moveLockedWithArtboard); `copy` (Alt-drag) leaves them and moves copies → {index, moved: the art moved or the copies}",
|
||||
"{index | indices: [index…], dx, dy, moveArt?: bool, copy?: bool} move artboards (and the art fully inside them; locked and hidden art only with prefs moveLockedWithArtboard), several in one step; `copy` (Alt-drag) leaves them and moves copies → {index (the first), indices: the artboards moved or the copies, moved: the art moved or the copies}",
|
||||
has_doc,
|
||||
artboard_move
|
||||
),
|
||||
@@ -366,26 +366,41 @@ fn sample_color(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// ---------- artboards ----------
|
||||
|
||||
fn artboard_move(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let i = p.get("index").and_then(Value::as_u64).ok_or_else(|| bad("artboard.move", "missing index"))? as usize;
|
||||
let indices = super::panelcmds::artboard_indices(&s.doc()?.doc, p, "artboard.move")?;
|
||||
let dv = Vec2::new(f64_or(p, "dx", 0.0), f64_or(p, "dy", 0.0));
|
||||
let art = super::panelcmds::artboard_art(s, i, bool_or(p, "moveArt", false))?;
|
||||
let move_art = bool_or(p, "moveArt", false);
|
||||
// Each artboard's art, an object counted once (artboards can overlap).
|
||||
let mut arts: Vec<(usize, Vec<NodeId>)> = vec![];
|
||||
for &i in &indices {
|
||||
let art = super::panelcmds::artboard_art(s, i, move_art)?;
|
||||
let art = art.into_iter().filter(|id| !arts.iter().any(|(_, a)| a.contains(id))).collect();
|
||||
arts.push((i, art));
|
||||
}
|
||||
let scale_strokes = s.prefs.scale_strokes;
|
||||
let copy = bool_or(p, "copy", false);
|
||||
let (index, moved) = s.edit(if copy { "Duplicate Artboard" } else { "Move Artboard" }, |d, _| {
|
||||
// A copy keeps the artboard (and its art) where they are and moves duplicates instead.
|
||||
if copy {
|
||||
return super::panelcmds::copy_artboard(d, i, dv, &art, scale_strokes);
|
||||
}
|
||||
let a = d.artboards.get_mut(i).ok_or_else(|| EngineError::Other("no such artboard".into()))?;
|
||||
a.rect = a.rect + dv;
|
||||
let id = a.id;
|
||||
d.move_artboard_guides(id, dv);
|
||||
for id in &art {
|
||||
if let Some(n) = d.node_mut(*id) {
|
||||
n.transform(Affine::translate(dv), scale_strokes);
|
||||
let (mut index, mut moved) = (vec![], vec![]);
|
||||
for (i, art) in &arts {
|
||||
// A copy keeps the artboard (and its art) where they are and moves duplicates instead.
|
||||
if copy {
|
||||
let (at, copies) = super::panelcmds::copy_artboard(d, *i, dv, art, scale_strokes)?;
|
||||
index.push(at);
|
||||
moved.extend(copies);
|
||||
continue;
|
||||
}
|
||||
let a = d.artboards.get_mut(*i).ok_or_else(|| EngineError::Other("no such artboard".into()))?;
|
||||
a.rect = a.rect + dv;
|
||||
let id = a.id;
|
||||
d.move_artboard_guides(id, dv);
|
||||
for id in art {
|
||||
if let Some(n) = d.node_mut(*id) {
|
||||
n.transform(Affine::translate(dv), scale_strokes);
|
||||
}
|
||||
}
|
||||
index.push(*i);
|
||||
moved.extend(art.iter().copied());
|
||||
}
|
||||
Ok((i, art.clone()))
|
||||
Ok((index, moved))
|
||||
})?;
|
||||
Ok(json!({ "index": index, "moved": moved.iter().map(|i| i.0).collect::<Vec<_>>() }))
|
||||
Ok(json!({ "index": index.first(), "indices": index, "moved": moved.iter().map(|i| i.0).collect::<Vec<_>>() }))
|
||||
}
|
||||
@@ -627,3 +627,32 @@ fn distort_rejects_malformed_coordinates_and_ids_without_editing_art() {
|
||||
s.execute("object.distort", &json!({"corners": corners, "from": [0, 0, 100, 100], "ids": [a.0]})).unwrap();
|
||||
assert_eq!(undo_len(&s), history + 1);
|
||||
}
|
||||
|
||||
/// `artboard.move` and `artboard.delete` take several artboards at once (#1139): one undo step
|
||||
/// each, the art on each moving with it, and one artboard always left.
|
||||
#[test]
|
||||
fn several_artboards_move_and_delete_together() {
|
||||
let mut s = session();
|
||||
s.execute("artboard.new", &json!({"x": 1000, "y": 0, "width": 200, "height": 200})).unwrap();
|
||||
s.execute("artboard.new", &json!({"x": 1400, "y": 0, "width": 200, "height": 200})).unwrap();
|
||||
let r = rect(&mut s, 1050.0, 50.0, 50.0, 50.0);
|
||||
let before: Vec<Rect> = s.doc().unwrap().doc.artboards.iter().map(|a| a.rect).collect();
|
||||
let out = s.execute("artboard.move", &json!({"indices": [2, 1], "dx": 10, "dy": 20, "moveArt": true})).unwrap();
|
||||
assert_eq!(out["indices"], json!([1, 2]));
|
||||
let d = &s.doc().unwrap().doc;
|
||||
assert_eq!(d.artboards[0].rect, before[0]);
|
||||
assert_eq!(
|
||||
(d.artboards[1].rect, d.artboards[2].rect),
|
||||
(before[1] + vectorcraft_geom::Vec2::new(10.0, 20.0), before[2] + vectorcraft_geom::Vec2::new(10.0, 20.0))
|
||||
);
|
||||
assert_eq!(d.node(r).unwrap().geometric_bounds().unwrap().x0, 1060.0, "the art on artboard 2 moved with it");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().doc.artboards[2].rect, before[2], "one undo step");
|
||||
for bad in [json!({"indices": [1, 9]}), json!({"indices": []}), json!({"indices": [-1]}), json!({"indices": "1"})] {
|
||||
assert!(s.execute("artboard.move", &json!({"indices": bad["indices"], "dx": 1})).is_err(), "{bad}");
|
||||
}
|
||||
assert!(s.execute("artboard.delete", &json!({"indices": [0, 1, 2]})).is_err(), "one is always left");
|
||||
s.execute("artboard.delete", &json!({"indices": [2, 0]})).unwrap();
|
||||
let d = &s.doc().unwrap().doc;
|
||||
assert_eq!(d.artboards.iter().map(|a| a.rect).collect::<Vec<_>>(), [before[1]]);
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
//! Artboard tool (Shift+O).
|
||||
//!
|
||||
//! Click an artboard to make it active (dashed bounds, 8 handles and its name). Drag inside moves
|
||||
//! Click 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,
|
||||
@@ -26,6 +28,8 @@ enum Drag {
|
||||
/// 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,
|
||||
@@ -48,6 +52,8 @@ enum Drag {
|
||||
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
|
||||
@@ -69,6 +75,7 @@ impl Default for ArtboardTool {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
active: 0,
|
||||
others: vec![],
|
||||
move_art: true,
|
||||
scale_art: false,
|
||||
drag: None,
|
||||
@@ -90,6 +97,31 @@ impl ArtboardTool {
|
||||
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) {
|
||||
@@ -199,15 +231,24 @@ impl Tool for ArtboardTool {
|
||||
if let Some(i) = cx.doc.artboard_at(p)
|
||||
&& let Some(a) = cx.doc.artboards.get(i)
|
||||
{
|
||||
self.active = i;
|
||||
self.drag = Some(Drag::Move { index: i, start: p, rect: a.rect, began: false, copy: false });
|
||||
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, start, rect, began, copy })) => {
|
||||
(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 {
|
||||
@@ -221,13 +262,14 @@ impl Tool for ArtboardTool {
|
||||
out.push(Action::Cancel);
|
||||
out.push(Action::Begin(label(m.alt).into()));
|
||||
}
|
||||
self.drag = Some(Drag::Move { index, start, rect, began: true, copy: m.alt });
|
||||
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);
|
||||
out.push(Action::Preview(
|
||||
"artboard.move".into(),
|
||||
json!({ "index": index, "dx": d.x, "dy": d.y, "moveArt": self.move_art, "copy": m.alt }),
|
||||
));
|
||||
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 })) => {
|
||||
@@ -259,10 +301,14 @@ impl Tool for ArtboardTool {
|
||||
(PointerKind::Up, Some(d)) => {
|
||||
self.end_drag();
|
||||
match d {
|
||||
Drag::Move { began: true, copy, .. } => {
|
||||
// The copy, last in the previewed document, becomes the active artboard.
|
||||
Drag::Move { began: true, copy, many, .. } => {
|
||||
// The copies, last in the previewed document, become the selection (the
|
||||
// last one active).
|
||||
if copy {
|
||||
self.active = cx.doc.artboards.len().saturating_sub(1);
|
||||
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]
|
||||
}
|
||||
@@ -300,7 +346,7 @@ impl Tool for ArtboardTool {
|
||||
fn claims_key(&self, cx: &ToolContext, key: ToolKey) -> bool {
|
||||
matches!(key, ToolKey::Delete | ToolKey::Backspace)
|
||||
&& self.drag.is_none()
|
||||
&& cx.doc.artboards.len() > 1
|
||||
&& cx.doc.artboards.len() > self.selected(cx).len()
|
||||
&& self.active < cx.doc.artboards.len()
|
||||
}
|
||||
|
||||
@@ -310,9 +356,14 @@ impl Tool for ArtboardTool {
|
||||
if !self.claims_key(cx, key) {
|
||||
return vec![];
|
||||
}
|
||||
let i = self.active;
|
||||
self.active = i.saturating_sub(1);
|
||||
vec![Action::Exec("artboard.delete".into(), json!({ "index": i }))]
|
||||
let sel = self.selected(cx);
|
||||
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, .. }));
|
||||
@@ -326,6 +377,7 @@ impl Tool for ArtboardTool {
|
||||
fn notify(&mut self, cx: &ToolContext, what: &str) {
|
||||
if what == "created" {
|
||||
self.active = cx.doc.artboards.len().saturating_sub(1);
|
||||
self.others.clear();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -340,6 +392,10 @@ impl Tool for ArtboardTool {
|
||||
}
|
||||
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 });
|
||||
@@ -377,12 +433,24 @@ impl Tool for ArtboardTool {
|
||||
}
|
||||
|
||||
fn options(&self) -> Value {
|
||||
json!({ "active": self.active, "moveArt": self.move_art, "scaleArt": self.scale_art })
|
||||
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,
|
||||
"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),
|
||||
_ => {}
|
||||
@@ -523,6 +591,42 @@ mod tests {
|
||||
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 it can't, so the key isn't taken.
|
||||
assert_eq!(t.key(&c, ToolKey::Delete, Mods::default()), vec![Action::Exec("artboard.delete".into(), json!({"indices": [0, 1]}))]);
|
||||
t.set_option("selected", &json!([0, 1, 2]));
|
||||
assert!(!t.claims_key(&c, ToolKey::Delete));
|
||||
// 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]
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
//! fits it in the window.
|
||||
|
||||
use egui::{Sense, Ui, pos2, vec2};
|
||||
use serde_json::json;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{pstate, set_pstate};
|
||||
use crate::theme::Tokens;
|
||||
@@ -40,6 +40,46 @@ pub(crate) fn selected(app: &VectorcraftApp, n: usize) -> usize {
|
||||
app.view().map_or(0, |v| v.artboard).min(n.saturating_sub(1))
|
||||
}
|
||||
|
||||
/// The selected artboards (of `n`): while the Artboard tool is in use, those it has selected
|
||||
/// (Shift-click, #1139), else the active one.
|
||||
pub(crate) fn picked(app: &VectorcraftApp, n: usize) -> Vec<usize> {
|
||||
let tool = (app.session.tool_id() == "artboard").then(|| app.session.tool_options()["selected"].clone());
|
||||
let mut all: Vec<usize> = tool
|
||||
.iter()
|
||||
.filter_map(Value::as_array)
|
||||
.flatten()
|
||||
.filter_map(Value::as_u64)
|
||||
.filter_map(|i| usize::try_from(i).ok())
|
||||
.filter(|i| *i < n)
|
||||
.collect();
|
||||
if all.is_empty() {
|
||||
all.push(selected(app, n));
|
||||
}
|
||||
all.sort_unstable();
|
||||
all.dedup();
|
||||
all
|
||||
}
|
||||
|
||||
/// Shift- or Ctrl/Cmd-click on artboard `i`'s row while the Artboard tool is in use: into its
|
||||
/// selection or out of it (the active one stays the navigator's).
|
||||
fn toggle(app: &mut VectorcraftApp, i: usize, n: usize) {
|
||||
let mut all = picked(app, n);
|
||||
match all.iter().position(|j| *j == i) {
|
||||
Some(at) if all.len() > 1 => {
|
||||
all.remove(at);
|
||||
}
|
||||
Some(_) => return,
|
||||
None => all.insert(0, i),
|
||||
}
|
||||
let active = if all.contains(&i) { i } else { all.first().copied().unwrap_or(0) };
|
||||
all.retain(|j| *j != active);
|
||||
all.insert(0, active);
|
||||
if let Some(v) = app.view_mut() {
|
||||
v.artboard = active;
|
||||
}
|
||||
app.session.set_tool_option("selected", &json!(all));
|
||||
}
|
||||
|
||||
/// Make artboard `i` the active one, leaving the view where it is: the navigator's and, while it
|
||||
/// is the tool, the Artboard tool's.
|
||||
pub(crate) fn select(app: &mut VectorcraftApp, i: usize) {
|
||||
@@ -72,6 +112,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
return;
|
||||
}
|
||||
let sel = selected(app, abs.len());
|
||||
let all = picked(app, abs.len());
|
||||
let editing: Option<usize> = pstate(ui.ctx(), "ab-edit");
|
||||
widgets::list_box(ui, |ui| {
|
||||
ui.set_min_height(110.0);
|
||||
@@ -82,7 +123,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
if resp.drag_started() {
|
||||
egui::DragAndDrop::set_payload(ui.ctx(), RowDrag(i));
|
||||
}
|
||||
if i == sel {
|
||||
if all.contains(&i) {
|
||||
ui.painter().rect_filled(r, 0.0, t.row_selected);
|
||||
} else if resp.hovered() {
|
||||
ui.painter().rect_filled(r, 0.0, t.hover);
|
||||
@@ -120,7 +161,10 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
app.select_tool("artboard");
|
||||
select(app, i);
|
||||
}
|
||||
if resp.clicked() {
|
||||
let several = ui.input(|inp| inp.modifiers.shift || inp.modifiers.command);
|
||||
if resp.clicked() && several && app.session.tool_id() == "artboard" {
|
||||
toggle(app, i, abs.len());
|
||||
} else if resp.clicked() {
|
||||
select(app, i);
|
||||
}
|
||||
// Double-clicking the number goes to the artboard (fits it in the window); the name
|
||||
@@ -159,8 +203,10 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
} else if new.clicked() && app.run("artboard.new", json!({})).is_ok() {
|
||||
select(app, n);
|
||||
}
|
||||
if widgets::icon_button_enabled(ui, "trash-2", tl!("Delete Artboard"), false, n > 1, 24.0).clicked() {
|
||||
app.run("artboard.delete", json!({"index": sel})).ok();
|
||||
if widgets::icon_button_enabled(ui, "trash-2", tl!("Delete Artboard"), false, n > all.len(), 24.0).clicked()
|
||||
&& app.run("artboard.delete", json!({ "indices": all })).is_ok()
|
||||
{
|
||||
select(app, all.first().copied().unwrap_or(0).saturating_sub(1));
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -255,6 +301,49 @@ mod tests {
|
||||
assert_eq!(picked(&mut app, json!({})), json!([a]));
|
||||
assert_eq!(picked(&mut app, json!({"artboard": 1})), json!([b]));
|
||||
}
|
||||
/// With the Artboard tool, Shift-click on a row adds its artboard to the tool's selection or
|
||||
/// takes it out (#1139), and every selected row is highlighted.
|
||||
#[test]
|
||||
fn shift_clicking_rows_selects_several_artboards() {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.session.execute("file.new", &json!({"width": 200, "height": 150})).unwrap();
|
||||
for x in [400, 800] {
|
||||
app.session.execute("artboard.new", &json!({"x": x, "y": 0, "width": 200, "height": 150})).unwrap();
|
||||
}
|
||||
app.select_tool("artboard");
|
||||
select(&mut app, 0);
|
||||
let ctx = egui::Context::default();
|
||||
let screen = Some(egui::Rect::from_min_size(egui::Pos2::ZERO, vec2(240.0, 400.0)));
|
||||
let frame = |app: &mut VectorcraftApp, time: f64, events: Vec<egui::Event>| {
|
||||
let raw = egui::RawInput { events, time: Some(time), screen_rect: screen, ..Default::default() };
|
||||
let mut out = ctx.run_ui(raw, |ui| show(app, ui));
|
||||
out.textures_delta.clear();
|
||||
out.shapes
|
||||
};
|
||||
let shapes = frame(&mut app, 0.0, vec![]);
|
||||
let third = shapes
|
||||
.iter()
|
||||
.find_map(|c| match &c.shape {
|
||||
egui::Shape::Text(t) if t.galley.text() == "Artboard 3" => Some(t.pos + vec2(4.0, 4.0)),
|
||||
_ => None,
|
||||
})
|
||||
.unwrap();
|
||||
let click = |at: egui::Pos2| {
|
||||
let b =
|
||||
|pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::SHIFT };
|
||||
vec![egui::Event::ModifiersChanged(egui::Modifiers::SHIFT), egui::Event::PointerMoved(at), b(true), b(false)]
|
||||
};
|
||||
frame(&mut app, 1.0, click(third));
|
||||
assert_eq!(picked(&app, 3), [0, 2]);
|
||||
let rows = frame(&mut app, 2.0, vec![egui::Event::ModifiersChanged(egui::Modifiers::NONE)])
|
||||
.iter()
|
||||
.filter(|c| matches!(&c.shape, egui::Shape::Rect(r) if r.fill == Tokens::get(&ctx).row_selected))
|
||||
.count();
|
||||
assert_eq!(rows, 2, "both rows highlighted");
|
||||
frame(&mut app, 3.0, click(third));
|
||||
assert_eq!(picked(&app, 3), [0]);
|
||||
}
|
||||
|
||||
/// Double-clicking a row's number goes to its artboard and its name renames it; a row's
|
||||
/// Options button edits that artboard with the Artboard tool, and the tool's artboard is the
|
||||
/// active one.
|
||||
|
||||
+3
-1
@@ -1,6 +1,6 @@
|
||||
# UI and interaction parity
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (Edit Toolbar: the All Tools drawer beside the toolbar, tools dragged in and out, #1018; earlier: Direct Selection whole-path drags and Alt-drag copies, #1104) · **Target:** Adobe Illustrator 2026 (30.x)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (several artboards selected at once, #1139; earlier: the Edit Toolbar drawer, #1018) · **Target:** Adobe Illustrator 2026 (30.x)
|
||||
|
||||
How VectorCraft's tools, handles, snapping and panels behave next to Illustrator's. A vector illustrator's
|
||||
trust is in the details: where a handle lands, what Shift and Alt do mid-drag, what a smart guide snaps to,
|
||||
@@ -126,6 +126,7 @@ tools, never checked side by side) isn't captured row by row.
|
||||
| Space: Hand; Cmd+Space / Cmd+Alt+Space: zoom in/out; middle button pans | done | |
|
||||
| Cmd/Ctrl: last-used selection tool; Cmd+Alt: Group Selection inside Direct Selection | partial | |
|
||||
| Alt-drag duplicates (objects, artboards with their art) | done | |
|
||||
| Shift-click selects several artboards (Artboard tool, Artboards panel); they move, Alt-copy and delete together | done | `artboard.move`/`artboard.delete` `indices` (#1139) |
|
||||
| Shape tools: Shift constrains, Alt from centre, Space moves while dragging, arrows change sides/points/rows | done | |
|
||||
| Tool double-click opens its options | done | |
|
||||
| Per-platform mapping (Cmd ↔ Ctrl, Option ↔ Alt) | done | |
|
||||
@@ -168,6 +169,7 @@ re-measure of the panels added since.
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-11 | minor | Artboards: Shift-click selects several, which move, copy and delete together (#1139); score unchanged (estimated) |
|
||||
| 2026-10-11 | minor | Edit Toolbar opens the All Tools drawer beside the toolbar instead of a centred dialog, dims the tools the toolbar holds, and edits the toolbar by drag and drop while it is open (#1018); score unchanged (estimated) |
|
||||
| 2026-10-11 | minor | Direct Selection: dragging a path selected whole by an anchor moves it whole; Alt-drag copies by an anchor too, toggles during the drag and snaps onto the original (#1104); score unchanged (estimated) |
|
||||
| 2026-10-10 | major | First checklist, built from the tool sources and tests against Illustrator 2026's public documentation |
|
||||
Reference in new issue
Block a user