diff --git a/ROADMAP.md b/ROADMAP.md
index 0b5e4b9b..282cdb91 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -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 Distribute Spacing keep the key object still also when it is inside a selected group or compound path (#1074). A `.ai` whose layers can't be read yet still opens with its artboards where they were (in a grid, say), not in a row (#1068). Layer Options (`ui.layerOptions`) refuses a malformed or unknown row id instead of skipping it or opening on the highlighted rows (#1077). Shaper scribbles given a list of object ids refuse a malformed or unknown id instead of skipping it and editing the shapes that remain (#1078). Delete Width Point (`stroke.widthPoint.remove`) refuses a list of indices with a malformed entry instead of deleting the points that remain, and no longer wraps a huge index round on the web (#1079). Preferences' checkboxes have an outline when the pointer isn't over them, in every interface brightness, as the app's other dialogs' checkboxes do (#1080). The last plain checkboxes (Properties panel snapping and Scale Strokes & Effects, Revolve, Save options, Export for Screens, Variables, plug-in forms) keep a visible box at rest in every theme too (#885). Setting or copying a width point with a malformed index is refused instead of adding a new point or wrapping a huge index round (follow-up to #1079). An Illustrator EPS opened through its layers keeps its own page size and the red outline of text stacked over a dark one, as 0.8.0 did (#1102). The macOS DMG is built so the app in it passes `codesign --verify --strict --deep`, and the release checks that it does (#1085). Swatch and graphic style libraries, Libraries panel files, presets and plug-ins dropped on the window open as File › Open opens them instead of being placed on the canvas (#1083). A key object needs another selected object outside it: a change that leaves one object selected lets the key go, and one selected object aligns to the artboard whatever Align To says (#1088). Align to Artboard aligns to the active artboard, and actions play their Align to Artboard, All on Active Artboard and Paste in Place, in Front and in Back steps on the artboard active when they play (#1105). Align refuses a call with neither direction, and Align, Distribute, Reflect and Shear refuse a direction, axis, target or bounds they don't take instead of falling back to a default (#1106). `object.setProps` deselects the objects it hides or locks, the key object included, as the Layers panel's eye and lock do (#1107). Align, Distribute and Distribute Spacing move pattern tiles with the art when General › Transform Pattern Tiles is on (#1108). Object › Lock › All Artwork Above and Object › Hide › All Artwork Above deselect the selected objects they lock or hide, the key object included (#1109). Save Swatch Library's `count` is the number of swatches the file holds: `.gpl` and `.ase` hold solid colors only, and CSS leaves freeform gradients out (#1110). In a rotated view (Rotate View), an artboard's name stays at its own top-left corner (#1095). Ctrl+Shift+L, C, R, J and F align or justify the paragraphs of the selected type, or those the Type tool's selection touches (#1111). On macOS, file dialogs are sheets on the document window, so resizing one no longer crashes the app; while one is open the menus, Quit and Close wait, and its answer goes to the document that asked (#867). Shape Builder merges regions that crossing lines cut along the lines' own edges, so a strip merged across a grid of lines is one outline with no seams left inside (#1081). Proportional Metrics also works in vertical type: upright glyphs take the font's proportional heights (`vpal`), and Line-end Punctuation Half Width takes nothing more off the punctuation it re-spaced (#1082). Plug-in whole-number parameters keep their exact value however large, and stay in the range the manifest gives (#1091). PDF and `.ai` import keeps type spaced glyph by glyph (character tightening, optical kerning) with each glyph in its place, giving each character its own tracking when the file's font is installed, and CJK punctuation closed up by its proportional width continues its line (#1101). Direct Selection: pressing an anchor of a path selected whole keeps all its anchors selected, so the drag moves them all; Alt-dragging by an anchor copies the whole path, held by that anchor, which snaps onto other anchors and onto the original the copy leaves behind; Alt pressed or released during the drag turns the copy on and off, as with the Selection tool, whose copies snap to their original too, and the undo step is named Copy or Move for what the drag did (#1104). The toolbar's Fill/Stroke proxy and Color, Gradient and None buttons grow in two columns, where the three drawing modes sit side by side (#1129). An image copied in a browser that puts its `
` markup on the clipboard as text pastes as the picture, not the markup (#1089). `file.place` of an SVG with `crop: "crop"` frames and clips it by its viewBox, so art drawn on a shared canvas keeps its place and scale (#788). While a star is dragged, Ctrl/Cmd keeps its inner radius as the outer one grows and Alt keeps its shoulders straight (#812). Edit Toolbar opens the All Tools drawer beside the toolbar, dims the tools already in it, and adds, removes and moves tools by drag and drop while it is open (#1018). The Selection tool's arrow is black and Direct Selection's white in every theme, as users know them: in the dark themes they looked swapped. The font menu keeps its highlight on the same font when the list changes under it (a family's styles listed above it, fonts that finish loading, a favourite unstarred), and ends the live preview when that font leaves the list (#1116). The Windows installer has a Shortcuts page with a ticked "Create a desktop shortcut" box, and a silent install adds the shortcut unless `INSTALLDESKTOPSHORTCUT=""` is passed (#1128). Image Trace's Noise merges specks until none under its size is left beside another colour, so a higher Noise never gives more paths (#1130). `cargo clippy -D warnings` passes for the wasm32 web build (#1132). Gradient stops with opacity blend colour and opacity apart on the canvas, as Illustrator and the PDF and SVG exports do: a transparent stop's colour still tints the fade (#1142). An SVG mask whose art is all hidden imports as an empty opacity mask, so its object stays hidden as browsers draw it (#1143). |
+| 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 Distribute Spacing keep the key object still also when it is inside a selected group or compound path (#1074). A `.ai` whose layers can't be read yet still opens with its artboards where they were (in a grid, say), not in a row (#1068). Layer Options (`ui.layerOptions`) refuses a malformed or unknown row id instead of skipping it or opening on the highlighted rows (#1077). Shaper scribbles given a list of object ids refuse a malformed or unknown id instead of skipping it and editing the shapes that remain (#1078). Delete Width Point (`stroke.widthPoint.remove`) refuses a list of indices with a malformed entry instead of deleting the points that remain, and no longer wraps a huge index round on the web (#1079). Preferences' checkboxes have an outline when the pointer isn't over them, in every interface brightness, as the app's other dialogs' checkboxes do (#1080). The last plain checkboxes (Properties panel snapping and Scale Strokes & Effects, Revolve, Save options, Export for Screens, Variables, plug-in forms) keep a visible box at rest in every theme too (#885). Setting or copying a width point with a malformed index is refused instead of adding a new point or wrapping a huge index round (follow-up to #1079). An Illustrator EPS opened through its layers keeps its own page size and the red outline of text stacked over a dark one, as 0.8.0 did (#1102). The macOS DMG is built so the app in it passes `codesign --verify --strict --deep`, and the release checks that it does (#1085). Swatch and graphic style libraries, Libraries panel files, presets and plug-ins dropped on the window open as File › Open opens them instead of being placed on the canvas (#1083). A key object needs another selected object outside it: a change that leaves one object selected lets the key go, and one selected object aligns to the artboard whatever Align To says (#1088). Align to Artboard aligns to the active artboard, and actions play their Align to Artboard, All on Active Artboard and Paste in Place, in Front and in Back steps on the artboard active when they play (#1105). Align refuses a call with neither direction, and Align, Distribute, Reflect and Shear refuse a direction, axis, target or bounds they don't take instead of falling back to a default (#1106). `object.setProps` deselects the objects it hides or locks, the key object included, as the Layers panel's eye and lock do (#1107). Align, Distribute and Distribute Spacing move pattern tiles with the art when General › Transform Pattern Tiles is on (#1108). Object › Lock › All Artwork Above and Object › Hide › All Artwork Above deselect the selected objects they lock or hide, the key object included (#1109). Save Swatch Library's `count` is the number of swatches the file holds: `.gpl` and `.ase` hold solid colors only, and CSS leaves freeform gradients out (#1110). In a rotated view (Rotate View), an artboard's name stays at its own top-left corner (#1095). Ctrl+Shift+L, C, R, J and F align or justify the paragraphs of the selected type, or those the Type tool's selection touches (#1111). On macOS, file dialogs are sheets on the document window, so resizing one no longer crashes the app; while one is open the menus, Quit and Close wait, and its answer goes to the document that asked (#867). Shape Builder merges regions that crossing lines cut along the lines' own edges, so a strip merged across a grid of lines is one outline with no seams left inside (#1081). Proportional Metrics also works in vertical type: upright glyphs take the font's proportional heights (`vpal`), and Line-end Punctuation Half Width takes nothing more off the punctuation it re-spaced (#1082). Plug-in whole-number parameters keep their exact value however large, and stay in the range the manifest gives (#1091). PDF and `.ai` import keeps type spaced glyph by glyph (character tightening, optical kerning) with each glyph in its place, giving each character its own tracking when the file's font is installed, and CJK punctuation closed up by its proportional width continues its line (#1101). Direct Selection: pressing an anchor of a path selected whole keeps all its anchors selected, so the drag moves them all; Alt-dragging by an anchor copies the whole path, held by that anchor, which snaps onto other anchors and onto the original the copy leaves behind; Alt pressed or released during the drag turns the copy on and off, as with the Selection tool, whose copies snap to their original too, and the undo step is named Copy or Move for what the drag did (#1104). The toolbar's Fill/Stroke proxy and Color, Gradient and None buttons grow in two columns, where the three drawing modes sit side by side (#1129). An image copied in a browser that puts its `
` markup on the clipboard as text pastes as the picture, not the markup (#1089). `file.place` of an SVG with `crop: "crop"` frames and clips it by its viewBox, so art drawn on a shared canvas keeps its place and scale (#788). While a star is dragged, Ctrl/Cmd keeps its inner radius as the outer one grows and Alt keeps its shoulders straight (#812). Edit Toolbar opens the All Tools drawer beside the toolbar, dims the tools already in it, and adds, removes and moves tools by drag and drop while it is open (#1018). The Selection tool's arrow is black and Direct Selection's white in every theme, as users know them: in the dark themes they looked swapped. The font menu keeps its highlight on the same font when the list changes under it (a family's styles listed above it, fonts that finish loading, a favourite unstarred), and ends the live preview when that font leaves the list (#1116). The Windows installer has a Shortcuts page with a ticked "Create a desktop shortcut" box, and a silent install adds the shortcut unless `INSTALLDESKTOPSHORTCUT=""` is passed (#1128). Image Trace's Noise merges specks until none under its size is left beside another colour, so a higher Noise never gives more paths (#1130). `cargo clippy -D warnings` passes for the wasm32 web build (#1132). Gradient stops with opacity blend colour and opacity apart on the canvas, as Illustrator and the PDF and SVG exports do: a transparent stop's colour still tints the fade (#1142). An SVG mask whose art is all hidden imports as an empty opacity mask, so its object stays hidden as browsers draw it (#1143). Shift-click with the Artboard tool, or in the Artboards panel, selects several artboards, which then move, duplicate (Alt-drag) and delete together (#1139). |
| 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 |
diff --git a/crates/engine/src/cmd/layer.rs b/crates/engine/src/cmd/layer.rs
index c866bf83..81d4d4e4 100644
--- a/crates/engine/src/cmd/layer.rs
+++ b/crates/engine/src/cmd/layer.rs
@@ -129,7 +129,15 @@ pub fn specs() -> Vec {
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 {
}
fn artboard_delete(s: &mut Session, p: &Value) -> Result {
- 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()
diff --git a/crates/engine/src/cmd/panelcmds.rs b/crates/engine/src/cmd/panelcmds.rs
index cd77e4a5..ce207c47 100644
--- a/crates/engine/src/cmd/panelcmds.rs
+++ b/crates/engine/src/cmd/panelcmds.rs
@@ -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> {
+ 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> {
let d = &s.doc()?.doc;
let rect = d.artboards.get(i).map(|a| a.rect).ok_or_else(|| EngineError::Other("no such artboard".into()))?;
diff --git a/crates/engine/src/cmd/xform.rs b/crates/engine/src/cmd/xform.rs
index 8b7d286b..b7168688 100644
--- a/crates/engine/src/cmd/xform.rs
+++ b/crates/engine/src/cmd/xform.rs
@@ -45,7 +45,7 @@ pub fn specs() -> Vec {
"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 {
// ---------- artboards ----------
fn artboard_move(s: &mut Session, p: &Value) -> Result {
- 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)> = 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::>() }))
+ Ok(json!({ "index": index.first(), "indices": index, "moved": moved.iter().map(|i| i.0).collect::>() }))
}
diff --git a/crates/engine/src/tests_xform.rs b/crates/engine/src/tests_xform.rs
index 4984efec..9ad19989 100644
--- a/crates/engine/src/tests_xform.rs
+++ b/crates/engine/src/tests_xform.rs
@@ -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 = 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::>(), [before[1]]);
+}
diff --git a/crates/tools/src/xform/artboard.rs b/crates/tools/src/xform/artboard.rs
index 6bfe21c7..a6c9907d 100644
--- a/crates/tools/src/xform/artboard.rs
+++ b/crates/tools/src/xform/artboard.rs
@@ -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,
/// 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 {
+ let mut all: Vec = 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::>(), "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 =
+ 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]
diff --git a/crates/ui-egui/src/panels/artboards.rs b/crates/ui-egui/src/panels/artboards.rs
index ab99fbaa..00d5b36c 100644
--- a/crates/ui-egui/src/panels/artboards.rs
+++ b/crates/ui-egui/src/panels/artboards.rs
@@ -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 {
+ let tool = (app.session.tool_id() == "artboard").then(|| app.session.tool_options()["selected"].clone());
+ let mut all: Vec = 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 = 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| {
+ 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.
diff --git a/docs/ui-parity.md b/docs/ui-parity.md
index 12535604..10c2b2ad 100644
--- a/docs/ui-parity.md
+++ b/docs/ui-parity.md
@@ -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 |