Artboards: the Artboards panel's Delete Empty Artboards deletes every artboard with no artwork in one undo step

Squashed from #4142 onto main.
This commit is contained in:
Taha-Mahmoodi
2026-10-26 14:10:36 +07:00
committed by bflatastic
parent 0b5affef5e
commit a34055beec
32 changed files with 301 additions and 16 deletions
+34
View File
@@ -1843,6 +1843,40 @@ impl Document {
}
art
}
/// Does any artwork lie on `rect` (an artboard)? Artwork is any object under the layers,
/// hidden and locked ones and those on template layers included, whose visual bounds (symbol
/// instances measured by their art) meet `rect`; touching its edge counts. A group (not a clip
/// group) counts by its members, so one whose members sit on two other artboards leaves the one
/// between them empty. A clip group or clipping layer counts by its members inside its clip (and
/// by its clipping path where that paints). Guides (paths made into guides too) aren't artwork
/// (Delete Empty Artboards).
pub fn artboard_has_art(&self, rect: Rect) -> bool {
// As in `art_on_artboard`: a millionth of a point covers rounding at the edges.
let rect = rect.inflate(1e-6, 1e-6);
fn meets(b: Option<Rect>, r: Rect) -> bool {
b.is_some_and(|b| b.x0 <= r.x1 && r.x0 <= b.x1 && b.y0 <= r.y1 && r.y0 <= b.y1)
}
fn has(c: &Node, s: &SymbolBoxes, rect: Rect) -> bool {
match &c.kind {
NodeKind::Group { children, clip: true } | NodeKind::Layer { children, clip: true, .. } => {
let Some((clip, rest)) = children.split_first() else { return false };
let paint = clip.clip_paint();
if (paint.fill.is_some() || paint.stroke.is_some()) && meets(clip.visual_bounds_with(s), rect) {
return true;
}
// The members show only inside the clip's box.
let Some(b) = c.visual_bounds_with(s).filter(|b| meets(Some(*b), rect)) else { return false };
let inside = Rect::new(b.x0.max(rect.x0), b.y0.max(rect.y0), b.x1.min(rect.x1), b.y1.min(rect.y1));
rest.iter().any(|m| has(m, s, inside))
}
NodeKind::Layer { children, .. } | NodeKind::Group { children, .. } => children.iter().any(|m| has(m, s, rect)),
NodeKind::Path { guide: true, .. } => false,
_ => meets(c.visual_bounds_with(s), rect),
}
}
let symbols = self.symbol_boxes();
self.layers.iter().any(|l| has(l, &symbols, rect))
}
/// The art that moves, scales or is cut with artboard `index`: [`Document::art_on_artboard`]
/// without the objects on another locked artboard, which stay unless `locked_and_hidden`, as
/// locked art does.
+49 -12
View File
@@ -138,6 +138,15 @@ pub fn specs() -> Vec<CommandSpec> {
has_doc,
artboard_delete
),
cmd!(
"artboard.deleteEmpty",
"Delete Empty Artboards",
["Window", "Artboards"],
None,
"{} delete every artboard with no artwork on it (no object, hidden, locked and template art included, whose visual bounds meet or touch it, clip groups and clipping layers by their members inside the clip; guides don't count), with its guides, in one step; locked artboards stay; when every artboard is empty and none is locked, artboard 1 stays (with a locked one left, artboard 1 can go) → {deleted: [index…]} (the indices before deleting)",
has_empty_artboards,
artboard_delete_empty
),
cmd!(
"artboard.setProps",
"Artboard Options…",
@@ -863,21 +872,49 @@ fn artboard_new(s: &mut Session, p: &Value) -> Result<Value> {
fn artboard_delete(s: &mut Session, p: &Value) -> Result<Value> {
let indices = super::panelcmds::artboard_indices(&s.doc()?.doc, p, "artboard.delete")?;
super::panelcmds::refuse_locked(&s.doc()?.doc, &indices)?;
s.edit("Delete Artboard", |d, sel| {
if indices.len() >= d.artboards.len() {
return Err(EngineError::Other("a document needs at least one 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.retire_artboard_id(id);
d.retain_guides(sel, |_, g| g.artboard != Some(id));
}
Ok(())
})?;
s.edit("Delete Artboard", |d, sel| remove_artboards(d, sel, &indices))?;
ok()
}
/// Delete artboards `indices` (sorted) of `d` with their guides; at least one must be left.
fn remove_artboards(d: &mut Document, sel: &mut vectorcraft_doc::Selection, indices: &[usize]) -> Result<()> {
if indices.len() >= d.artboards.len() {
return Err(EngineError::Other("a document needs at least one 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.retire_artboard_id(id);
d.retain_guides(sel, |_, g| g.artboard != Some(id));
}
Ok(())
}
/// The artboards Delete Empty Artboards deletes: those with no artwork
/// ([`Document::artboard_has_art`]) that aren't locked, but artboard 1 when every artboard is one
/// of them (a document keeps one, as Delete Artboards keeps artboard 1).
pub(crate) fn empty_artboards(d: &Document) -> Vec<usize> {
let mut empty: Vec<usize> = d.artboards.iter().enumerate().filter(|(_, a)| !a.locked && !d.artboard_has_art(a.rect)).map(|(i, _)| i).collect();
if empty.len() == d.artboards.len() {
empty.retain(|&i| i != 0);
}
empty
}
fn has_empty_artboards(s: &Session) -> std::result::Result<(), String> {
let st = s.active().ok_or("no document open")?;
if empty_artboards(&st.doc).is_empty() { Err("no empty artboards to delete".into()) } else { Ok(()) }
}
fn artboard_delete_empty(s: &mut Session, _: &Value) -> Result<Value> {
let indices = empty_artboards(&s.doc()?.doc);
if indices.is_empty() {
return Err(EngineError::Other("no empty artboards to delete".into()));
}
s.edit("Delete Empty Artboards", |d, sel| remove_artboards(d, sel, &indices))?;
Ok(json!({ "deleted": indices }))
}
/// One artboard's part of `artboard.setProps`: its index, new rectangle, name, color, lock,
/// display marks (Show Center Mark, Show Cross Hairs, Show Video Safe Areas) and Video Ruler Pixel
/// Aspect Ratio, how far it moved, whether it was resized, the map its art and guides take when
+119
View File
@@ -1144,3 +1144,122 @@ fn stroke_advanced_scale_swap_flip() {
assert!(s.execute("stroke.setAdvanced", &json!({})).is_err());
assert!(s.execute("stroke.setAdvanced", &json!({"flipProfile": "sideways"})).is_err());
}
/// A document of `n` 200 × 150 artboards in a row, 400 pt apart (`x` = 400·i), each with a
/// guide of its own.
fn row_of_artboards(n: usize) -> Session {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 200, "height": 150})).unwrap();
for i in 1..n {
s.execute("artboard.new", &json!({"x": 400 * i, "y": 0, "width": 200, "height": 150})).unwrap();
}
for i in 0..n {
s.execute("guide.add", &json!({"vertical": true, "pos": 400 * i + 50, "artboard": i})).unwrap();
}
s
}
fn box_at(s: &mut Session, x: f64, w: f64) -> NodeId {
NodeId(s.execute("shape.rectangle", &json!({"x": x, "y": 10, "width": w, "height": 20})).unwrap()["id"].as_u64().unwrap())
}
fn artboard_names(s: &Session) -> Vec<String> {
s.doc().unwrap().doc.artboards.iter().map(|a| a.name.clone()).collect()
}
/// Artboards panel › Delete Empty Artboards: with art only on artboard 2, artboards 1 and 3 go
/// with their guides in one undo step, the art and its ids untouched; Undo brings them back.
#[test]
fn delete_empty_artboards_deletes_those_with_no_art_in_one_step() {
let mut s = row_of_artboards(3);
let art = box_at(&mut s, 450.0, 20.0);
let before = s.doc().unwrap().doc.clone();
let steps = s.doc().unwrap().history.undo.len();
assert_eq!(s.execute("artboard.deleteEmpty", &json!({})).unwrap(), json!({"deleted": [0, 2]}));
let st = s.doc().unwrap();
assert_eq!(artboard_names(&s), ["Artboard 2"]);
assert_eq!(st.doc.guides.len(), 1, "the deleted artboards' guides go with them");
assert_eq!(st.doc.node(art).map(|n| n.geometric_bounds()), before.node(art).map(|n| n.geometric_bounds()), "the art stays");
assert_eq!(st.history.undo.len(), steps + 1);
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(artboard_names(&s), ["Artboard 1", "Artboard 2", "Artboard 3"]);
assert_eq!(s.doc().unwrap().doc.guides.len(), 3);
}
/// What counts as art: hidden and locked objects, an object straddling two artboards and one
/// that only touches an artboard's edge; a group counts by its members, not by its box.
#[test]
fn delete_empty_artboards_keeps_artboards_with_hidden_locked_or_shared_art() {
let mut s = row_of_artboards(5);
// Hidden art on 1, locked art on 2.
box_at(&mut s, 10.0, 20.0);
s.execute("object.hide", &json!({})).unwrap();
box_at(&mut s, 410.0, 20.0);
s.execute("object.lock", &json!({})).unwrap();
// Straddling 3 (x 800–1000) and 4 (x 1200–1400).
box_at(&mut s, 900.0, 350.0);
// A group of a box on 3 and one whose right edge touches 6's left edge (x 2000): its box spans
// 5 (x 1600–1800), which stays empty, and 6 has art only by the touch.
s.execute("artboard.new", &json!({"x": 2000, "y": 0, "width": 200, "height": 150})).unwrap();
let a = box_at(&mut s, 850.0, 20.0);
let b = box_at(&mut s, 1980.0, 20.0);
// No stroke, so its visual box ends at x 2000.
s.execute("paint.setStroke", &json!({"none": true})).unwrap();
s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap();
s.execute("object.group", &json!({})).unwrap();
assert_eq!(s.execute("artboard.deleteEmpty", &json!({})).unwrap(), json!({"deleted": [4]}));
assert_eq!(artboard_names(&s), ["Artboard 1", "Artboard 2", "Artboard 3", "Artboard 4", "Artboard 6"]);
}
/// A locked empty artboard stays; with every artboard empty artboard 1 stays; with none empty
/// the command is disabled, refuses and adds no undo step.
#[test]
fn delete_empty_artboards_leaves_locked_ones_and_artboard_1() {
let mut s = row_of_artboards(3);
s.execute("artboard.setProps", &json!({"index": 1, "locked": true})).unwrap();
assert_eq!(s.execute("artboard.deleteEmpty", &json!({})).unwrap(), json!({"deleted": [0, 2]}), "all empty, one locked");
assert_eq!(artboard_names(&s), ["Artboard 2"]);
let mut s = row_of_artboards(3);
assert_eq!(s.execute("artboard.deleteEmpty", &json!({})).unwrap(), json!({"deleted": [1, 2]}), "all empty: artboard 1 stays");
assert_eq!(artboard_names(&s), ["Artboard 1"]);
let steps = s.doc().unwrap().history.undo.len();
let spec = crate::cmd::command_specs().iter().find(|c| c.id == "artboard.deleteEmpty").unwrap();
assert!((spec.enabled)(&s).is_err(), "one artboard left: nothing to delete");
assert!(s.execute("artboard.deleteEmpty", &json!({})).is_err());
box_at(&mut s, 10.0, 20.0);
let steps_with_art = s.doc().unwrap().history.undo.len();
assert!(s.execute("artboard.deleteEmpty", &json!({})).is_err(), "nothing empty");
assert_eq!(s.doc().unwrap().history.undo.len(), steps_with_art);
assert!(steps_with_art > steps);
}
/// Art outside a clip shows nowhere: a clip group or a clipping layer has art only where its
/// members show inside the clip, so an artboard that only the clipped-away part of a member
/// covers is empty.
#[test]
fn delete_empty_artboards_measures_clipped_art_through_the_clip() {
// Clip group: the clip on 1, a member running on to 2. Clipping layer: the clip on 3, a member
// running on to 4. Nothing else.
let mut s = row_of_artboards(4);
let long = box_at(&mut s, 50.0, 400.0);
let clip = box_at(&mut s, 10.0, 100.0);
// The front object clips (drawn after the member).
s.execute("select.set", &json!({"ids": [long.0, clip.0]})).unwrap();
s.execute("object.clippingMask.make", &json!({})).unwrap();
s.execute("layer.new", &json!({"name": "Clipped"})).unwrap();
box_at(&mut s, 850.0, 400.0);
// The layer's top object becomes its clipping path.
let layer_clip = box_at(&mut s, 810.0, 100.0);
let layer_of =
|s: &Session| s.doc().unwrap().doc.layers.iter().find(|l| l.children().unwrap().iter().any(|c| c.id == layer_clip)).unwrap().clone();
let layer = layer_of(&s);
assert_eq!(layer.children().unwrap().len(), 2, "both in their own layer");
s.execute("layer.clippingMask.toggle", &json!({"id": layer.id.0})).unwrap();
let layer = layer_of(&s);
assert!(matches!(layer.kind, vectorcraft_doc::NodeKind::Layer { clip: true, .. }));
let first = layer.children().unwrap()[0].id;
assert_eq!(first, layer_clip, "the layer's first object clips the rest");
assert_eq!(s.execute("artboard.deleteEmpty", &json!({})).unwrap(), json!({"deleted": [1, 3]}));
assert_eq!(artboard_names(&s), ["Artboard 1", "Artboard 3"]);
}
+1
View File
@@ -5482,5 +5482,6 @@ axis Y Axis Y ос
Rectangular Grid Tool Options Опции на инструмента „Правоъгълна мрежа“
Polar Grid Tool Options Опции на инструмента „Полярна мрежа“
Fill Line Запълване на линия
@msg no empty artboards to delete няма празни работни области за изтриване
Scale Presets Пресети за мащаб
Live preview Преглед на живо
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -5558,6 +5558,7 @@ axis Y Axis osa Y
Rectangular Grid Tool Options Možnosti nástroje Obdélníková mřížka
Polar Grid Tool Options Možnosti nástroje Polární mřížka
Fill Line Výplň úsečky
@msg no empty artboards to delete žádné prázdné pracovní plochy k odstranění
Scale Presets Přednastavení měřítka
@msg a {width}×{height} screenshot's byte count overflows počet bajtů snímku {width}×{height} přeteče
@msg the window's {what} ({points} points × {scale} DPI) cannot size a screenshot okno {what} ({points} bodů × {scale} DPI) nemůže určit velikost snímku
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -5498,5 +5498,6 @@ axis Y Axis Y-akse
Rectangular Grid Tool Options Indstillinger for rektangulært gitterværktøj
Polar Grid Tool Options Indstillinger for polært gitterværktøj
Fill Line Udfyld linje
@msg no empty artboards to delete ingen tomme tegneflader at slette
Scale Presets Skaleringsforudindstillinger
Live preview Direkte forhåndsvisning
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -5140,6 +5140,7 @@ axis Y Axis Y-Achse
Rectangular Grid Tool Options Optionen für das Rechteckiges-Raster-Werkzeug
Polar Grid Tool Options Optionen für das Radiales-Raster-Werkzeug
Fill Line Linie füllen
@msg no empty artboards to delete keine leeren Zeichenflächen zum Löschen
Scale Presets Skalierungsvorgaben
@msg a {width}×{height} screenshot's byte count overflows die Byteanzahl eines {width}×{height}-Screenshots läuft über
@msg the window's {what} ({points} points × {scale} DPI) cannot size a screenshot die {what} des Fensters ({points} Punkte × {scale} DPI) kann keinen Screenshot bemessen
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -5510,6 +5510,7 @@ axis Y Axis Άξονας Y
Rectangular Grid Tool Options Επιλογές εργαλείου ορθογώνιου πλέγματος
Polar Grid Tool Options Επιλογές εργαλείου πολικού πλέγματος
Fill Line Γέμισμα γραμμής
@msg no empty artboards to delete δεν υπάρχουν κενοί πίνακες σχεδίασης για διαγραφή
Scale Presets Προεπιλογές κλίμακας
@msg a {width}×{height} screenshot's byte count overflows το πλήθος byte ενός στιγμιότυπου {width}×{height} υπερχειλίζει
@msg the window's {what} ({points} points × {scale} DPI) cannot size a screenshot το {what} του παραθύρου ({points} σημεία × {scale} DPI) δεν μπορεί να καθορίσει μέγεθος στιγμιότυπου
Can't render this file because it contains an unexpected character in line 2070 and column 19.
+1
View File
@@ -5500,6 +5500,7 @@ axis Y Axis Eje Y
Rectangular Grid Tool Options Opciones de la herramienta Cuadrícula rectangular
Polar Grid Tool Options Opciones de la herramienta Cuadrícula polar
Fill Line Rellenar línea
@msg no empty artboards to delete no hay mesas de trabajo vacías que eliminar
Scale Presets Ajustes predefinidos de escala
@msg a {width}×{height} screenshot's byte count overflows el número de bytes de una captura de {width}×{height} se desborda
@msg the window's {what} ({points} points × {scale} DPI) cannot size a screenshot el {what} de la ventana ({points} puntos × {scale} DPI) no puede dimensionar una captura
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -5589,6 +5589,7 @@ axis Y Axis Y-akseli
Rectangular Grid Tool Options Suorakulmaisen ruudukon työkalun asetukset
Polar Grid Tool Options Napakoordinaattiruudukon työkalun asetukset
Fill Line Täytä viiva
@msg no empty artboards to delete ei poistettavia tyhjiä piirtoalustoja
Scale Presets Skaalauksen esiasetukset
@msg a {width}×{height} screenshot's byte count overflows {width}×{height} kuvakaappauksen tavumäärä ylittää rajan
@msg the window's {what} ({points} points × {scale} DPI) cannot size a screenshot ikkunan {what} ({points} pistettä × {scale} DPI) ei voi määrittää kuvakaappauksen kokoa
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -5557,6 +5557,7 @@ axis Y Axis Axe Y
Rectangular Grid Tool Options Options de l’outil Grille rectangulaire
Polar Grid Tool Options Options de l’outil Grille à coordonnées polaires
Fill Line Remplir le trait
@msg no empty artboards to delete aucun plan de travail vide à supprimer
Scale Presets Préréglages de mise à l'échelle
@msg a {width}×{height} screenshot's byte count overflows le nombre d'octets d'une capture {width}×{height} dépasse la limite
@msg the window's {what} ({points} points × {scale} DPI) cannot size a screenshot la {what} de la fenêtre ({points} points × {scale} DPI) ne permet pas de dimensionner une capture
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -5544,6 +5544,7 @@ axis Y Axis Y tengely
Rectangular Grid Tool Options Téglalaprács beállításai
Polar Grid Tool Options Polárrács beállításai
Fill Line Szakasz kitöltése
@msg no empty artboards to delete nincs törölhető üres rajztábla
Scale Presets Méretezési beállítások
@msg a {width}×{height} screenshot's byte count overflows a(z) {width}×{height} képernyőkép bájtszáma túlcsordul
@msg the window's {what} ({points} points × {scale} DPI) cannot size a screenshot az ablak {what} ({points} pont × {scale} DPI) nem tud képernyőkép-méretet adni
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -5498,5 +5498,6 @@ axis Y Axis Sumbu Y
Rectangular Grid Tool Options Opsi alat kisi persegi panjang
Polar Grid Tool Options Opsi alat kisi polar
Fill Line Isi garis
@msg no empty artboards to delete tidak ada artboard kosong untuk dihapus
Scale Presets Preset skala
Live preview Pratinjau langsung
Can't render this file because it contains an unexpected character in line 3839 and column 19.
+1
View File
@@ -5174,6 +5174,7 @@ axis Y Axis Asse Y
Rectangular Grid Tool Options Opzioni strumento Griglia rettangolare
Polar Grid Tool Options Opzioni strumento Griglia polare
Fill Line Riempi linea
@msg no empty artboards to delete nessuna tavola da disegno vuota da eliminare
Scale Presets Preimpostazioni di scala
@msg a {width}×{height} screenshot's byte count overflows il numero di byte di uno screenshot {width}×{height} va in overflow
@msg the window's {what} ({points} points × {scale} DPI) cannot size a screenshot la {what} della finestra ({points} punti × {scale} DPI) non può dimensionare uno screenshot
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -5236,6 +5236,7 @@ axis Y Axis Y 軸
Rectangular Grid Tool Options 長方形グリッドツールオプション
Polar Grid Tool Options 同心円グリッドツールオプション
Fill Line 線を塗りつぶす
@msg no empty artboards to delete 削除する空のアートボードはありません
Scale Presets 拡大縮小のプリセット
@msg a {width}×{height} screenshot's byte count overflows {width}×{height} のスクリーンショットのバイト数がオーバーフローします
@msg the window's {what} ({points} points × {scale} DPI) cannot size a screenshot ウィンドウの {what}({points} ポイント × {scale} DPI)ではスクリーンショットのサイズを決められません
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -5474,6 +5474,7 @@ axis Y Axis Y ღერძი
Rectangular Grid Tool Options მართკუთხა ბადის ხელსაწყოს პარამეტრები
Polar Grid Tool Options პოლარული ბადის ხელსაწყოს პარამეტრები
Fill Line ხაზის შევსება
@msg no empty artboards to delete წასაშლელი ცარიელი სამუშაო დაფები არ არის
Scale Presets მასშტაბის წინასწარ დაყენებული მნიშვნელობები
@msg a {width}×{height} screenshot's byte count overflows {width}×{height} ეკრანის სურათის ბაიტების რაოდენობა გადაივსო
@msg the window's {what} ({points} points × {scale} DPI) cannot size a screenshot ფანჯრის {what} ({points} პუნქტი × {scale} DPI) ეკრანის სურათის ზომას ვერ განსაზღვრავს
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -5137,6 +5137,7 @@ axis Y Axis Y축
Rectangular Grid Tool Options 사각형 격자 도구 옵션
Polar Grid Tool Options 극좌표 격자 도구 옵션
Fill Line 선 채우기
@msg no empty artboards to delete 삭제할 빈 아트보드가 없습니다
Scale Presets 크기 조절 사전 설정
@msg a {width}×{height} screenshot's byte count overflows {width}×{height} 스크린샷의 바이트 수가 넘칩니다
@msg the window's {what} ({points} points × {scale} DPI) cannot size a screenshot 창의 {what}({points}포인트 × {scale} DPI)로는 스크린샷 크기를 정할 수 없습니다
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -5542,5 +5542,6 @@ axis Y Axis Y-as
Rectangular Grid Tool Options Opties rechthoekig-rastergereedschap
Polar Grid Tool Options Opties polair-rastergereedschap
Fill Line Lijn vullen
@msg no empty artboards to delete geen lege tekengebieden om te verwijderen
Scale Presets Schaalvoorinstellingen
Live preview Live voorbeeld
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -5410,6 +5410,7 @@ axis Y Axis Oś Y
Rectangular Grid Tool Options Opcje narzędzia Siatka prostokątna
Polar Grid Tool Options Opcje narzędzia Siatka biegunowa
Fill Line Wypełnij odcinek
@msg no empty artboards to delete brak pustych obszarów kompozycji do usunięcia
Scale Presets Ustawienia wstępne skali
@msg a {width}×{height} screenshot's byte count overflows liczba bajtów zrzutu ekranu {width}×{height} przekracza limit
@msg the window's {what} ({points} points × {scale} DPI) cannot size a screenshot {what} okna ({points} punktów × {scale} DPI) nie pozwala ustalić rozmiaru zrzutu ekranu
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -5475,6 +5475,7 @@ axis Y Axis Eixo Y
Rectangular Grid Tool Options Opções da Ferramenta Grade Retangular
Polar Grid Tool Options Opções da Ferramenta Grade Polar
Fill Line Preencher linha
@msg no empty artboards to delete não há mesas de trabalho vazias para excluir
Scale Presets Predefinições de escala
@msg a {width}×{height} screenshot's byte count overflows a contagem de bytes de uma captura {width}×{height} transborda
@msg the window's {what} ({points} points × {scale} DPI) cannot size a screenshot o {what} da janela ({points} pontos × {scale} DPI) não pode dimensionar uma captura
Can't render this file because it contains an unexpected character in line 6 and column 47.
+1
View File
@@ -5170,6 +5170,7 @@ axis Y Axis Ось Y
Rectangular Grid Tool Options Параметры инструмента «Прямоугольная сетка»
Polar Grid Tool Options Параметры инструмента «Полярная сетка»
Fill Line Заливка отрезка
@msg no empty artboards to delete нет пустых монтажных областей для удаления
Scale Presets Наборы масштаба
@msg a {width}×{height} screenshot's byte count overflows количество байтов снимка {width}×{height} переполняется
@msg the window's {what} ({points} points × {scale} DPI) cannot size a screenshot {what} окна ({points} пунктов × {scale} DPI) не позволяет задать размер снимка
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -5582,6 +5582,7 @@ axis Y Axis Y-axel
Rectangular Grid Tool Options Alternativ för verktyget för rektangulärt rutnät
Polar Grid Tool Options Alternativ för verktyget för polärt rutnät
Fill Line Fyll linje
@msg no empty artboards to delete inga tomma ritytor att radera
Scale Presets Skalningsförval
@msg a {width}×{height} screenshot's byte count overflows byteantalet för en skärmbild på {width}×{height} överstiger gränsen
@msg the window's {what} ({points} points × {scale} DPI) cannot size a screenshot fönstrets {what} ({points} punkter × {scale} DPI) kan inte bestämma en skärmbilds storlek
Can't render this file because it contains an unexpected character in line 5 and column 25.
+1
View File
@@ -5515,6 +5515,7 @@ axis Y Axis Y ekseni
Rectangular Grid Tool Options Dikdörtgen Izgara Aracı Seçenekleri
Polar Grid Tool Options Yuvarlak Izgara Aracı Seçenekleri
Fill Line Çizgiyi doldur
@msg no empty artboards to delete silinecek boş çalışma yüzeyi yok
Scale Presets Ölçek ön ayarları
@msg a {width}×{height} screenshot's byte count overflows {width}×{height} ekran görüntüsünün bayt sayısı taşıyor
@msg the window's {what} ({points} points × {scale} DPI) cannot size a screenshot pencerenin {what} ({points} nokta × {scale} DPI) bir ekran görüntüsünü boyutlandıramaz
Can't render this file because it contains an unexpected character in line 5 and column 25.
+1
View File
@@ -5154,6 +5154,7 @@ axis Y Axis Вісь Y
Rectangular Grid Tool Options Параметри інструменту «Прямокутна сітка»
Polar Grid Tool Options Параметри інструменту «Полярна сітка»
Fill Line Заливка відрізка
@msg no empty artboards to delete немає порожніх монтажних областей для видалення
Scale Presets Набори масштабу
@msg a {width}×{height} screenshot's byte count overflows кількість байтів знімка {width}×{height} переповнюється
@msg the window's {what} ({points} points × {scale} DPI) cannot size a screenshot {what} вікна ({points} пунктів × {scale} DPI) не дозволяє задати розмір знімка
Can't render this file because it contains an unexpected character in line 749 and column 40.
+1
View File
@@ -5154,6 +5154,7 @@ axis Y Axis Trục Y
Rectangular Grid Tool Options Tùy chọn công cụ Lưới chữ nhật
Polar Grid Tool Options Tùy chọn công cụ Lưới cực
Fill Line Tô đoạn thẳng
@msg no empty artboards to delete không có bảng vẽ trống nào để xóa
Scale Presets Cài đặt sẵn tỉ lệ
@msg a {width}×{height} screenshot's byte count overflows số byte của ảnh chụp {width}×{height} bị tràn
@msg the window's {what} ({points} points × {scale} DPI) cannot size a screenshot {what} của cửa sổ ({points} điểm × {scale} DPI) không thể định cỡ ảnh chụp
Can't render this file because it contains an unexpected character in line 4053 and column 10.
+1
View File
@@ -5397,6 +5397,7 @@ axis Y Axis Y 轴
Rectangular Grid Tool Options 矩形网格工具选项
Polar Grid Tool Options 极坐标网格工具选项
Fill Line 填充线段
@msg no empty artboards to delete 没有可删除的空白画板
Scale Presets 缩放预设
@msg a {width}×{height} screenshot's byte count overflows {width}×{height} 截图的字节数溢出
@msg the window's {what} ({points} points × {scale} DPI) cannot size a screenshot 窗口的{what}({points} 点 × {scale} DPI)无法确定截图大小
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -5193,6 +5193,7 @@ axis Y Axis Y 軸
Rectangular Grid Tool Options 矩形格線工具選項
Polar Grid Tool Options 極座標格點工具選項
Fill Line 填色線段
@msg no empty artboards to delete 沒有可刪除的空白工作區域
Scale Presets 縮放預設集
@msg a {width}×{height} screenshot's byte count overflows {width}×{height} 螢幕擷取畫面的位元組數溢出
@msg the window's {what} ({points} points × {scale} DPI) cannot size a screenshot 視窗的{what}({points} 點 × {scale} DPI)無法決定螢幕擷取畫面的大小
Can't render this file because it contains an unexpected character in line 6 and column 47.
+59 -1
View File
@@ -277,6 +277,18 @@ pub(crate) fn delete_selected(app: &mut VectorcraftApp) {
}
}
/// Artboards panel › Delete Empty Artboards: delete the artboards with no artwork, then make the
/// active artboard, where it is now, active again, or else the nearest one before it that is left.
pub(crate) fn delete_empty(app: &mut VectorcraftApp) {
let n = app.session.active().map_or(0, |d| d.doc.artboards.len());
let active = selected(app, n);
let Ok(r) = app.run("artboard.deleteEmpty", json!({})) else { return };
let deleted: Vec<usize> = r["deleted"].as_array().into_iter().flatten().filter_map(Value::as_u64).map(|i| i as usize).collect();
// The last artboard up to the active one that is left, numbered as it is now.
let kept = (0..=active).rev().find(|i| !deleted.contains(i)).unwrap_or(0);
select(app, kept.saturating_sub(deleted.iter().filter(|d| **d < kept).count()));
}
/// Shift-click on artboard `i`'s row while the Artboard tool is in use: the tool selects the
/// artboards from `anchor` to `i`, and `i` becomes the active one. `anchor` is the last row clicked
/// without Shift or where the last range started; when its artboard is no longer selected, the
@@ -563,7 +575,9 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) {
let all = picked(app, n);
start_rename(ui.ctx(), app, if all.contains(&sel) { sel } else { all.first().copied().unwrap_or(sel) });
}
menu_item(ui, tl!("Delete Empty Artboards"), false, false);
if menu_item(ui, tl!("Delete Empty Artboards"), crate::menus::enabled(app, "artboard.deleteEmpty"), false) {
delete_empty(app);
}
ui.separator();
if menu_item(ui, tl!("Convert to Artboards"), crate::menus::enabled(app, "artboard.convertToArtboards"), false) {
crate::menus::invoke(app, "artboard.convertToArtboards", json!({}));
@@ -583,6 +597,7 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) {
#[cfg(test)]
mod tests {
use super::*;
use crate::widgets::accessibility_tests::{named, tree};
use vectorcraft_engine::Session;
/// Clicking a row makes its artboard the active one (the navigator's, Fit Artboard in Window's).
@@ -1745,6 +1760,49 @@ mod tests {
assert_eq!(names, ["Artboard 1", "Artboard 2"]);
assert_eq!((selected(&app, 2), app.session.tool_options()["active"].clone()), (1, json!(1)));
}
/// The panel menu's Delete Empty Artboards is disabled while every artboard has art, and
/// enabled otherwise; a click deletes the empty ones in one undo step and makes the artboard
/// left active.
#[test]
fn delete_empty_artboards_deletes_the_empty_ones_and_selects_one_left() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 200, "height": 150})).unwrap();
for x in [400, 800] {
app.run("artboard.new", json!({"x": x, "y": 0, "width": 200, "height": 150})).unwrap();
}
let rect =
|app: &mut VectorcraftApp, x: f64| app.run("shape.rectangle", json!({"x": x, "y": 10, "width": 20, "height": 20})).unwrap()["id"].clone();
let ids: Vec<Value> = [10.0, 450.0, 850.0].into_iter().map(|x| rect(&mut app, x)).collect();
let ctx = egui::Context::default();
let screen = Some(egui::Rect::from_min_size(egui::Pos2::ZERO, vec2(240.0, 400.0)));
let raw = |events| egui::RawInput { events, screen_rect: screen, ..Default::default() };
let item_of = |app: &mut VectorcraftApp| {
let mut out = ctx.run_ui(raw(vec![]), |ui| menu(app, ui));
out.textures_delta.clear();
out.shapes.iter().find_map(|c| match &c.shape {
egui::Shape::Text(t) if t.galley.text().trim_start() == "Delete Empty Artboards" => Some(t.visual_bounding_rect().center()),
_ => None,
})
};
// Menu rows pad their label where a check mark would go.
let label = " Delete Empty Artboards";
assert!(named(&tree(|ui| menu(&mut app, ui)), label).is_disabled(), "every artboard has art");
app.run("select.set", json!({"ids": [ids[0], ids[2]]})).unwrap();
app.run("edit.clear", json!({})).unwrap();
select(&mut app, 2);
assert!(!named(&tree(|ui| menu(&mut app, ui)), label).is_disabled(), "two artboards are empty");
let item = item_of(&mut app).unwrap();
let steps = app.session.active().unwrap().history.undo.len();
let button = |pressed| egui::Event::PointerButton { pos: item, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
ctx.run_ui(raw(vec![egui::Event::PointerMoved(item), button(true), button(false)]), |ui| menu(&mut app, ui)).textures_delta.clear();
let st = app.session.active().unwrap();
let names: Vec<String> = st.doc.artboards.iter().map(|a| a.name.clone()).collect();
assert_eq!(names, ["Artboard 2"]);
assert_eq!(st.history.undo.len(), steps + 1, "one undo step");
assert_eq!(selected(&app, 1), 0, "the artboard left is active");
}
/// With the Artboard tool, the canvas context menu locks and unlocks artboards: Lock Selected
/// locks the selected one (the others stay free), Unlock All frees every artboard.
#[test]
+3 -1
View File
File diff suppressed because one or more lines are too long
+7
View File
@@ -3172,6 +3172,13 @@ selected artboards keeps its size.
{"name":"run_command","arguments":{"command":"artboard.setProps","params":{"artboards":[{"index":0,"width":300},{"index":2,"width":300}],"scaleArt":true}}}
```
Delete Empty Artboards: `artboard.deleteEmpty {}` deletes every artboard with no artwork on it, with its guides, in
one undo step, and returns the indices it deleted (numbered as before) as `{deleted: [index…]}`. An artboard has
artwork when an object (hidden, locked and template art included; guides not) has visual bounds that meet or touch
it; a group counts by its members, a clip group or clipping layer by its members inside the clip. Locked artboards stay; when every artboard is empty and none is locked, artboard 1 stays (with a locked
artboard left, artboard 1 can go). With
nothing to delete the command is disabled and fails with `no empty artboards to delete`.
Locked artboards: `artboard.setProps {index, locked: true}` locks an artboard and `{index, locked: false}` unlocks it,
each in one undo step. The Artboards panel's lock column, left of a row's options icon, shows a lock on a locked
artboard, and a click there runs this command. A locked artboard keeps its place and size. `artboard.setProps` with a
File diff suppressed because one or more lines are too long
+3 -1
View File
File diff suppressed because one or more lines are too long