Merge pull request #1902 from Messhias/feat/g12-layers-collect-export

G12: Collect for Export on Layers (≡ and row menu)
This commit is contained in:
BFlat
2026-10-16 06:37:53 +07:00
committed by GitHub
4 changed files with 92 additions and 10 deletions
+21 -2
View File
@@ -20,7 +20,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Collect for Export",
["Object", "Collect for Export"],
None,
"{ids?: [id…] (default: the selected objects), multiple?: true (an asset per object; false: one asset of them all), name?: (the asset's name, when it makes one)} collect art as assets for Window › Asset Export and Export for Screens' Assets tab, named after the object (else Asset 1, Asset 2…); art already collected as such an asset keeps it → {assets: [asset id…] (in order), added: n}. Assets follow their art: moving it moves the crop, deleting it removes it from the asset",
"{ids?: [id…] (default: the selected objects; a layer id collects the art under it, including in its sublayers), multiple?: true (an asset per object; false: one asset of them all), name?: (the asset's name, when it makes one)} collect art as assets for Window › Asset Export and Export for Screens' Assets tab, named after the object (else Asset 1, Asset 2…); art already collected as such an asset keeps it → {assets: [asset id…] (in order), added: n}. Assets follow their art: moving it moves the crop, deleting it removes it from the asset",
has_doc,
add
),
@@ -78,13 +78,32 @@ fn clean_name(name: &str) -> Option<String> {
(!n.is_empty()).then_some(n)
}
/// Ids of art under `id`: the id itself when it is not a layer, else every non-layer under it
/// (sublayers unfolded). Layers alone have nothing to collect.
fn art_under(doc: &vectorcraft_doc::Document, id: NodeId, out: &mut Vec<NodeId>) {
let Some(n) = doc.node(id) else { return };
if n.is_layer() {
if let Some(kids) = n.children() {
for c in kids {
art_under(doc, c.id, out);
}
}
return;
}
out.push(id);
}
fn add(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "assets.add";
let ids = if p.get("ids").is_some_and(|v| !v.is_null()) {
// An explicit list is authoritative: never silently drop malformed or stale ids.
let ids = checked_ids_param(s, p, "ids", C)?;
let d = &s.doc()?.doc;
roots_of(d, d.paint_order(ids))
let mut art = Vec::new();
for id in &ids {
art_under(d, *id, &mut art);
}
roots_of(d, d.paint_order(art))
} else {
selected_roots(s)?
};
+25 -2
View File
@@ -68,14 +68,37 @@ fn multiple_gives_an_asset_per_object_and_single_one_of_them_all() {
let again = run(&mut s, "assets.add", json!({"ids": [b, a]}));
assert_eq!((again["added"].clone(), again["assets"].clone()), (json!(0), json!([list[0]["id"], list[1]["id"]])));
assert_eq!(undo_len(&s), steps);
// Layers aren't art; nothing to collect is an error.
// A layer id collects the art under it (Layers panel Collect for Export / MCP).
let layer = s.doc().unwrap().doc.layers[0].id.0;
assert!(s.execute("assets.add", &json!({"ids": [layer]})).is_err());
let from_layer = run(&mut s, "assets.add", json!({"ids": [layer]}));
assert_eq!(from_layer["added"], 0, "those objects are assets already");
assert_eq!(from_layer["assets"], json!([list[0]["id"], list[1]["id"]]));
// An empty layer has nothing to collect.
let empty = run(&mut s, "layer.new", json!({}))["id"].as_u64().unwrap();
assert!(s.execute("assets.add", &json!({"ids": [empty]})).is_err());
run(&mut s, "select.set", json!({"ids": []}));
assert!(s.execute("assets.add", &json!({})).is_err());
assert!(s.execute("assets.add", &json!({"ids": [a, b], "name": "Two"})).is_err(), "a name names one asset");
}
#[test]
fn a_layer_id_collects_the_art_under_it_including_sublayers() {
let mut s = session();
let a = rect(&mut s, 10.0, 10.0, 20.0, 20.0);
let sub = run(&mut s, "layer.newSublayer", json!({}))["id"].as_u64().unwrap();
run(&mut s, "layer.setCurrent", json!({"id": sub}));
let b = rect(&mut s, 50.0, 10.0, 30.0, 20.0);
let layer = s.doc().unwrap().doc.layers[0].id.0;
let r = run(&mut s, "assets.add", json!({"ids": [layer]}));
assert_eq!(r["added"], 2);
let list = assets(&mut s);
assert_eq!(list.len(), 2);
assert_eq!((list[0]["nodes"].clone(), list[1]["nodes"].clone()), (json!([a]), json!([b])));
let one = run(&mut s, "assets.add", json!({"ids": [layer], "multiple": false}));
assert_eq!(one["added"], 1);
assert_eq!(assets(&mut s).last().unwrap()["nodes"], json!([a, b]));
}
#[test]
fn deleting_the_object_prunes_it_and_undo_brings_it_back() {
let mut s = session();
+36
View File
@@ -994,6 +994,13 @@ fn row(ui: &mut Ui, view: &View, item: &Row, out: &mut Out) {
out.actions.push(("ui.layerOptions".into(), json!({"ids": [n.id.0]})));
}
}
// Row menu: Collect for Export on this row (or every highlighted row when this is one of them).
let collect_ids: Vec<u64> = if view.rows.contains(&n.id) { view.rows.iter().map(|id| id.0).collect() } else { vec![n.id.0] };
resp.context_menu(|ui| {
collect_for_export_menu(ui, true, |multiple| {
out.actions.push(("assets.add".into(), json!({ "ids": collect_ids.clone(), "multiple": multiple })));
});
});
// Dragging rows: the highlighted rows when this is one of them, else this row.
if resp.drag_started() && view.column.is_none() {
let ids = if view.rows.contains(&n.id) { view.rows.clone() } else { vec![n.id] };
@@ -1486,6 +1493,8 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) {
Some(("Merge Selected", "layer.merge", json!({}), rows.len() >= 2, false)),
Some(("Flatten Artwork", "layer.flatten", json!({}), true, false)),
Some(("Collect in New Layer", "layer.collectInNew", json!({}), !rows.is_empty() || has_sel, false)),
// Sentinel: rendered as Collect for Export › As Single / As Multiple (assets.add).
Some(("Collect for Export", "", json!({}), !targets.is_empty() || has_sel, false)),
Some(("Release to Layers (Sequence)", "layer.releaseToLayers", json!({}), one_container, false)),
Some(("Release to Layers (Build)", "layer.releaseToLayersBuild", json!({}), one_container, false)),
Some(("Reverse Order", "layer.reverse", json!({}), rows.len() >= 2, false)),
@@ -1525,6 +1534,15 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) {
None => {
ui.separator();
}
Some(("Collect for Export", _, _, enabled, _)) => {
collect_for_export_menu(ui, enabled, |multiple| {
let mut p = json!({ "multiple": multiple });
if !targets.is_empty() {
p["ids"] = json!(targets.iter().map(|id| id.0).collect::<Vec<_>>());
}
app.run("assets.add", p).ok();
});
}
Some((label, cmd, p, enabled, checked)) => {
if widgets::menu_item(ui, label, enabled, checked) {
app.run(cmd, p).ok();
@@ -1534,6 +1552,23 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) {
}
}
/// Collect for Export › As Single Asset / As Multiple Assets (`assets.add`).
fn collect_for_export_menu(ui: &mut Ui, enabled: bool, mut run: impl FnMut(bool)) {
ui.add_enabled_ui(enabled, |ui| {
// English labels match the rest of this panel menu (and Object › Collect for Export).
ui.menu_button("Collect for Export", |ui| {
if widgets::menu_item(ui, "As Single Asset", true, false) {
run(false);
ui.close();
}
if widgets::menu_item(ui, "As Multiple Assets", true, false) {
run(true);
ui.close();
}
});
});
}
/// `ui.layersExpand {ids?, open?}`: open (or close) rows of the active document's Layers panel;
/// without `ids`, every row that holds others.
pub fn expand(app: &mut VectorcraftApp, p: &Value) -> Result<Value, String> {
@@ -2500,6 +2535,7 @@ mod tests {
"Merge Selected",
"Flatten Artwork",
"Collect in New Layer",
"Collect for Export",
"Release to Layers (Sequence)",
"Release to Layers (Build)",
"Reverse Order",
+10 -6
View File
@@ -2766,13 +2766,14 @@ defaults); presets and remembered settings are preferences.
## Asset Export
Assets are pieces of art collected for export on their own (Window › Asset Export, Object › Collect for Export, File ›
Export Selection…).
Assets are pieces of art collected for export on their own (Window › Asset Export, Object › Collect for Export, the
Layers panel ≡ menu and row menu, File › Export Selection…).
`assets.add {ids?, multiple?}` collects the selected objects (or `ids`): one asset per object, or with
`multiple: false` one asset of them all, named after the object or `Asset 1`, `Asset 2`…; art already collected keeps
its asset. Assets name their objects, so they follow edits: moving the art moves the crop, and deleting it
removes it from its asset (an asset left with nothing goes). `assets.list` gives each asset's id, name, objects and
crop (`bounds`); `assets.rename {asset, name}` and `assets.remove {assets}` are undo steps.
`multiple: false` one asset of them all, named after the object or `Asset 1`, `Asset 2`…; a layer id collects the art
under it (sublayers included). Art already collected keeps its asset. Assets name their objects, so they follow edits:
moving the art moves the crop, and deleting it removes it from its asset (an asset left with nothing goes).
`assets.list` gives each asset's id, name, objects and crop (`bounds`); `assets.rename {asset, name}` and
`assets.remove {assets}` are undo steps.
`assets.export {assets?, folder?, zip?}` writes each asset's art alone, cropped to it and named after it, in every
format row of the document's export settings, the ones Export for Screens remembers (`document.exportSettings`); any
@@ -2783,9 +2784,12 @@ renderer measures for what the art draws, its effects included.
```json
{"name":"run_command","arguments":{"command":"assets.add","params":{"multiple":true}}}
{"name":"run_command","arguments":{"command":"assets.add","params":{"ids":[12],"multiple":true}}}
{"name":"run_command","arguments":{"command":"assets.settings.set","params":{"formats":[{"format":"png","scale":"1x"},{"format":"png","scale":"2x"},{"format":"svg"}]}}}
{"name":"run_command","arguments":{"command":"assets.export","params":{"zip":true}}}
```
A layer id in `ids` (12 above) is the same as Collect for Export on that Layers row: every object under the layer
becomes an asset (or one asset with `multiple: false`).
## CSS Properties