From 6757c7f9f33a0e18f9fc68b58c02a879a9437b0d Mon Sep 17 00:00:00 2001 From: bflatastic Date: Mon, 5 Oct 2026 14:27:46 +0700 Subject: [PATCH 1/2] M4.65: Asset Export panel and the Assets tab of Export for Screens Window > Asset Export lists the document's assets as thumbnails: art dragged in off the canvas (or added with +) becomes one asset per object, or one of them all with Alt; Object > Collect for Export does the same. Assets name their objects, so they follow edits (moving the art moves the crop) and deleting the art removes it from its asset. Click selects, double-click renames, the bin removes. The panel's export settings are Export for Screens' own format rows on the document's shared settings, and Export writes each asset alone, cropped to its art, into a picked folder (the web downloads a zip). Export for Screens' Assets tab lists the assets with checkboxes and exports the checked ones. Commands: assets.add, assets.remove, assets.rename, assets.list, assets.export, assets.settings.set; document.exportForScreens takes assets. Model: Document.assets (ExportAsset {id, name, nodes}). Co-Authored-By: Claude Opus 5.5 --- crates/doc/src/assets.rs | 57 +++ crates/doc/src/lib.rs | 8 + crates/doc/src/selection.rs | 4 +- crates/engine/src/cmd/assets.rs | 227 +++++++++++ crates/engine/src/cmd/fileio/export.rs | 17 +- crates/engine/src/cmd/fileio/mod.rs | 21 +- crates/engine/src/cmd/fileio/screens.rs | 128 ++++-- crates/engine/src/cmd/mod.rs | 2 + crates/engine/src/lib.rs | 6 + crates/engine/src/tests_assets.rs | 267 +++++++++++++ crates/mcp/src/headless.rs | 2 +- .../ui-egui/src/dialogs/export_for_screens.rs | 174 +++++++-- crates/ui-egui/src/dialogs/mod.rs | 5 + .../src/dialogs/tests_screen_assets.rs | 100 +++++ crates/ui-egui/src/io.rs | 7 +- crates/ui-egui/src/menus.rs | 9 +- crates/ui-egui/src/panels/asset_export.rs | 366 ++++++++++++++++++ crates/ui-egui/src/panels/mod.rs | 5 + .../ui-egui/src/panels/tests_asset_export.rs | 206 ++++++++++ crates/ui-egui/src/state.rs | 1 + docs/control-protocol.md | 1 + docs/mcp.md | 21 + 22 files changed, 1541 insertions(+), 93 deletions(-) create mode 100644 crates/doc/src/assets.rs create mode 100644 crates/engine/src/cmd/assets.rs create mode 100644 crates/engine/src/tests_assets.rs create mode 100644 crates/ui-egui/src/dialogs/tests_screen_assets.rs create mode 100644 crates/ui-egui/src/panels/asset_export.rs create mode 100644 crates/ui-egui/src/panels/tests_asset_export.rs diff --git a/crates/doc/src/assets.rs b/crates/doc/src/assets.rs new file mode 100644 index 00000000..ec84333a --- /dev/null +++ b/crates/doc/src/assets.rs @@ -0,0 +1,57 @@ +//! Asset Export (Window → Asset Export, Object → Collect for Export): pieces of art collected to +//! export on their own, at the export settings Export for Screens shares. +//! +//! An asset names objects rather than copying them: it follows edits (a moved object moves the +//! crop, a recoloured one exports recoloured) and lets go of objects that are deleted +//! ([`Document::prune_assets`]; an asset left with none goes too). Asset ids come from the +//! document's id counter, so they never repeat within a document. + +use serde::{Deserialize, Serialize}; + +use crate::{Document, NodeId}; + +/// One asset: what it is called (its files' names) and the objects it exports, cropped to their +/// visual bounds. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct ExportAsset { + pub id: u64, + pub name: String, + pub nodes: Vec, +} + +/// What the assets are named when they aren't named after their object: `Asset 1`, `Asset 2`… +const ASSET: &str = "Asset "; + +impl Document { + /// Asset `id`. + pub fn asset(&self, id: u64) -> Option<&ExportAsset> { + self.assets.iter().find(|a| a.id == id) + } + + /// The name of the next unnamed asset: one more than the highest `Asset N` so far. + pub fn next_asset_name(&self) -> String { + let n = self.assets.iter().filter_map(|a| a.name.strip_prefix(ASSET)?.parse::().ok()).max().unwrap_or(0); + format!("{ASSET}{}", n.saturating_add(1)) + } + + /// Forget the objects of assets that are no longer in the document, and the assets left with + /// none (after every edit). + pub fn prune_assets(&mut self) { + if self.assets.iter().all(|a| a.nodes.iter().all(|id| self.node(*id).is_some())) { + return; + } + let mut assets = std::mem::take(&mut self.assets); + for a in &mut assets { + a.nodes.retain(|id| self.node(*id).is_some()); + } + assets.retain(|a| !a.nodes.is_empty()); + self.assets = assets; + } + + /// `ids` that are in the document, back to front. + pub fn paint_order(&self, ids: impl IntoIterator) -> Vec { + let mut v: Vec<(Vec, NodeId)> = ids.into_iter().filter_map(|id| self.index_path(id).map(|p| (p, id))).collect(); + v.sort(); + v.into_iter().map(|(_, id)| id).collect() + } +} diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 24713339..2e04dc6b 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -6,6 +6,7 @@ #![forbid(unsafe_code)] pub mod appearance; +pub mod assets; pub mod graph; pub mod hit; pub mod inks; @@ -49,6 +50,7 @@ pub use appearance::{ Appearance, AppearanceItem, ArrowAlign, Arrowhead, Dash, Effect, FillLayer, LineCap, LineJoin, ProfilePreset, SavedProfile, StrokeAlign, StrokeLayer, WidthProfile, }; +pub use assets::ExportAsset; pub use graph::{GraphKind, GraphSpec}; pub use hit::{Hit, HitKind}; pub use links::{LinkInfo, PlacementOptions}; @@ -526,6 +528,10 @@ pub struct Document { /// as JSON: the print engine sits above this crate); `None` until they are set up. #[serde(default, skip_serializing_if = "Option::is_none")] pub print_setup: Option, + /// Window → Asset Export: art collected for export ([`ExportAsset`]), in panel order. Their + /// export settings are Export for Screens' ([`Document::export_settings`]). + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub assets: Vec, } fn ppi72() -> f64 { @@ -592,6 +598,7 @@ impl Document { slices: vec![], slices_clip_to_artboard: true, print_setup: None, + assets: vec![], }; let id = d.alloc_id(); d.layers.push(Arc::new(Node::layer(id, "Layer 1", LayerColor::Preset(0)))); @@ -614,6 +621,7 @@ impl Document { l.walk(&mut |n| max = max.max(n.id.0)); } max = self.slices.iter().fold(max, |m, s| m.max(s.id.0)); + max = self.assets.iter().fold(max, |m, a| m.max(a.id)); self.next_id = self.next_id.max(max + 1); } diff --git a/crates/doc/src/selection.rs b/crates/doc/src/selection.rs index 6c598f87..70ce0fe3 100644 --- a/crates/doc/src/selection.rs +++ b/crates/doc/src/selection.rs @@ -117,9 +117,7 @@ impl Selection { } /// Top-level ordering: selected ids sorted by paint order (bottom first). pub fn in_paint_order(&self, doc: &Document) -> Vec { - let mut v: Vec<(Vec, NodeId)> = self.objects.iter().filter_map(|id| doc.index_path(*id).map(|p| (p, *id))).collect(); - v.sort(); - v.into_iter().map(|(_, id)| id).collect() + doc.paint_order(self.objects.iter().copied()) } } diff --git a/crates/engine/src/cmd/assets.rs b/crates/engine/src/cmd/assets.rs new file mode 100644 index 00000000..c8a817d5 --- /dev/null +++ b/crates/engine/src/cmd/assets.rs @@ -0,0 +1,227 @@ +//! Window → Asset Export and Object → Collect for Export: art collected as assets +//! ([`vectorcraft_doc::ExportAsset`]), exported each on its own, cropped to its art, at the export +//! settings Export for Screens shares (`document.exportSettings`). File → Export Selection collects +//! the selection this way and opens Export for Screens on its Assets tab (a UI command). + +use serde_json::{Map, Value, json}; +use vectorcraft_doc::{ExportAsset, NodeId}; + +use super::edit::{roots_of, selected_roots}; +use super::fileio::{SCREEN_SETTINGS_KEYS, check_screen_settings, export_screens, store_screen_settings}; +use super::*; + +/// The longest asset name kept (characters). +const MAX_NAME: usize = 255; + +pub fn specs() -> Vec { + vec![ + cmd!( + "assets.add", + "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", + has_doc, + add + ), + cmd!( + "assets.remove", + "Remove Asset", + [], + None, + "{assets: [asset id…]} take assets out of the Asset Export panel (their art stays) → {removed: n}", + has_doc, + remove + ), + cmd!("assets.rename", "Rename Asset", [], None, "{asset: id, name} rename an asset (its files' names)", has_doc, rename), + cmd!( + query "assets.list", + "Assets", + [], + None, + "{} → {assets: [{id, name, nodes: [id…], bounds: [x, y, width, height] | null (the crop: the visual bounds of its art)}]} the Asset Export panel's assets, in order", + has_doc, + list + ), + cmd!( + "assets.export", + "Export Assets", + [], + None, + "{assets?: [asset id…] (default all), folder?, zip?, openLocation?, formats?, preset?, settings?, prefix?, subfolders?, antiAlias?} each asset's art alone, cropped to it, in every format row: document.exportForScreens {assets} at the document's export settings (document.exportSettings) under the ones given → what document.exportForScreens returns (no folder: the files, or one zip, as dataBase64). Leaves the remembered settings as they are (assets.settings.set changes them)", + has_doc, + export + ), + cmd!( + "assets.settings.set", + "Asset Export Settings", + [], + None, + "{formats?, preset?: mobile|density|\"\" (none), settings?, prefix?, subfolders?, antiAlias?} change the export settings the Asset Export panel and Export for Screens share (document.exportSettings; the values of document.exportForScreens; null removes one): formats replace a preset, a preset replaces the formats → {settings}. Not an undo step", + has_doc, + settings_set + ), + ] +} + +/// `e` as an error of command `cmd` (for errors of the export underneath). +fn of_cmd(cmd: &str, e: EngineError) -> EngineError { + match e { + EngineError::BadParams { msg, .. } => bad(cmd, msg), + e => e, + } +} + +/// `name` trimmed and at most [`MAX_NAME`] characters long (`None` when empty). +fn clean_name(name: &str) -> Option { + let n: String = name.trim().chars().take(MAX_NAME).collect(); + (!n.is_empty()).then_some(n) +} + +fn add(s: &mut Session, p: &Value) -> Result { + const C: &str = "assets.add"; + let ids = match ids_param(p, "ids") { + Some(ids) => { + let d = &s.doc()?.doc; + roots_of(d, d.paint_order(ids)) + } + None => selected_roots(s)?, + }; + if ids.is_empty() { + return Err(bad(C, "select the art to collect (or give its ids)")); + } + let name = str_param(p, "name").and_then(clean_name); + let sets: Vec> = if bool_or(p, "multiple", true) { ids.iter().map(|id| vec![*id]).collect() } else { vec![ids] }; + if name.is_some() && sets.len() > 1 { + return Err(bad(C, "a name names one asset: give multiple: false, or one object")); + } + let existing = |d: &vectorcraft_doc::Document, nodes: &[NodeId]| d.assets.iter().find(|a| a.nodes == nodes).map(|a| a.id); + let d = &s.doc()?.doc; + // Nothing new: no undo step. + if sets.iter().all(|set| existing(d, set).is_some()) { + return Ok(json!({ "assets": sets.iter().filter_map(|set| existing(d, set)).collect::>(), "added": 0 })); + } + s.edit("Collect for Export", |d, _| { + let (mut out, mut added) = (vec![], 0); + for nodes in sets { + if let Some(id) = existing(d, &nodes) { + out.push(id); + continue; + } + let own = match nodes.as_slice() { + [one] => d.node(*one).and_then(|n| n.name.as_deref()).and_then(clean_name), + _ => None, + }; + let name = name.clone().or(own).unwrap_or_else(|| d.next_asset_name()); + let id = d.alloc_id().0; + d.assets.push(ExportAsset { id, name, nodes }); + out.push(id); + added += 1; + } + Ok(json!({ "assets": out, "added": added })) + }) +} + +/// The asset ids of param `key`. +fn asset_ids(p: &Value, key: &str, cmd: &str) -> Result> { + let ids = p.get(key).and_then(Value::as_array).ok_or_else(|| bad(cmd, format!("`{key}` must be an array of asset ids (see assets.list)")))?; + ids.iter().map(|v| v.as_u64().ok_or_else(|| bad(cmd, format!("{v} is not an asset id")))).collect() +} + +fn remove(s: &mut Session, p: &Value) -> Result { + const C: &str = "assets.remove"; + let ids = asset_ids(p, "assets", C)?; + let n = s.doc()?.doc.assets.iter().filter(|a| ids.contains(&a.id)).count(); + if n == 0 { + return Err(bad(C, "no such assets (see assets.list)")); + } + s.edit("Remove Asset", |d, _| { + d.assets.retain(|a| !ids.contains(&a.id)); + Ok(json!({ "removed": n })) + }) +} + +fn rename(s: &mut Session, p: &Value) -> Result { + const C: &str = "assets.rename"; + let id = p.get("asset").and_then(Value::as_u64).ok_or_else(|| bad(C, "`asset` is the asset id (see assets.list)"))?; + let name = str_param(p, "name").and_then(clean_name).ok_or_else(|| bad(C, "give the asset a name"))?; + let at = s.doc()?.doc.assets.iter().position(|a| a.id == id).ok_or_else(|| bad(C, format!("no asset {id}")))?; + s.edit("Rename Asset", |d, _| { + if let Some(a) = d.assets.get_mut(at) { + a.name = name; + } + ok() + }) +} + +fn list(s: &mut Session, _: &Value) -> Result { + let d = &s.doc()?.doc; + let rows: Vec = d + .assets + .iter() + .map(|a| { + let bounds = a.nodes.iter().filter_map(|id| d.node(*id)?.visual_bounds()).reduce(|x, y| x.union(y)); + json!({ + "id": a.id, + "name": a.name, + "nodes": a.nodes.iter().map(|n| n.0).collect::>(), + "bounds": bounds.map(|b| [b.x0, b.y0, b.width(), b.height()]), + }) + }) + .collect(); + Ok(json!({ "assets": rows })) +} + +/// The shared export settings of the document (the [`SCREEN_SETTINGS_KEYS`] it has). +fn shared_settings(s: &Session) -> Result> { + let saved = &s.doc()?.doc.export_settings; + Ok(SCREEN_SETTINGS_KEYS.iter().filter_map(|k| Some((k.to_string(), saved.get(*k).filter(|v| !v.is_null())?.clone()))).collect()) +} + +/// `base` with the settings of `p` over it: a given value replaces (null removes) the saved one; +/// formats drop a saved preset and a preset (`""`: none) saved formats. +fn overlay(base: &mut Map, p: &Map) { + let given = |k: &str| p.get(k).is_some_and(|v| !v.is_null()); + if given("formats") && !p.contains_key("preset") { + base.remove("preset"); + } + if given("preset") && !p.contains_key("formats") { + base.remove("formats"); + } + for (k, v) in p { + match v { + Value::Null => _ = base.remove(k), + Value::String(id) if k == "preset" && id.is_empty() => _ = base.remove(k), + v => _ = base.insert(k.clone(), v.clone()), + } + } +} + +fn export(s: &mut Session, p: &Value) -> Result { + const C: &str = "assets.export"; + let mut q = shared_settings(s)?; + if let Some(o) = p.as_object() { + overlay(&mut q, o); + } + if !q.contains_key("assets") { + let all: Vec = s.doc()?.doc.assets.iter().map(|a| a.id).collect(); + if all.is_empty() { + return Err(bad(C, "no assets yet: collect art for export first (assets.add)")); + } + q.insert("assets".into(), json!(all)); + } + export_screens(s, &Value::Object(q)).map_err(|e| of_cmd(C, e)) +} + +fn settings_set(s: &mut Session, p: &Value) -> Result { + const C: &str = "assets.settings.set"; + let o = p.as_object().ok_or_else(|| bad(C, "give the settings to change, e.g. {\"formats\": [{\"format\": \"png\", \"scale\": \"2x\"}]}"))?; + if let Some(k) = o.keys().find(|k| !SCREEN_SETTINGS_KEYS.contains(&k.as_str())) { + return Err(bad(C, format!("`{k}` isn't an export setting: {}", SCREEN_SETTINGS_KEYS.join(", ")))); + } + let mut settings = s.doc()?.doc.export_settings.clone(); + overlay(&mut settings, o); + check_screen_settings(s, &Value::Object(settings.clone())).map_err(|e| of_cmd(C, e))?; + store_screen_settings(s, settings)?; + Ok(json!({ "settings": shared_settings(s)? })) +} diff --git a/crates/engine/src/cmd/fileio/export.rs b/crates/engine/src/cmd/fileio/export.rs index c9b7716f..2c2ffffc 100644 --- a/crates/engine/src/cmd/fileio/export.rs +++ b/crates/engine/src/cmd/fileio/export.rs @@ -125,15 +125,22 @@ fn selection(s: &mut Session, cmd: &str) -> Result<(Document, vectorcraft_geom:: let is_template = |id| st.doc.node(id).is_some_and(|l| matches!(l.kind, NodeKind::Layer { template: true, .. })); // On a template layer or sublayer, at any depth. let on_template = |id| st.doc.ancestry(id).is_some_and(|a| a.into_iter().any(is_template)); - let nodes: Vec> = ids.into_iter().filter(|id| !on_template(*id)).filter_map(|id| st.doc.node(id).cloned().map(Arc::new)).collect(); - let bounds = nodes.iter().filter_map(|n| n.visual_bounds()).reduce(|a, b| a.union(b)).ok_or_else(|| bad(cmd, "select something to export"))?; - let mut d = single_artboard(&st.doc, bounds, "Selection"); - let mut layer = Node::layer(d.alloc_id(), "Selection", vectorcraft_doc::LayerColor::Preset(0)); + let ids: Vec = ids.into_iter().filter(|id| !on_template(*id)).collect(); + isolated(&st.doc, &ids, "Selection").ok_or_else(|| bad(cmd, "select something to export")) +} + +/// Objects `ids` of `doc` alone on one layer (in that order, back to front), with one artboard +/// named `name`: their visual bounds (`None` when they have none). +pub(super) fn isolated(doc: &Document, ids: &[NodeId], name: &str) -> Option<(Document, vectorcraft_geom::Rect)> { + let nodes: Vec> = ids.iter().filter_map(|id| doc.node(*id).cloned().map(Arc::new)).collect(); + let bounds = nodes.iter().filter_map(|n| n.visual_bounds()).reduce(|a, b| a.union(b))?; + let mut d = single_artboard(doc, bounds, name); + let mut layer = Node::layer(d.alloc_id(), name, vectorcraft_doc::LayerColor::Preset(0)); if let Some(ch) = layer.children_mut() { *ch = nodes; } d.layers = vec![Arc::new(layer)]; - Ok((d, bounds)) + Some((d, bounds)) } /// File → Export Selection: the selected objects alone, cropped to their visual bounds. Objects on diff --git a/crates/engine/src/cmd/fileio/mod.rs b/crates/engine/src/cmd/fileio/mod.rs index 95f779cf..1339a4cf 100644 --- a/crates/engine/src/cmd/fileio/mod.rs +++ b/crates/engine/src/cmd/fileio/mod.rs @@ -58,6 +58,9 @@ pub use save::{ templates_folder, }; pub use screens::{PRESETS as SCREEN_PRESETS, ScreenSize, preset_rows as screen_preset_rows}; +pub(crate) use screens::{ + SHARED_KEYS as SCREEN_SETTINGS_KEYS, check_settings as check_screen_settings, export as export_screens, store_settings as store_screen_settings, +}; pub use svg::options_map as svg_options; /// Atomic file writes (a temporary file renamed over the target: a failed write never damages the /// file it replaces), for the apps' own writers too. @@ -110,7 +113,7 @@ pub fn specs() -> Vec { "Export for Screens", ["File", "Export"], None, - "{folder?, zip?: false (one store-only .zip of every file: no folder → {name, dataBase64, bytes, files: [name…]}; with a folder it is written there → {path, bytes, files}), artboards?: [index…] | range?: \"1-3\" (default all), fullDocument?: false (one file per format instead: a PDF of every artboard, other formats the bounds of all visible art, named after the document), includeBleed?: false (artboards grown by the document's bleed), subfolders?: false (each row's files in a sub-folder: its folder, else its size for raster (1x, 2x, 100w…) or its format (SVG, PDF)), preset?: mobile (PNG 1x, 2x, 3x) | density (PNG 0.75x–4x in ldpi…xxxhdpi sub-folders) instead of formats (turns subfolders on), formats?: [{format: png|png8|jpg|webp|gif|svg|svgz|pdf, scale?: 1 | \"2x\" | \"100w\" | \"100h\" | \"72ppi\", width?: px | height?: px | ppi? (raster only; width, then height, then ppi win over scale), suffix?: (raster default: @2x, @100w, @100h, none at 1x; vector formats drop size suffixes), folder?: (its sub-folder), quality?: (jpg 0–100), …the format's options (antiAlias, background, preset (pdf)…)}], settings?: {png|png8|jpg|webp|gif|svg|pdf: {…options for every row of that format}} (rows' own options win), prefix?, antiAlias?: none|art|type (raster rows without their own), openLocation?: (remembered; the app shows the files after exporting)} one file per artboard and format (a PDF holds its artboard alone; artboards with the same name, in any case, get -2, -3…; an unnamed one is Artboard-N) → {files: [path…]}; no folder → {files: [{name, dataBase64}]} (names include sub-folders: 2x/Icon@2x.png). The params are remembered in the document (document.exportSettings; not an undo step)", + "{folder?, zip?: false (one store-only .zip of every file: no folder → {name, dataBase64, bytes, files: [name…]}; with a folder it is written there → {path, bytes, files}), artboards?: [index…] | range?: \"1-3\" (default all), assets?: [asset id…] (instead of artboards: each asset's art alone, cropped to its bounds and named after the asset; see assets.list), fullDocument?: false (one file per format instead: a PDF of every artboard, other formats the bounds of all visible art, named after the document), includeBleed?: false (artboards grown by the document's bleed), subfolders?: false (each row's files in a sub-folder: its folder, else its size for raster (1x, 2x, 100w…) or its format (SVG, PDF)), preset?: mobile (PNG 1x, 2x, 3x) | density (PNG 0.75x–4x in ldpi…xxxhdpi sub-folders) instead of formats (turns subfolders on), formats?: [{format: png|png8|jpg|webp|gif|svg|svgz|pdf, scale?: 1 | \"2x\" | \"100w\" | \"100h\" | \"72ppi\", width?: px | height?: px | ppi? (raster only; width, then height, then ppi win over scale), suffix?: (raster default: @2x, @100w, @100h, none at 1x; vector formats drop size suffixes), folder?: (its sub-folder), quality?: (jpg 0–100), …the format's options (antiAlias, background, preset (pdf)…)}], settings?: {png|png8|jpg|webp|gif|svg|pdf: {…options for every row of that format}} (rows' own options win), prefix?, antiAlias?: none|art|type (raster rows without their own), openLocation?: (remembered; the app shows the files after exporting)} one file per artboard and format (a PDF holds its artboard alone; artboards with the same name, in any case, get -2, -3…; an unnamed one is Artboard-N) → {files: [path…]}; no folder → {files: [{name, dataBase64}]} (names include sub-folders: 2x/Icon@2x.png). The params (but zip and assets) are remembered in the document (document.exportSettings; not an undo step)", has_doc, screens::export_for_screens ), @@ -721,14 +724,20 @@ pub(crate) fn create_dir(path: &str) -> Result<()> { /// `-`, `Artboard-N` when unnamed, and `-2`, `-3`… after a name already used (compared without /// case: `Icon` and `icon` are one file on most desktop file systems). pub fn artboard_file_names(doc: &vectorcraft_doc::Document, boards: &[usize]) -> Vec { + unique_file_names(boards.iter().map(|&b| (doc.artboards.get(b).map_or("", |a| a.name.as_str()), format!("Artboard-{}", b + 1)))) +} + +/// File-name parts for `(name, fallback)` pairs (artboards, assets): the name with unsafe +/// characters as `-`, the fallback when it is empty, and `-2`, `-3`… after a name already used +/// (compared without case). +pub(crate) fn unique_file_names<'a>(names: impl IntoIterator) -> Vec { let mut taken = std::collections::HashSet::new(); - boards - .iter() - .map(|&b| { - let name = doc.artboards.get(b).map_or("", |a| a.name.as_str()); + names + .into_iter() + .map(|(name, fallback)| { let mut base: String = name.chars().map(|c| if c.is_alphanumeric() || c == '-' || c == '_' { c } else { '-' }).collect(); if base.is_empty() { - base = format!("Artboard-{}", b + 1); + base = fallback; } let mut name = base.clone(); for i in 2.. { diff --git a/crates/engine/src/cmd/fileio/screens.rs b/crates/engine/src/cmd/fileio/screens.rs index dedd5f9b..62ff1894 100644 --- a/crates/engine/src/cmd/fileio/screens.rs +++ b/crates/engine/src/cmd/fileio/screens.rs @@ -1,7 +1,8 @@ //! File → Export → Export for Screens (`document.exportForScreens`): the chosen artboards (or the -//! whole document) in every format row at each row's size, into a folder and its sub-folders, or -//! returned for download (each file, or one ZIP); the presets the dialog offers; and the settings -//! the document remembers (`document.exportSettings`). +//! whole document, or the chosen assets of the Asset Export panel) in every format row at each +//! row's size, into a folder and its sub-folders, or returned for download (each file, or one ZIP); +//! the presets the dialog offers; and the settings the document remembers +//! (`document.exportSettings`), which the Asset Export panel shares ([`SHARED_KEYS`]). use std::collections::HashSet; use std::sync::Arc; @@ -13,8 +14,8 @@ use vectorcraft_geom::Rect; use super::super::*; use super::encode::single_artboard; -use super::export::without_artboards; -use super::{ARTBOARD_PARAMS, ArtboardPick, Format, artboard_file_names, create_dir, encode, writable, write_file}; +use super::export::{isolated, without_artboards}; +use super::{ARTBOARD_PARAMS, ArtboardPick, Format, artboard_file_names, create_dir, encode, unique_file_names, writable, write_file}; const C: &str = "document.exportForScreens"; @@ -219,6 +220,10 @@ impl Row { } } +/// The settings the Asset Export panel shares with the dialog: what is exported, not which art +/// or where to. +pub(crate) const SHARED_KEYS: [&str; 6] = ["formats", "preset", "settings", "prefix", "subfolders", "antiAlias"]; + /// What `document.exportForScreens` reads besides the formats. #[derive(Default, Deserialize)] #[serde(default, rename_all = "camelCase")] @@ -232,6 +237,45 @@ struct ScreenParams { subfolders: bool, preset: Option, prefix: Option, + /// Asset ids (Asset Export) to write instead of artboards. + assets: Option>, +} + +impl ScreenParams { + fn of(p: &Value) -> Result { + if p.is_object() { Self::deserialize(p).map_err(|e| bad(C, e.to_string())) } else { Ok(Self::default()) } + } +} + +/// The format rows `p` asks for (`formats`, else `preset`'s, else one PNG at 1x), each with the +/// format's `settings` and the top-level `antiAlias`. +fn rows_of(s: &Session, p: &Value, preset: Option<&str>) -> Result> { + let settings = p.get("settings").cloned().unwrap_or(Value::Null); + let rows: Vec = match (preset, p.get("formats")) { + (Some(_), Some(f)) if !f.is_null() => return Err(bad(C, "give formats or a preset, not both")), + (Some(id), _) => preset_rows(id).ok_or_else(|| bad(C, format!("preset `{id}`: mobile or density")))?, + (None, Some(Value::Array(rows))) => rows.clone(), + (None, None | Some(Value::Null)) => vec![json!({})], + (None, Some(v)) => return Err(bad(C, format!("formats must be an array of rows, not {v}"))), + }; + let mut rows: Vec = rows.iter().map(|r| Row::parse(s, r, &settings)).collect::>()?; + if rows.is_empty() { + return Err(bad(C, "no formats to export")); + } + // A top-level anti-aliasing mode applies to every row that doesn't name its own. + if let Some(aa) = p.get("antiAlias") { + for r in &mut rows { + if let Some(m) = r.options.as_object_mut() { + m.entry("antiAlias").or_insert_with(|| aa.clone()); + } + } + } + Ok(rows) +} + +/// Check the export settings in `p` (formats, preset, settings…) as an export would read them. +pub(crate) fn check_settings(s: &Session, p: &Value) -> Result<()> { + rows_of(s, p, ScreenParams::of(p)?.preset.as_deref()).map(|_| ()) } /// The file names (and bytes) an export wrote, or the files it returns. @@ -272,31 +316,23 @@ impl Output { } } -/// File → Export for Screens: every chosen artboard in every format row, one file each (a PDF holds -/// its artboard alone), or with `fullDocument` one file per row (a PDF of every artboard, else the -/// bounds of all art). Artboards that share a name get `-2`, `-3`… instead of overwriting. The -/// params are remembered in the document (`document.exportSettings`). +/// File → Export for Screens: [`export`], with the params remembered in the document +/// (`document.exportSettings`). pub(super) fn export_for_screens(s: &mut Session, p: &Value) -> Result { - let o: ScreenParams = if p.is_object() { ScreenParams::deserialize(p).map_err(|e| bad(C, e.to_string()))? } else { ScreenParams::default() }; - let settings = p.get("settings").cloned().unwrap_or(Value::Null); - let rows: Vec = match (o.preset.as_deref(), p.get("formats")) { - (Some(_), Some(f)) if !f.is_null() => return Err(bad(C, "give formats or a preset, not both")), - (Some(id), _) => preset_rows(id).ok_or_else(|| bad(C, format!("preset `{id}`: mobile or density")))?, - (None, Some(Value::Array(rows))) => rows.clone(), - (None, None | Some(Value::Null)) => vec![json!({})], - (None, Some(v)) => return Err(bad(C, format!("formats must be an array of rows, not {v}"))), - }; - let mut rows: Vec = rows.iter().map(|r| Row::parse(s, r, &settings)).collect::>()?; - if rows.is_empty() { - return Err(bad(C, "no formats to export")); - } - // A top-level anti-aliasing mode applies to every row that doesn't name its own. - if let Some(aa) = p.get("antiAlias") { - for r in &mut rows { - if let Some(m) = r.options.as_object_mut() { - m.entry("antiAlias").or_insert_with(|| aa.clone()); - } - } + let r = export(s, p)?; + remember(s, p); + Ok(r) +} + +/// Every chosen artboard in every format row, one file each (a PDF holds its artboard alone), or +/// with `fullDocument` one file per row (a PDF of every artboard, else the bounds of all art), or +/// with `assets` each asset's art alone, cropped to it. Artboards (assets) that share a name get +/// `-2`, `-3`… instead of overwriting. +pub(crate) fn export(s: &mut Session, p: &Value) -> Result { + let o = ScreenParams::of(p)?; + let rows = rows_of(s, p, o.preset.as_deref())?; + if o.full_document && o.assets.is_some() { + return Err(bad(C, "give assets or fullDocument, not both")); } let subfolders = o.subfolders || o.preset.is_some(); // A prefix names files, not folders. @@ -347,6 +383,24 @@ pub(super) fn export_for_screens(s: &mut Session, p: &Value) -> Result { }; out.put(folder, o.zip, name, bytes)?; } + } else if let Some(ids) = &o.assets { + let assets = + ids.iter().map(|id| doc.asset(*id).ok_or_else(|| bad(C, format!("no asset {id} (see assets.list)")))).collect::>>()?; + if assets.is_empty() { + return Err(bad(C, "no assets to export")); + } + for (a, stem) in assets.iter().zip(unique_file_names(assets.iter().map(|a| (a.name.as_str(), "Asset".to_string())))) { + let (d, bounds) = isolated(&doc, &doc.paint_order(a.nodes.iter().copied()), &a.name) + .ok_or_else(|| bad(C, format!("asset `{}` has no art to export", a.name)))?; + for r in &rows { + let name = name_of(r, &stem); + if !out.claim(&name) { + continue; + } + let bytes = encode(&d, r.format.id, &r.options_for(bounds, Some(0))?)?; + out.put(folder, o.zip, name, bytes)?; + } + } } else { let n = doc.artboards.len(); let boards = o.boards.resolve(n).map_err(|e| bad(C, e))?.unwrap_or_else(|| (0..n).collect()); @@ -365,7 +419,6 @@ pub(super) fn export_for_screens(s: &mut Session, p: &Value) -> Result { } } } - remember(s, p); let names: Vec<&str> = out.kept.iter().map(|(n, _)| n.as_str()).collect(); if o.zip { let zip = super::zip::store(&out.kept).map_err(|e| bad(C, e))?; @@ -387,17 +440,26 @@ pub(super) fn export_for_screens(s: &mut Session, p: &Value) -> Result { }) } -/// Keep the params of a successful export in the document (without `zip`, a delivery detail), so -/// the dialog reopens on them. Not an undo step; the document has changed only if they differ. +/// Keep the params of a successful export in the document (without `zip`, a delivery detail, and +/// `assets`, a choice of art), so the dialog reopens on them. fn remember(s: &mut Session, p: &Value) { let Some(o) = p.as_object() else { return }; let mut settings = o.clone(); settings.remove("zip"); - let Ok(st) = s.doc_mut() else { return }; + settings.remove("assets"); + // Only after a successful export, which had a document. + let _ = store_settings(s, settings); +} + +/// Make `settings` the document's export settings. Not an undo step (like the settings of any +/// export); the document has changed only if they differ. +pub(crate) fn store_settings(s: &mut Session, settings: Map) -> Result<()> { + let st = s.doc_mut()?; if st.doc.export_settings != settings { Arc::make_mut(&mut st.doc).export_settings = settings; st.revision += 1; } + Ok(()) } /// `document.exportSettings`: the Export for Screens settings the document last exported with. diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 45378e66..f81e023e 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -1,6 +1,7 @@ //! The command registry. Ids follow Illustrator's menu structure. pub(crate) mod appearance; +mod assets; mod attributes; mod brushsym; mod buildcmds; @@ -235,6 +236,7 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(recovery::specs()); v.extend(fileio::dxfimport::specs()); v.extend(webexport::specs()); + v.extend(assets::specs()); v }) } diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 29103d00..ed569292 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -957,6 +957,10 @@ impl Session { if !st.doc.text_threads.is_empty() { cmd::threads::reflow(&before, Arc::make_mut(&mut st.doc)); } + // Asset Export: assets let go of deleted art. + if !st.doc.assets.is_empty() { + Arc::make_mut(&mut st.doc).prune_assets(); + } if doc_sane(&st.doc, &st.selection) { Ok(v) } else { @@ -1078,6 +1082,8 @@ mod tests; #[cfg(test)] mod tests_appearance; #[cfg(test)] +mod tests_assets; +#[cfg(test)] mod tests_attributes; #[cfg(test)] mod tests_brushsym; diff --git a/crates/engine/src/tests_assets.rs b/crates/engine/src/tests_assets.rs new file mode 100644 index 00000000..2a674b15 --- /dev/null +++ b/crates/engine/src/tests_assets.rs @@ -0,0 +1,267 @@ +//! Asset Export: collecting art as assets (single and multiple), assets following their art +//! (moves, deletions), names, undo, the native round trip, the shared export settings and +//! exporting assets alone, cropped to their art. + +use serde_json::{Value, json}; +use vectorcraft_color::{Color, Paint}; + +use super::*; + +fn session() -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 200, "height": 200})).unwrap(); + // Unstroked art: visual bounds are the geometry. + s.paint.stroke = Paint::None; + s +} + +fn run(s: &mut Session, id: &str, p: Value) -> Value { + s.execute(id, &p).unwrap_or_else(|e| panic!("{id}: {e}")) +} + +fn rect(s: &mut Session, x: f64, y: f64, w: f64, h: f64) -> u64 { + run(s, "shape.rectangle", json!({"x": x, "y": y, "width": w, "height": h}))["id"].as_u64().unwrap() +} + +fn assets(s: &mut Session) -> Vec { + run(s, "assets.list", json!({}))["assets"].as_array().unwrap().clone() +} + +fn names(s: &mut Session) -> Vec { + assets(s).iter().map(|a| a["name"].as_str().unwrap().to_string()).collect() +} + +fn undo_len(s: &Session) -> usize { + s.doc().unwrap().history.undo.len() +} + +/// `(name, bytes)` of each file an export returned. +fn files(r: &Value) -> Vec<(String, Vec)> { + r["files"] + .as_array() + .unwrap() + .iter() + .map(|f| (f["name"].as_str().unwrap().to_string(), vectorcraft_format::base64_decode(f["dataBase64"].as_str().unwrap()).unwrap())) + .collect() +} + +#[test] +fn multiple_gives_an_asset_per_object_and_single_one_of_them_all() { + let mut s = session(); + let (a, b) = (rect(&mut s, 10.0, 10.0, 20.0, 20.0), rect(&mut s, 50.0, 10.0, 30.0, 20.0)); + run(&mut s, "select.set", json!({"ids": [a, b]})); + let r = run(&mut s, "assets.add", json!({})); + 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]))); + assert_eq!(r["assets"], json!([list[0]["id"], list[1]["id"]])); + let single = run(&mut s, "assets.add", json!({"multiple": false})); + assert_eq!(single["added"], 1); + let list = assets(&mut s); + assert_eq!(list.len(), 3); + assert_eq!(list[2]["nodes"], json!([a, b]), "one asset of both, back to front"); + assert_eq!(list[2]["bounds"], json!([10.0, 10.0, 70.0, 20.0]), "the crop is the art's bounds"); + // Collecting the same art again keeps its assets (and isn't an undo step). + let steps = undo_len(&s); + 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. + let layer = s.doc().unwrap().doc.layers[0].id.0; + assert!(s.execute("assets.add", &json!({"ids": [layer]})).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 deleting_the_object_prunes_it_and_undo_brings_it_back() { + let mut s = session(); + let (a, b) = (rect(&mut s, 10.0, 10.0, 20.0, 20.0), rect(&mut s, 50.0, 10.0, 30.0, 20.0)); + run(&mut s, "assets.add", json!({"ids": [a]})); + run(&mut s, "assets.add", json!({"ids": [a, b], "multiple": false})); + run(&mut s, "select.set", json!({"ids": [a]})); + run(&mut s, "edit.clear", json!({})); + let list = assets(&mut s); + assert_eq!(list.len(), 1, "the asset of the deleted object goes"); + assert_eq!(list[0]["nodes"], json!([b]), "the other keeps the art that is left"); + run(&mut s, "edit.undo", json!({})); + let list = assets(&mut s); + assert_eq!((list.len(), list[1]["nodes"].clone()), (2, json!([a, b]))); +} + +#[test] +fn moving_the_art_moves_the_crop() { + let mut s = session(); + let a = rect(&mut s, 10.0, 10.0, 20.0, 20.0); + run(&mut s, "assets.add", json!({"ids": [a]})); + let before = files(&run(&mut s, "assets.export", json!({}))); + run(&mut s, "select.set", json!({"ids": [a]})); + run(&mut s, "object.move", json!({"dx": 40, "dy": 25})); + assert_eq!(assets(&mut s)[0]["bounds"], json!([50.0, 35.0, 20.0, 20.0])); + let after = files(&run(&mut s, "assets.export", json!({}))); + assert_eq!(before, after, "the same art, cropped the same way"); + assert_eq!(after[0].0, "Asset-1.png"); +} + +#[test] +fn an_asset_exports_its_art_alone() { + let mut s = session(); + s.paint.fill = Paint::solid(Color::rgb(1.0, 0.0, 0.0)); + let red = rect(&mut s, 10.0, 10.0, 20.0, 20.0); + // Blue art over it isn't part of the asset. + s.paint.fill = Paint::solid(Color::rgb(0.0, 0.0, 1.0)); + rect(&mut s, 0.0, 0.0, 25.0, 25.0); + run(&mut s, "assets.add", json!({"ids": [red]})); + let out = files(&run(&mut s, "assets.export", json!({"formats": [{"format": "png", "scale": "2x"}]}))); + assert_eq!(out[0].0, "Asset-1@2x.png"); + let img = image::load_from_memory(&out[0].1).unwrap().to_rgba8(); + assert_eq!(img.dimensions(), (40, 40)); + assert!(img.pixels().all(|p| p.0 == [255, 0, 0, 255]), "only the red square"); + // Vector formats crop to it too. + let svg = files(&run(&mut s, "assets.export", json!({"formats": [{"format": "svg"}]}))); + let text = String::from_utf8(svg[0].1.clone()).unwrap(); + assert_eq!(svg[0].0, "Asset-1.svg"); + assert!(text.contains("viewBox=\"10 10 20 20\"") || text.contains("viewBox=\"0 0 20 20\""), "{text}"); + assert!(!text.to_lowercase().contains("#0000ff"), "no blue: {text}"); +} + +#[test] +fn names_follow_the_object_or_count_up_and_can_be_changed() { + let mut s = session(); + let (a, b, c) = (rect(&mut s, 10.0, 10.0, 20.0, 20.0), rect(&mut s, 50.0, 10.0, 20.0, 20.0), rect(&mut s, 90.0, 10.0, 20.0, 20.0)); + run(&mut s, "layer.setProps", json!({"id": b, "name": "Icon"})); + run(&mut s, "assets.add", json!({"ids": [a, b]})); + assert_eq!(names(&mut s), ["Asset 1", "Icon"]); + let first = assets(&mut s)[0]["id"].as_u64().unwrap(); + run(&mut s, "assets.rename", json!({"asset": first, "name": " Asset 7 "})); + run(&mut s, "assets.add", json!({"ids": [c]})); + assert_eq!(names(&mut s), ["Asset 7", "Icon", "Asset 8"], "trimmed; numbering goes on from the highest"); + run(&mut s, "assets.add", json!({"ids": [a, c], "multiple": false, "name": "Pair"})); + assert_eq!(names(&mut s)[3], "Pair"); + assert!(s.execute("assets.rename", &json!({"asset": first, "name": " "})).is_err()); + assert!(s.execute("assets.rename", &json!({"asset": 999_999, "name": "X"})).is_err()); + // Assets that share a name (in any case) get -2, -3… instead of overwriting. + let ids: Vec = assets(&mut s).iter().map(|a| a["id"].as_u64().unwrap()).collect(); + run(&mut s, "assets.rename", json!({"asset": ids[0], "name": "icon"})); + let out = files(&run(&mut s, "assets.export", json!({"assets": [ids[0], ids[1]]}))); + assert_eq!(out.iter().map(|f| f.0.as_str()).collect::>(), ["icon.png", "Icon-2.png"]); +} + +#[test] +fn add_remove_and_rename_are_undo_steps() { + let mut s = session(); + let a = rect(&mut s, 10.0, 10.0, 20.0, 20.0); + let steps = undo_len(&s); + let id = run(&mut s, "assets.add", json!({"ids": [a]}))["assets"][0].as_u64().unwrap(); + run(&mut s, "assets.rename", json!({"asset": id, "name": "Logo"})); + run(&mut s, "assets.remove", json!({"assets": [id]})); + assert_eq!(undo_len(&s), steps + 3); + assert!(assets(&mut s).is_empty()); + run(&mut s, "edit.undo", json!({})); + assert_eq!(names(&mut s), ["Logo"]); + run(&mut s, "edit.undo", json!({})); + assert_eq!(names(&mut s), ["Asset 1"]); + run(&mut s, "edit.undo", json!({})); + assert!(assets(&mut s).is_empty()); + run(&mut s, "edit.redo", json!({})); + assert_eq!(names(&mut s), ["Asset 1"]); + assert!(s.execute("assets.remove", &json!({"assets": [12345]})).is_err()); + assert!(s.execute("assets.remove", &json!({})).is_err()); +} + +#[test] +fn assets_round_trip_through_the_native_format() { + let mut s = session(); + let (a, b) = (rect(&mut s, 10.0, 10.0, 20.0, 20.0), rect(&mut s, 50.0, 10.0, 30.0, 20.0)); + run(&mut s, "assets.add", json!({"ids": [a, b], "multiple": false, "name": "Both"})); + let saved = s.doc().unwrap().doc.assets.clone(); + let b64 = run(&mut s, "document.serialize", json!({"format": "vectorcraft"}))["dataBase64"].as_str().unwrap().to_string(); + run(&mut s, "document.open", json!({"name": "assets.vectorcraft", "dataBase64": b64})); + assert_eq!(s.doc().unwrap().doc.assets, saved); + // A new asset never takes an id already used. + let c = rect(&mut s, 90.0, 10.0, 20.0, 20.0); + let id = run(&mut s, "assets.add", json!({"ids": [c]}))["assets"][0].as_u64().unwrap(); + assert!(saved.iter().all(|x| x.id != id)); + // Documents without assets don't write the key, and files without it load. + let mut d = vectorcraft_doc::Document::new(10.0, 10.0); + let text = serde_json::to_string(&d).unwrap(); + assert!(!text.contains("\"assets\"")); + d = serde_json::from_str(&text).unwrap(); + assert!(d.assets.is_empty()); +} + +#[test] +fn the_shared_settings_drive_the_export_and_arent_an_undo_step() { + let mut s = session(); + let a = rect(&mut s, 10.0, 10.0, 20.0, 20.0); + run(&mut s, "assets.add", json!({"ids": [a]})); + let steps = undo_len(&s); + let r = run(&mut s, "assets.settings.set", json!({"formats": [{"format": "png", "scale": "1x"}, {"format": "svg"}], "prefix": "ic_"})); + assert_eq!(r["settings"]["prefix"], "ic_"); + assert_eq!(undo_len(&s), steps); + assert!(s.doc().unwrap().is_dirty()); + // Export for Screens reads them too (they are its remembered settings). + assert_eq!(run(&mut s, "document.exportSettings", json!({}))["settings"]["formats"][1]["format"], "svg"); + let out = files(&run(&mut s, "assets.export", json!({}))); + assert_eq!(out.iter().map(|f| f.0.as_str()).collect::>(), ["ic_Asset-1.png", "ic_Asset-1.svg"]); + // A preset replaces the rows (and turns on sub-folders); rows replace a preset. + run(&mut s, "assets.settings.set", json!({"preset": "mobile"})); + let settings = run(&mut s, "document.exportSettings", json!({}))["settings"].clone(); + assert!(settings.get("formats").is_none() && settings["preset"] == "mobile"); + let out = files(&run(&mut s, "assets.export", json!({}))); + assert_eq!(out.iter().map(|f| f.0.as_str()).collect::>(), ["1x/ic_Asset-1.png", "2x/ic_Asset-1@2x.png", "3x/ic_Asset-1@3x.png"]); + run(&mut s, "assets.settings.set", json!({"formats": [{"format": "jpg", "quality": 80}], "preset": ""})); + let settings = run(&mut s, "document.exportSettings", json!({}))["settings"].clone(); + assert!(settings.get("preset").is_none() && settings["formats"][0]["format"] == "jpg"); + // Params given to one export win without changing the settings. + let out = files(&run(&mut s, "assets.export", json!({"formats": [{"format": "webp"}], "prefix": null}))); + assert_eq!(out[0].0, "Asset-1.webp"); + assert_eq!(run(&mut s, "document.exportSettings", json!({}))["settings"]["formats"][0]["format"], "jpg"); + // Bad settings are refused and change nothing. + for bad in [json!({"formats": [{"format": "dxf"}]}), json!({"preset": "tiny"}), json!({"folder": "/tmp"}), json!([])] { + assert!(s.execute("assets.settings.set", &bad).is_err(), "{bad}"); + } + assert_eq!(run(&mut s, "document.exportSettings", json!({}))["settings"]["formats"][0]["format"], "jpg"); +} + +#[test] +fn export_for_screens_writes_chosen_assets_and_keeps_its_rules() { + let mut s = session(); + let (a, b) = (rect(&mut s, 10.0, 10.0, 20.0, 20.0), rect(&mut s, 50.0, 10.0, 30.0, 20.0)); + let ids = run(&mut s, "assets.add", json!({"ids": [a, b]}))["assets"].clone(); + let r = run(&mut s, "document.exportForScreens", json!({"assets": [ids[1]], "formats": [{"format": "png", "scale": "60w"}]})); + let out = files(&r); + assert_eq!(out.len(), 1); + assert_eq!(out[0].0, "Asset-2@60w.png"); + assert_eq!(image::load_from_memory(&out[0].1).unwrap().to_rgba8().dimensions(), (60, 40), "the width of the asset, not the artboard"); + // The choice of art isn't remembered; the formats are. + let settings = run(&mut s, "document.exportSettings", json!({}))["settings"].clone(); + assert!(settings.get("assets").is_none() && settings["formats"][0]["scale"] == "60w"); + // A zip of every asset. + let zip = run(&mut s, "document.exportForScreens", json!({"assets": ids, "zip": true})); + assert_eq!(zip["files"], json!(["Asset-1.png", "Asset-2.png"])); + for bad in [json!({"assets": [ids[0]], "fullDocument": true}), json!({"assets": [424242]}), json!({"assets": []}), json!({"assets": "all"})] { + assert!(s.execute("document.exportForScreens", &bad).is_err(), "{bad}"); + } + // No assets: nothing for assets.export to do. + run(&mut s, "assets.remove", json!({"assets": ids})); + assert!(s.execute("assets.export", &json!({})).is_err()); +} + +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn assets_export_into_a_folder() { + let dir = std::env::temp_dir().join(format!("vc-assets-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let folder = dir.to_string_lossy().replace('\\', "/"); + let mut s = session(); + let a = rect(&mut s, 10.0, 10.0, 20.0, 20.0); + run(&mut s, "assets.add", json!({"ids": [a], "name": "Logo"})); + let r = + run(&mut s, "assets.export", json!({"folder": folder, "subfolders": true, "formats": [{"format": "png", "scale": "2x"}, {"format": "pdf"}]})); + assert_eq!(r["files"], json!([format!("{folder}/2x/Logo@2x.png"), format!("{folder}/PDF/Logo.pdf")])); + assert!(dir.join("2x/Logo@2x.png").is_file() && dir.join("PDF/Logo.pdf").is_file()); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/mcp/src/headless.rs b/crates/mcp/src/headless.rs index 104bbe51..1bd0b46f 100644 --- a/crates/mcp/src/headless.rs +++ b/crates/mcp/src/headless.rs @@ -35,7 +35,7 @@ const HOST_COMMANDS: &[(&str, &str, &str)] = &[ ( "file.exportForScreens", "Export for Screens…", - "{folder?, zip?, artboards? | range? | fullDocument?, includeBleed?, subfolders?, preset?, formats?, settings?, prefix?} = document.exportForScreens (no folder → the files, or one zip, as dataBase64)", + "{folder?, zip?, artboards? | range? | fullDocument? | assets?, includeBleed?, subfolders?, preset?, formats?, settings?, prefix?} = document.exportForScreens (no folder → the files, or one zip, as dataBase64)", ), ("tool.select", "Select Tool", "{tool} e.g. selection, directSelection, pen, rectangle, ellipse, polygon, star, lineSegment"), ]; diff --git a/crates/ui-egui/src/dialogs/export_for_screens.rs b/crates/ui-egui/src/dialogs/export_for_screens.rs index 6db7141b..a0beef9f 100644 --- a/crates/ui-egui/src/dialogs/export_for_screens.rs +++ b/crates/ui-egui/src/dialogs/export_for_screens.rs @@ -1,14 +1,16 @@ //! File → Export → Export for Screens: the Artboards tab (All, a range or Full Document, Include -//! Bleed, thumbnails with checkboxes) and the Assets tab, the destination (folder picker, Open -//! Location after Export, Create Sub-folders; the web downloads instead), the format rows (scale -//! as `2x`, `100w`, `100h` or `72ppi`, suffix, format), the presets, Format Settings (the gear) -//! and the prefix. It opens on the settings the document last exported with. +//! Bleed, thumbnails with checkboxes) and the Assets tab (the Asset Export panel's assets, with +//! checkboxes), the destination (folder picker, Open Location after Export, Create Sub-folders; +//! the web downloads instead), the format rows (scale as `2x`, `100w`, `100h` or `72ppi`, suffix, +//! format), the presets, Format Settings (the gear) and the prefix. It opens on the settings the +//! document last exported with; the format rows are the Asset Export panel's too ([`formats`]). //! //! Fields (`ui.dialog.set`): `tab` (artboards|assets), `select` (all|range|full), `range`, -//! `boards` ([bool] per artboard), `includeBleed`, `folder`, `openLocation`, `subfolders`, -//! `prefix`, `preset` (""|mobile|density: where the rows came from), `formats` (rows of -//! `document.exportForScreens`, `scale` as text), `settings` ({png|png8|jpg|webp|gif|svg|pdf: {…}}); -//! `__settings` names the format whose Format Settings show ("" when closed). +//! `boards` ([bool] per artboard), `assets` ([asset id] checked on the Assets tab), `includeBleed`, +//! `folder`, `openLocation`, `subfolders`, `prefix`, `preset` (""|mobile|density: where the rows +//! came from), `formats` (rows of `document.exportForScreens`, `scale` as text), `settings` +//! ({png|png8|jpg|webp|gif|svg|pdf: {…}}); `__settings` names the format whose Format Settings +//! show ("" when closed). use serde::Deserialize; use serde_json::{Map, Value, json}; @@ -24,7 +26,11 @@ pub(super) const SPEC: DialogSpec = DialogSpec { body, confirm, ok: Some("Export Artboard"), - ok_label: Some(|app| if io::is_web(app) { "Download" } else { "Export Artboard" }), + ok_label: Some(|app| match (io::is_web(app), app.ui.dialog.as_ref().is_some_and(|d| d.str("tab") == "assets")) { + (true, _) => "Download", + (false, true) => "Export Asset", + (false, false) => "Export Artboard", + }), min_width: 660.0, ..DialogSpec::FORM }; @@ -54,10 +60,38 @@ const SETTINGS_LABELS: [&str; 7] = ["PNG", "PNG 8", "JPG", "WebP", "GIF", "SVG", /// The sizes a row's scale list offers (any can be typed). const SIZES: [&str; 12] = ["0.5x", "0.75x", "1x", "1.5x", "2x", "3x", "4x", "512w", "512h", "72ppi", "150ppi", "300ppi"]; +/// The format rows of export settings `saved` (`document.exportSettings`), sizes shown as text: +/// the saved rows, else the saved preset's, else one PNG at 1x; and that preset. +pub(crate) fn saved_rows(saved: &Map) -> (Vec, Option<&str>) { + let get = |k: &str| saved.get(k).filter(|v| !v.is_null()); + let preset = get("preset").and_then(Value::as_str).filter(|p| fileio::screen_preset_rows(p).is_some()); + let rows = match (get("formats").and_then(Value::as_array), preset) { + (Some(rows), _) if rows.iter().any(Value::is_object) => rows.iter().filter(|r| r.is_object()).map(editable_row).collect(), + (_, Some(p)) => fileio::screen_preset_rows(p).unwrap_or_default(), + _ => vec![json!({"format": "png", "scale": "1x", "suffix": ""})], + }; + (rows, preset) +} + +/// Open the dialog on its Assets tab with `checked` assets checked (every asset when `None`). +pub(crate) fn open_assets(app: &mut VectorcraftApp, checked: Option<&[u64]>) { + open(app); + if let Some(d) = app.ui.dialog.as_mut() { + d.fields.insert("tab".into(), json!("assets")); + if let Some(c) = checked { + d.fields.insert("assets".into(), json!(c)); + } + } +} + /// Open the dialog on the settings the active document last exported with (else one PNG row at -/// 1x, into the Desktop). +/// 1x, into the Desktop), every asset checked. pub fn open(app: &mut VectorcraftApp) { - let (n, saved) = app.session.active().map(|st| (st.doc.artboards.len(), st.doc.export_settings.clone())).unwrap_or_default(); + let (n, saved, assets) = app + .session + .active() + .map(|st| (st.doc.artboards.len(), st.doc.export_settings.clone(), st.doc.assets.iter().map(|a| a.id).collect::>())) + .unwrap_or_default(); let get = |k: &str| saved.get(k).filter(|v| !v.is_null()); let flag = |k: &str, default: bool| get(k).and_then(Value::as_bool).unwrap_or(default); // The artboards: Full Document, a range (or a list of them), else all. @@ -68,13 +102,7 @@ pub fn open(app: &mut VectorcraftApp) { _ => "all", }; let boards: Vec = (0..n).map(|i| chosen.as_ref().is_none_or(|c| c.contains(&i))).collect(); - // The rows: the saved ones (or the saved preset's), sizes shown as text. - let preset = get("preset").and_then(Value::as_str).filter(|p| fileio::screen_preset_rows(p).is_some()); - let rows = match (get("formats").and_then(Value::as_array), preset) { - (Some(rows), _) if rows.iter().any(Value::is_object) => rows.iter().filter(|r| r.is_object()).map(editable_row).collect(), - (_, Some(p)) => fileio::screen_preset_rows(p).unwrap_or_default(), - _ => vec![json!({"format": "png", "scale": "1x", "suffix": ""})], - }; + let (rows, preset) = saved_rows(&saved); // Every format's settings: its defaults, then the saved ones. let mut settings = Map::new(); for (id, _) in SETTINGS { @@ -90,6 +118,7 @@ pub fn open(app: &mut VectorcraftApp) { "select": select, "range": range_text(&boards), "boards": boards, + "assets": assets, "includeBleed": flag("includeBleed", false), "folder": folder.unwrap_or_default(), "openLocation": flag("openLocation", true), @@ -169,20 +198,32 @@ fn params(app: &VectorcraftApp, d: &Dialog) -> Result { return Err("add a format to export".into()); } let mut p = Map::new(); - let files = match d.str("select").as_str() { - "full" => { - p.insert("fullDocument".into(), json!(true)); - rows.len() + // What each row writes a file of: the checked assets on the Assets tab, else the artboards. + let pieces = if d.str("tab") == "assets" { + let checked = checked_assets(app, d); + if checked.is_empty() { + return Err("check an asset to export".into()); } - "range" => { - let range = d.str("range"); - // A bad range keeps the dialog open. - let chosen = ArtboardPick { range: Some(range.clone()), ..Default::default() }.resolve(n)?.unwrap_or_default(); - p.insert("range".into(), json!(range)); - chosen.len() * rows.len() + let count = checked.len(); + p.insert("assets".into(), json!(checked)); + count + } else { + match d.str("select").as_str() { + "full" => { + p.insert("fullDocument".into(), json!(true)); + 1 + } + "range" => { + let range = d.str("range"); + // A bad range keeps the dialog open. + let chosen = ArtboardPick { range: Some(range.clone()), ..Default::default() }.resolve(n)?.unwrap_or_default(); + p.insert("range".into(), json!(range)); + chosen.len() + } + _ => n, } - _ => n * rows.len(), }; + let files = pieces * rows.len(); for k in ["includeBleed", "subfolders"] { p.insert(k.into(), json!(d.bool(k))); } @@ -223,8 +264,7 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { ui.vertical(|ui| { ui.set_width(270.0); if tab == 1 { - ui.add_space(24.0); - widgets::dim_label(ui, "No assets to export yet: artwork collected for export shows here."); + assets(app, ui, d); } else { artboards(app, ui, d); } @@ -240,6 +280,11 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { destination(app, ui, d); ui.add_space(10.0); formats(ui, d); + ui.add_space(6.0); + ui.horizontal(|ui| { + widgets::dim_label(ui, "Prefix:"); + form::text(ui, d, "prefix", 140.0); + }); } }); }); @@ -325,6 +370,50 @@ fn artboards(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) { } } +/// The checked assets that are still in the document, in panel order. +fn checked_assets(app: &VectorcraftApp, d: &Dialog) -> Vec { + let on: Vec = d.fields.get("assets").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_u64).collect()).unwrap_or_default(); + app.session.active().map(|st| st.doc.assets.iter().map(|a| a.id).filter(|id| on.contains(id)).collect()).unwrap_or_default() +} + +/// The Assets tab: each asset's thumbnail and checkbox, Select All and Clear. +fn assets(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) { + let Some(st) = app.session.active() else { return }; + if st.doc.assets.is_empty() { + ui.add_space(24.0); + widgets::dim_label(ui, "No assets yet. Collect art with Object › Collect for Export, or drag it into the Asset Export panel."); + return; + } + let mut on = checked_assets(app, d); + let before = on.clone(); + ui.horizontal(|ui| { + if ui.small_button("Select All").clicked() { + on = st.doc.assets.iter().map(|a| a.id).collect(); + } + if ui.small_button("Clear").clicked() { + on.clear(); + } + }); + egui::ScrollArea::vertical().max_height(330.0).show(ui, |ui| { + for a in &st.doc.assets { + ui.horizontal(|ui| { + let (r, _) = ui.allocate_exact_size(egui::vec2(46.0, 46.0), egui::Sense::hover()); + crate::panels::asset_export::paint_thumb(ui, st, a, r); + let mut checked = on.contains(&a.id); + if ui.checkbox(&mut checked, a.name.as_str()).changed() { + on.retain(|id| *id != a.id); + if checked { + on.push(a.id); + } + } + }); + } + }); + if on != before { + d.fields.insert("assets".into(), json!(on)); + } +} + /// Export to: the folder (typed or picked), Open Location after Export and Create Sub-folders; the /// web downloads instead. fn destination(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) { @@ -347,9 +436,9 @@ fn destination(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) { form::check(ui, d, "subfolders", "Create Sub-folders"); } -/// The preset, the Format Settings gear, the format rows (scale, suffix, format), Add Scale and the -/// prefix. -fn formats(ui: &mut egui::Ui, d: &mut Dialog) { +/// The preset, the Format Settings gear (which sets `__settings`), the format rows (scale, suffix, +/// format) and Add Scale, as wide as `ui` allows: the dialog's and the Asset Export panel's rows. +pub(crate) fn formats(ui: &mut egui::Ui, d: &mut Dialog) { let mut rows: Vec = d.fields.get("formats").and_then(Value::as_array).cloned().unwrap_or_default(); let mut edited = false; ui.horizontal(|ui| { @@ -362,7 +451,7 @@ fn formats(ui: &mut egui::Ui, d: &mut Dialog) { let preset = d.str("preset"); let labels: Vec<&str> = std::iter::once("Custom").chain(SCREEN_PRESETS.iter().map(|(_, l)| *l)).collect(); let at = SCREEN_PRESETS.iter().position(|(id, _)| *id == preset).map_or(0, |i| i + 1); - if let Some(i) = widgets::dropdown(ui, "efs-preset", labels[at], &labels, 180.0) { + if let Some(i) = widgets::dropdown(ui, "efs-preset", labels[at], &labels, (ui.available_width() - 8.0).clamp(90.0, 180.0)) { let id = i.checked_sub(1).and_then(|i| SCREEN_PRESETS.get(i)).map_or("", |(id, _)| *id); if let Some(preset_rows) = fileio::screen_preset_rows(id) { rows = preset_rows; @@ -375,6 +464,10 @@ fn formats(ui: &mut egui::Ui, d: &mut Dialog) { }); let mut remove = None; let one = rows.len() == 1; + // The columns at the dialog's widths, narrower in a narrower column (the panel): the three + // fields share what the remove buttons, the spacing and the fields' frames leave. + let k = ((ui.available_width() - 66.0) / 262.0).clamp(0.6, 1.0); + let (scale_w, suffix_w, format_w) = (96.0 * k, 70.0 * k, 96.0 * k); egui::Grid::new("efs-formats").num_columns(4).spacing([8.0, 6.0]).show(ui, |ui| { for head in ["Scale", "Suffix", "Format", ""] { widgets::dim_label(ui, head); @@ -384,7 +477,7 @@ fn formats(ui: &mut egui::Ui, d: &mut Dialog) { let raster = row["format"].as_str().and_then(fileio::format).is_none_or(|f| f.raster); let scale = row["scale"].as_str().unwrap_or("1x").to_string(); ui.add_enabled_ui(raster, |ui| { - if let Some(s) = widgets::text_presets(ui, ("efs-scale", i), &scale, &SIZES, 96.0) { + if let Some(s) = widgets::text_presets(ui, ("efs-scale", i), &scale, &SIZES, scale_w) { // The suffix follows the size while it is the automatic one. let auto = |text: &str| ScreenSize::parse(text).map(ScreenSize::suffix); if row["suffix"].as_str().is_none_or(|x| Some(x.to_string()) == auto(&scale)) @@ -396,14 +489,14 @@ fn formats(ui: &mut egui::Ui, d: &mut Dialog) { edited = true; } }); - if let Some(s) = widgets::text_field(ui, ("efs-suffix", i), row["suffix"].as_str(), 70.0, 1) { + if let Some(s) = widgets::text_field(ui, ("efs-suffix", i), row["suffix"].as_str(), suffix_w, 1) { row["suffix"] = json!(s); edited = true; } let (format, quality) = (row["format"].as_str().unwrap_or("png"), row["quality"].as_u64()); let current = FORMATS.iter().position(|(_, f, q)| *f == format && q.is_none_or(|q| Some(u64::from(q)) == quality)); let label = current.map_or_else(|| format.to_uppercase(), |i| FORMAT_LABELS[i].to_string()); - if let Some((_, f, q)) = widgets::dropdown(ui, ("efs-fmt", i), &label, &FORMAT_LABELS, 96.0).and_then(|i| FORMATS.get(i)) { + if let Some((_, f, q)) = widgets::dropdown(ui, ("efs-fmt", i), &label, &FORMAT_LABELS, format_w).and_then(|i| FORMATS.get(i)) { row["format"] = json!(f); if let Some(o) = row.as_object_mut() { match q { @@ -443,11 +536,6 @@ fn formats(ui: &mut egui::Ui, d: &mut Dialog) { d.fields.insert("formats".into(), json!(rows)); d.fields.insert("preset".into(), json!("")); } - ui.add_space(6.0); - ui.horizontal(|ui| { - widgets::dim_label(ui, "Prefix:"); - form::text(ui, d, "prefix", 140.0); - }); } /// Format Settings: the options of format `id` that apply to every row of it, then Done. diff --git a/crates/ui-egui/src/dialogs/mod.rs b/crates/ui-egui/src/dialogs/mod.rs index 0cbadb69..539d8724 100644 --- a/crates/ui-egui/src/dialogs/mod.rs +++ b/crates/ui-egui/src/dialogs/mod.rs @@ -73,6 +73,9 @@ pub use document_setup::open as open_document_setup; pub use effect::open as open_effect_dialog; pub use export_as::open as open_export_as; pub use export_for_screens::open as open_export_for_screens; +pub(crate) use export_for_screens::{ + KIND as EXPORT_FOR_SCREENS, formats as screen_formats, open_assets as open_export_for_screens_assets, saved_rows as screen_saved_rows, +}; pub use new_document::{open as open_new_document, preset_card}; pub use png_options::open as open_raster_options; pub use save_pdf::{open as open_save_pdf, open_preset as open_pdf_preset}; @@ -344,6 +347,8 @@ mod tests_raster_formats; #[cfg(test)] mod tests_dxf; #[cfg(test)] +mod tests_screen_assets; +#[cfg(test)] mod tests_screens; #[cfg(test)] diff --git a/crates/ui-egui/src/dialogs/tests_screen_assets.rs b/crates/ui-egui/src/dialogs/tests_screen_assets.rs new file mode 100644 index 00000000..feedb177 --- /dev/null +++ b/crates/ui-egui/src/dialogs/tests_screen_assets.rs @@ -0,0 +1,100 @@ +//! Export for Screens' Assets tab, drawn headlessly: the checked assets, the OK label, desktop +//! folders and web downloads. + +use std::cell::RefCell; +use std::rc::Rc; + +use serde_json::{Value, json}; +use vectorcraft_engine::Session; + +use super::tests_export::{frame, kind, set}; +use super::*; +use crate::Services; +use crate::tests_labels::painted_text; + +type Log = Rc)>>>; + +/// An app with two unstroked squares (selected); `web` gives it a download service (recorded in +/// the log) instead of a file manager. +fn app(web: bool) -> (VectorcraftApp, Log, [u64; 2]) { + let log = Log::default(); + let l = log.clone(); + let mut services = Services::default(); + if web { + services.download = Some(Box::new(move |name: &str, bytes: &[u8]| l.borrow_mut().push((name.to_string(), bytes.to_vec())))); + } else { + services.reveal = Some(Box::new(move |path: &str| { + l.borrow_mut().push((path.to_string(), vec![])); + Ok(()) + })); + } + let mut app = VectorcraftApp::new(Session::new(), services); + app.run("file.new", json!({"width": 200, "height": 100})).unwrap(); + app.session.paint.stroke = vectorcraft_color::Paint::None; + let mut rect = |x: f64| app.run("shape.rectangle", json!({"x": x, "y": 10, "width": 30, "height": 20})).unwrap()["id"].as_u64().unwrap(); + let ids = [rect(10.0), rect(100.0)]; + app.run("select.set", json!({"ids": ids})).unwrap(); + (app, log, ids) +} + +fn field(app: &VectorcraftApp, k: &str) -> Value { + app.ui.dialog.as_ref().expect("a dialog is open").fields.get(k).cloned().unwrap_or(Value::Null) +} + +fn assets(app: &mut VectorcraftApp) -> Vec { + app.run("assets.list", json!({})).unwrap()["assets"].as_array().unwrap().clone() +} + +#[test] +fn the_assets_tab_exports_the_checked_assets_into_the_folder() { + let (mut app, log, _) = app(false); + app.run("assets.add", json!({})).unwrap(); + let ids: Vec = assets(&mut app).iter().map(|a| a["id"].as_u64().unwrap()).collect(); + app.run("file.exportForScreens", json!({})).unwrap(); + assert_eq!((field(&app, "tab"), field(&app, "assets")), (json!("artboards"), json!(ids)), "every asset checked"); + assert_eq!((spec(export_for_screens::KIND).ok_label.unwrap())(&app), "Export Artboard"); + set(&mut app, "tab", json!("assets")); + assert_eq!((spec(export_for_screens::KIND).ok_label.unwrap())(&app), "Export Asset"); + frame(&mut app); + // Nothing checked keeps the dialog open. + set(&mut app, "assets", json!([])); + assert!(confirm(&mut app).is_err()); + assert_eq!(kind(&app), Some(export_for_screens::KIND)); + let dir = std::env::temp_dir().join(format!("vc-efs-assets-{}", std::process::id())); + let folder = dir.to_string_lossy().replace('\\', "/"); + set(&mut app, "folder", json!(folder)); + set(&mut app, "assets", json!([ids[1], 999])); + set(&mut app, "formats", json!([{"format": "png", "scale": "2x", "suffix": "@2x"}])); + let r = confirm(&mut app).unwrap(); + assert_eq!(r["files"], json!([format!("{folder}/Asset-2@2x.png")])); + let png = image::load_from_memory(&std::fs::read(dir.join("Asset-2@2x.png")).unwrap()).unwrap(); + assert_eq!((png.width(), png.height()), (60, 40), "the asset's art at 2x"); + assert_eq!(log.borrow().len(), 1, "Open Location shows the file"); + // The rows are remembered for the dialog and the Asset Export panel alike. + assert_eq!(app.run("document.exportSettings", json!({})).unwrap()["settings"]["formats"][0]["scale"], "2x"); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn an_empty_assets_tab_says_how_to_collect_art() { + let (mut app, _, _) = app(false); + app.run("file.exportForScreens", json!({})).unwrap(); + set(&mut app, "tab", json!("assets")); + let text = painted_text(&mut app, |app, ui| super::show(app, ui.ctx())); + assert!(text.contains("Collect for Export"), "{text}"); +} + +#[test] +fn the_menus_reach_asset_export() { + let (mut app, _, _) = app(false); + let entries = crate::menus::menu_entries(&app); + let find = |cmd: &str, label: &str| entries.iter().find(|e| e.command.as_deref() == Some(cmd) && e.label == label).map(|e| e.path.clone()); + assert_eq!(find("assets.add", "As Single Asset"), Some(vec!["Object".to_string(), "Collect for Export".to_string()])); + assert_eq!(find("assets.add", "As Multiple Assets"), Some(vec!["Object".to_string(), "Collect for Export".to_string()])); + let window = entries.iter().find(|e| e.label == "Asset Export").unwrap(); + assert_eq!(window.params, json!({"panel": crate::panels::asset_export::ID})); + app.run("window.panel", window.params.clone()).unwrap(); + assert_eq!(app.ui.open_panel.as_deref(), Some(crate::panels::asset_export::ID)); + app.run("assets.add", json!({"multiple": false})).unwrap(); + assert_eq!(assets(&mut app).len(), 1); +} diff --git a/crates/ui-egui/src/io.rs b/crates/ui-egui/src/io.rs index 5dcc0c8d..f7ec064e 100644 --- a/crates/ui-egui/src/io.rs +++ b/crates/ui-egui/src/io.rs @@ -523,9 +523,14 @@ pub(crate) fn open_in_app(app: &mut VectorcraftApp, path: &str) -> Result<(), St /// without one the web downloads them (the ZIP, or each file) and the other frontends get them /// back as base64. With `openLocation`, the file manager shows the first file written. pub fn export_for_screens(app: &mut VectorcraftApp, params: Value) -> Result { + export_files(app, "document.exportForScreens", params) +} + +/// [`export_for_screens`] through command `id` (`document.exportForScreens` or `assets.export`). +pub(crate) fn export_files(app: &mut VectorcraftApp, id: &str, params: Value) -> Result { let open_location = params.get("openLocation").and_then(Value::as_bool).unwrap_or(false); let folder = params.get("folder").and_then(Value::as_str).unwrap_or_default().to_string(); - let r = app.run("document.exportForScreens", params)?; + let r = app.run(id, params)?; let count = r["files"].as_array().map_or(0, Vec::len); // Files that came back as bytes: (name, base64). let returned: Vec<(&str, &str)> = match r["dataBase64"].as_str() { diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 25a6ff7f..fd33d93a 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -1458,6 +1458,13 @@ pub fn menu_tree() -> Vec<(&'static str, Vec)> { ], ), sub("Graph", vec![c("Type…", "graph.setType"), c("Data…", "graph.setData"), todo("Design…"), todo("Column…"), todo("Marker…")]), + sub( + "Collect for Export", + vec![ + cp("As Single Asset", "assets.add", json!({"multiple": false})), + cp("As Multiple Assets", "assets.add", json!({"multiple": true})), + ], + ), ], ), ( @@ -1690,7 +1697,7 @@ pub fn menu_tree() -> Vec<(&'static str, Vec)> { panel("Align", "align"), panel("Appearance", "appearance"), panel("Artboards", "artboards"), - todo("Asset Export"), + panel("Asset Export", crate::panels::asset_export::ID), panel("Attributes", "attributes"), panel("Brushes", "brushes"), panel("Color", "color"), diff --git a/crates/ui-egui/src/panels/asset_export.rs b/crates/ui-egui/src/panels/asset_export.rs new file mode 100644 index 00000000..64700d84 --- /dev/null +++ b/crates/ui-egui/src/panels/asset_export.rs @@ -0,0 +1,366 @@ +//! Asset Export panel (Window → Asset Export): the document's assets as thumbnails with their +//! names. Art dragged in off the canvas becomes assets, one per object (with Alt held, one asset +//! of them all), and so does the selection with the + button. Clicking selects assets (Shift or +//! Cmd adds), double-clicking one renames it and the bin removes the selected ones. Below are the +//! export settings, the format rows Export for Screens shows too (one set per document), and +//! Export, which writes the selected assets (else all of them) into a picked folder; the web +//! downloads them. The screen button opens Export for Screens on its Assets tab. +//! +//! Everything runs through the `assets.*` commands. + +use std::cell::RefCell; +use std::collections::HashMap; +use std::sync::Arc; + +use egui::{Color32, Rect, Sense, Stroke, StrokeKind, Ui, vec2}; +use serde_json::{Value, json}; +use vectorcraft_doc::{ExportAsset, Node, NodeId}; +use vectorcraft_engine::DocState; + +use super::{pstate, set_pstate}; +use crate::dialogs::{EXPORT_FOR_SCREENS, open_export_for_screens_assets, screen_formats, screen_saved_rows}; +use crate::state::Dialog; +use crate::theme::Tokens; +use crate::widgets::{self, PanelDrag, menu_item}; +use crate::{VectorcraftApp, io}; + +pub const ID: &str = "assetExport"; +/// A tile: the thumbnail over the name. +const TILE: egui::Vec2 = vec2(70.0, 84.0); +const THUMB: f32 = 58.0; +const SELECTED: &str = "asset-export-selected"; + +/// The assets selected in the panel (the panel's own state) that are still in the document. +fn selected(ctx: &egui::Context, st: &DocState) -> Vec { + let mut sel: Vec = pstate(ctx, SELECTED); + sel.retain(|id| st.doc.asset(*id).is_some()); + sel +} + +/// The addresses of an asset's objects, back to front: the identity of its look. +type Look = Vec; +/// A document (uid) at one revision. +type Revision = (u64, u64); + +/// Asset `a`'s art fitted into `r` on white. Rendered once per look of its objects (an edited +/// object is a new allocation, so its address is a free change detector, as in the Layers panel). +pub(crate) fn paint_thumb(ui: &Ui, st: &DocState, a: &ExportAsset, r: Rect) { + thread_local! { + static RENDERER: RefCell = RefCell::new(vectorcraft_render::Renderer::new()); + /// The addresses of each asset's objects in one document revision (no tree walks per frame). + static KEYS: RefCell<(Revision, HashMap)> = RefCell::new(Default::default()); + static CACHE: RefCell> = RefCell::new(HashMap::new()); + } + ui.painter().rect_filled(r, 0.0, Color32::WHITE); + if !ui.is_rect_visible(r) { + return; + } + let ids = || st.doc.paint_order(a.nodes.iter().copied()); + let nodes = KEYS.with(|k| { + let mut k = k.borrow_mut(); + if k.0 != (st.uid, st.revision) { + *k = ((st.uid, st.revision), HashMap::new()); + } + k.1.entry(a.id).or_insert_with(|| ids().iter().filter_map(|id| st.doc.node(*id)).map(|n| std::ptr::from_ref(n) as usize).collect()).clone() + }); + let px = (r.width() * ui.ctx().pixels_per_point()).round().max(8.0) as u32; + let key = (nodes, px); + let tex = CACHE.with(|c| c.borrow().get(&key).cloned()).or_else(|| { + let art: Vec<&Node> = ids().iter().filter_map(|id| st.doc.node(*id)).collect(); + let img = RENDERER.with(|rr| match art.as_slice() { + [one] => rr.borrow_mut().render_node_thumbnail(&st.doc, one, px, None), + many => { + let group = Node::group(NodeId(0), many.iter().map(|n| Arc::new((*n).clone())).collect()); + rr.borrow_mut().render_node_thumbnail(&st.doc, &group, px, None) + } + })?; + let color = egui::ColorImage::from_rgba_premultiplied([img.width as usize, img.height as usize], &img.pixels); + let tex = ui.ctx().load_texture(format!("asset-thumb-{}", a.id), color, egui::TextureOptions::LINEAR); + CACHE.with(|c| { + let mut c = c.borrow_mut(); + if c.len() > 512 { + c.clear(); + } + c.insert(key, tex.clone()); + }); + Some(tex) + }); + if let Some(t) = tex { + ui.painter().image(t.id(), r, Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)), Color32::WHITE); + } +} + +/// What the tiles saw this frame (acted on after drawing). +#[derive(Default)] +struct Events { + /// An asset clicked: (id, Shift/Cmd held). + click: Option<(u64, bool)>, + /// An asset renamed. + rename: Option<(u64, String)>, + /// Art dropped on the panel: (ids, as one asset). + drop: Option<(Vec, bool)>, +} + +/// The asset tiles: thumbnail, name (a text field while renaming), selection. +fn tiles(ui: &mut Ui, st: &DocState, sel: &[u64], ev: &mut Events) { + let t = Tokens::get(ui.ctx()); + let rename_id = egui::Id::new("asset-export-rename"); + let renaming: Option<(u64, String)> = ui.data(|d| d.get_temp(rename_id)); + ui.horizontal_wrapped(|ui| { + ui.spacing_mut().item_spacing = vec2(4.0, 4.0); + for a in &st.doc.assets { + let (r, resp) = ui.allocate_exact_size(TILE, Sense::click()); + let on = sel.contains(&a.id); + if on { + ui.painter().rect_filled(r, 2.0, t.row_selected); + } else if resp.hovered() { + ui.painter().rect_filled(r, 2.0, t.hover); + } + let th = Rect::from_min_size(egui::pos2(r.center().x - THUMB / 2.0, r.top() + 4.0), vec2(THUMB, THUMB)); + paint_thumb(ui, st, a, th); + ui.painter().rect_stroke(th, 0.0, Stroke::new(if on { 2.0 } else { 1.0 }, if on { t.accent } else { t.border }), StrokeKind::Outside); + let name_rect = Rect::from_min_max(egui::pos2(r.left() + 2.0, th.bottom() + 3.0), egui::pos2(r.right() - 2.0, r.bottom() - 1.0)); + match &renaming { + Some((id, buf)) if *id == a.id => { + let mut buf = buf.clone(); + let mut child = ui.new_child(egui::UiBuilder::new().max_rect(name_rect)); + let te = child.add(egui::TextEdit::singleline(&mut buf).desired_width(name_rect.width()).font(egui::FontId::proportional(11.5))); + // Enter or a click elsewhere commits, Escape cancels. + if te.lost_focus() { + ui.data_mut(|d| d.remove::<(u64, String)>(rename_id)); + if child.input(|i| !i.key_pressed(egui::Key::Escape)) && buf.trim() != a.name && !buf.trim().is_empty() { + ev.rename = Some((a.id, buf.trim().to_string())); + } + } else { + te.request_focus(); + ui.data_mut(|d| d.insert_temp(rename_id, (a.id, buf))); + } + } + _ => { + ui.painter().with_clip_rect(name_rect).text( + egui::pos2(name_rect.center().x, name_rect.center().y), + egui::Align2::CENTER_CENTER, + &a.name, + egui::FontId::proportional(11.5), + t.text, + ); + } + } + let resp = resp.on_hover_text(format!("{}\nDouble-click to rename", a.name)); + if resp.double_clicked() { + ui.data_mut(|d| d.insert_temp(rename_id, (a.id, a.name.clone()))); + } else if resp.clicked() { + ev.click = Some((a.id, ui.input(|i| i.modifiers.shift || i.modifiers.command))); + } + } + }); +} + +/// Art dragged off the canvas over the list: outline it; dropped, it becomes assets. +fn drop_zone(ui: &Ui, zone: &egui::Response, ev: &mut Events) { + let Some(drag) = zone.dnd_hover_payload::() else { return }; + let PanelDrag::Art(ids) = &*drag else { return }; + ui.painter().rect_stroke(zone.rect, 0.0, Stroke::new(1.5, Tokens::get(ui.ctx()).accent), StrokeKind::Inside); + if zone.dnd_release_payload::().is_some() { + ev.drop = Some((ids.clone(), ui.input(|i| i.modifiers.alt))); + } +} + +/// Run `cmd`, reporting a failure in the status bar. +fn run(app: &mut VectorcraftApp, cmd: &str, params: Value) -> Option { + app.run(cmd, params).map_err(|e| app.status(e)).ok() +} + +/// Collect `ids` (else the selection) as assets (one of them all with `single`), and select them +/// in the panel. +fn collect(app: &mut VectorcraftApp, ctx: &egui::Context, ids: Option>, single: bool) { + let mut p = json!({ "multiple": !single }); + if let Some(ids) = ids { + p["ids"] = json!(ids.iter().map(|id| id.0).collect::>()); + } + if let Some(r) = run(app, "assets.add", p) { + let ids: Vec = r["assets"].as_array().into_iter().flatten().filter_map(Value::as_u64).collect(); + set_pstate(ctx, SELECTED, ids); + } +} + +/// Export assets `ids` (`files_each`: the files one asset makes) into a picked folder (without a +/// folder picker: the last folder Export for Screens used, else the Desktop); the web downloads +/// them, several as one zip. +fn export(app: &mut VectorcraftApp, ids: Vec, files_each: usize) { + let mut p = json!({ "assets": ids }); + if io::is_web(app) { + p["zip"] = json!(ids.len() * files_each > 1); + } else { + let saved = app.session.active().map(|st| st.doc.export_settings.clone()).unwrap_or_default(); + let folder = match app.services.pick_folder.as_mut() { + Some(pick) => match pick() { + Some(f) => f, + None => return, + }, + None => match saved + .get("folder") + .and_then(Value::as_str) + .filter(|f| !f.is_empty()) + .map(str::to_string) + .or_else(vectorcraft_engine::cmd::fileio::export_folder) + { + Some(f) => f, + None => return app.status("choose a folder to export to".to_string()), + }, + }; + p["folder"] = json!(folder); + p["openLocation"] = json!(saved.get("openLocation").and_then(Value::as_bool).unwrap_or(true)); + } + if let Err(e) = io::export_files(app, "assets.export", p) { + app.status(e); + } +} + +/// What the export settings rows asked for. +enum Settings { + /// Change the shared settings (`assets.settings.set`). + Set(Value), + /// Show a format's Format Settings (in Export for Screens). + Format(String), +} + +/// The export settings: Export for Screens' format rows on the document's shared settings. +/// Returns the change asked for and how many files one asset makes. +fn settings(ui: &mut Ui, st: &DocState) -> (Option, usize) { + let (rows, preset) = screen_saved_rows(&st.doc.export_settings); + let preset = preset.unwrap_or_default().to_string(); + let n = rows.len(); + let mut d = Dialog::new(EXPORT_FOR_SCREENS, json!({ "formats": &rows, "preset": preset, "__settings": "" })); + screen_formats(ui, &mut d); + let (shown, picked) = (d.str("__settings"), d.str("preset")); + let formats = d.fields.remove("formats").unwrap_or(Value::Null); + let change = if !shown.is_empty() { + Some(Settings::Format(shown)) + } else if picked != preset && !picked.is_empty() { + Some(Settings::Set(json!({ "preset": picked, "subfolders": true }))) + } else if picked != preset || formats.as_array() != Some(&rows) { + // Rows edited, or Custom picked: the rows as they are, without the preset. + Some(Settings::Set(json!({ "formats": formats, "preset": "" }))) + } else { + None + }; + (change, n) +} + +pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { + let ctx = ui.ctx().clone(); + let Some(st) = app.session.active() else { + widgets::dim_label(ui, "No document"); + return; + }; + let sel = selected(&ctx, st); + let mut ev = Events::default(); + widgets::list_box(ui, |ui| { + let out = egui::ScrollArea::vertical().id_salt("asset-export-scroll").max_height(270.0).auto_shrink([false, true]).show(ui, |ui| { + ui.set_min_height(110.0); + ui.set_width(ui.available_width()); + if st.doc.assets.is_empty() { + super::empty_state(ui, "share-2", "No assets", "Drag art here, or select it and click +. Hold Alt for one asset of several objects."); + } else { + tiles(ui, st, &sel, &mut ev); + } + }); + let zone = ui.interact(out.inner_rect, ui.id().with("asset-export-drop"), Sense::hover()); + drop_zone(ui, &zone, &mut ev); + }); + ui.add_space(6.0); + widgets::section_header(ui, "Export Settings"); + let (change, files_each) = settings(ui, st); + let all: Vec = st.doc.assets.iter().map(|a| a.id).collect(); + let has_art = !st.selection.is_empty(); + let alt = ui.input(|i| i.modifiers.alt); + ui.add_space(6.0); + let mut export_clicked = false; + ui.horizontal(|ui| { + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + ui.add_enabled_ui(!all.is_empty(), |ui| { + let label = if sel.is_empty() || sel.len() == all.len() { "Export".to_string() } else { format!("Export {} Selected", sel.len()) }; + export_clicked = widgets::primary_button(ui, &label).on_hover_text("Export the selected assets (else all of them)").clicked(); + }); + }); + }); + let (mut screens, mut add, mut remove) = (false, false, false); + widgets::bottom_bar(ui, |ui| { + screens = widgets::icon_button(ui, "monitor", "Export for Screens…", false, 24.0).clicked(); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + remove = widgets::icon_button_enabled(ui, "trash-2", "Remove Selected Assets", false, !sel.is_empty(), 24.0).clicked(); + add = widgets::icon_button_enabled(ui, "plus", "Add Selected Artwork (Alt: as one asset)", false, has_art, 24.0).clicked(); + }); + }); + // Act on what the panel saw (the document is no longer borrowed). + if let Some((id, toggle)) = ev.click { + let mut s = sel.clone(); + match (toggle, s.iter().position(|x| *x == id)) { + (true, Some(i)) => _ = s.remove(i), + (true, None) => s.push(id), + (false, _) => s = vec![id], + } + set_pstate(&ctx, SELECTED, s); + } + if let Some((id, name)) = ev.rename { + run(app, "assets.rename", json!({ "asset": id, "name": name })); + } + if let Some((ids, single)) = ev.drop { + collect(app, &ctx, Some(ids), single); + } + match change { + Some(Settings::Set(p)) => _ = run(app, "assets.settings.set", p), + Some(Settings::Format(f)) => { + open_export_for_screens_assets(app, Some(&sel_or_all(&sel, &all))); + if let Some(d) = app.ui.dialog.as_mut() { + d.fields.insert("__settings".into(), json!(f)); + } + } + None => {} + } + if add { + collect(app, &ctx, None, alt); + } + if remove { + run(app, "assets.remove", json!({ "assets": sel })); + set_pstate(&ctx, SELECTED, Vec::::new()); + } + if screens { + open_export_for_screens_assets(app, Some(&sel_or_all(&sel, &all))); + } + if export_clicked { + export(app, sel_or_all(&sel, &all), files_each); + } +} + +/// The selected assets, else all of them. +fn sel_or_all(sel: &[u64], all: &[u64]) -> Vec { + if sel.is_empty() { all.to_vec() } else { sel.to_vec() } +} + +/// The panel (≡) menu. +pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { + let ctx = ui.ctx().clone(); + let Some(st) = app.session.active() else { return }; + let (has_art, sel) = (!st.selection.is_empty(), selected(&ctx, st)); + let all: Vec = st.doc.assets.iter().map(|a| a.id).collect(); + if menu_item(ui, "Add Selected Artwork as One Asset", has_art, false) { + collect(app, &ctx, None, true); + } + if menu_item(ui, "Add Selected Artwork as Multiple Assets", has_art, false) { + collect(app, &ctx, None, false); + } + ui.separator(); + if menu_item(ui, "Select All Assets", !all.is_empty(), false) { + set_pstate(&ctx, SELECTED, all.clone()); + } + if menu_item(ui, "Remove Selected Assets", !sel.is_empty(), false) { + run(app, "assets.remove", json!({ "assets": sel })); + set_pstate(&ctx, SELECTED, Vec::::new()); + } + ui.separator(); + if menu_item(ui, "Export for Screens…", true, false) { + open_export_for_screens_assets(app, Some(&sel_or_all(&sel, &all))); + } +} diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index 2cc7a194..3f6aa3d7 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -7,6 +7,7 @@ pub mod actions; pub mod align; pub mod appearance; pub mod artboards; +pub mod asset_export; pub mod attributes; pub mod brushes; pub mod character; @@ -96,6 +97,7 @@ pub fn show_icon_panel(app: &mut VectorcraftApp, ui: &mut Ui, id: &str) { "attributes" => attributes::show(app, ui), "colorThemes" => color_themes::show(app, ui), links::ID => links::show(app, ui), + asset_export::ID => asset_export::show(app, ui), _ => { dim_label(ui, "This panel is on the roadmap (see the parity plan)."); } @@ -139,6 +141,7 @@ pub fn panel_menu_items(app: &mut VectorcraftApp, ui: &mut Ui, id: &str) { "colorThemes" => color_themes::menu(app, ui), "layers" => layers::menu(app, ui), links::ID => links::menu(app, ui), + asset_export::ID => asset_export::menu(app, ui), _ => { ui.add_enabled(false, egui::Button::new("No options").frame(false)); } @@ -445,6 +448,8 @@ mod tests { #[cfg(test)] mod tests_appearance; #[cfg(test)] +mod tests_asset_export; +#[cfg(test)] mod tests_effectedit; #[cfg(test)] mod tests_freeform; diff --git a/crates/ui-egui/src/panels/tests_asset_export.rs b/crates/ui-egui/src/panels/tests_asset_export.rs new file mode 100644 index 00000000..59e732d3 --- /dev/null +++ b/crates/ui-egui/src/panels/tests_asset_export.rs @@ -0,0 +1,206 @@ +//! The Asset Export panel drawn headless: its empty state, art dropped in (one asset per object, +//! or one with Alt), selecting and renaming assets, the shared export settings rows and Export. + +use std::cell::RefCell; +use std::rc::Rc; + +use egui::{Pos2, Rect, pos2, vec2}; +use serde_json::{Value, json}; +use vectorcraft_doc::NodeId; +use vectorcraft_engine::Session; + +use super::asset_export::{ID, menu, show}; +use crate::widgets::PanelDrag; +use crate::{Services, VectorcraftApp}; + +type Log = Rc)>>>; + +/// An app with two unstroked squares; `web` gives it a download service (recorded in the log). +fn app(web: bool) -> (VectorcraftApp, Log, [u64; 2]) { + let log = Log::default(); + let l = log.clone(); + let mut services = Services::default(); + if web { + services.download = Some(Box::new(move |name: &str, bytes: &[u8]| l.borrow_mut().push((name.to_string(), bytes.to_vec())))); + } + let mut app = VectorcraftApp::new(Session::new(), services); + app.run("file.new", json!({"width": 200, "height": 100})).unwrap(); + app.session.paint.stroke = vectorcraft_color::Paint::None; + let mut rect = |x: f64| app.run("shape.rectangle", json!({"x": x, "y": 10, "width": 30, "height": 20})).unwrap()["id"].as_u64().unwrap(); + let ids = [rect(10.0), rect(100.0)]; + (app, log, ids) +} + +/// One frame of the panel, 236 pt wide as in the dock → its texts and where they are. +fn frame(ctx: &egui::Context, app: &mut VectorcraftApp, events: Vec, alt: bool) -> Vec<(String, Rect)> { + fn texts(s: &egui::Shape, out: &mut Vec<(String, Rect)>) { + match s { + egui::Shape::Text(t) => out.push((t.galley.text().to_string(), t.visual_bounding_rect())), + egui::Shape::Vec(v) => v.iter().for_each(|s| texts(s, out)), + _ => {} + } + } + let mut all = vec![egui::Event::ModifiersChanged(egui::Modifiers { alt, ..Default::default() })]; + all.extend(events); + let raw = egui::RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(236.0, 700.0))), events: all, ..Default::default() }; + let mut out = ctx.run_ui(raw, |ui| show(app, ui)); + out.textures_delta.clear(); + let mut v = vec![]; + out.shapes.iter().for_each(|c| texts(&c.shape, &mut v)); + v +} + +fn click(at: Pos2) -> Vec { + let button = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + vec![egui::Event::PointerMoved(at), button(true), button(false)] +} + +fn release(at: Pos2) -> Vec { + vec![ + egui::Event::PointerMoved(at), + egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() }, + ] +} + +/// Where text `label` was drawn. +fn at(texts: &[(String, Rect)], label: &str) -> Pos2 { + texts + .iter() + .find(|(t, _)| t == label) + .unwrap_or_else(|| panic!("no `{label}` in {:?}", texts.iter().map(|t| &t.0).collect::>())) + .1 + .center() +} + +fn assets(app: &mut VectorcraftApp) -> Vec { + app.run("assets.list", json!({})).unwrap()["assets"].as_array().unwrap().clone() +} + +fn has(texts: &[(String, Rect)], label: &str) -> bool { + texts.iter().any(|(t, _)| t == label) +} + +#[test] +fn an_empty_panel_says_how_to_add_assets_and_shows_the_export_settings() { + let (mut app, _, _) = app(false); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + frame(&ctx, &mut app, vec![], false); + let texts = frame(&ctx, &mut app, vec![], false); + for label in ["No assets", "Export Settings", "Scale", "Suffix", "Format", "+ Add Scale", "Export"] { + assert!(has(&texts, label), "{label}"); + } + // The menu draws. + let menu = crate::tests_labels::painted_text(&mut app, menu); + for item in ["Add Selected Artwork as One Asset", "Remove Selected Assets", "Export for Screens…"] { + assert!(menu.contains(item), "{item}: {menu}"); + } + assert!(crate::state::ICON_PANELS.iter().any(|(id, label, _)| *id == ID && *label == "Asset Export")); +} + +#[test] +fn art_dropped_on_the_panel_becomes_assets() { + let (mut app, _, [a, b]) = app(false); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + frame(&ctx, &mut app, vec![], false); + egui::DragAndDrop::set_payload(&ctx, PanelDrag::Art(vec![NodeId(a), NodeId(b)])); + frame(&ctx, &mut app, release(pos2(100.0, 50.0)), false); + let list = assets(&mut app); + assert_eq!(list.len(), 2, "one asset per object"); + // With Alt, one asset of them all. + egui::DragAndDrop::set_payload(&ctx, PanelDrag::Art(vec![NodeId(a), NodeId(b)])); + frame(&ctx, &mut app, release(pos2(100.0, 50.0)), true); + let list = assets(&mut app); + assert_eq!(list.len(), 3); + assert_eq!(list[2]["nodes"], json!([a, b])); + let texts = frame(&ctx, &mut app, vec![], false); + assert!(has(&texts, "Asset 1") && has(&texts, "Asset 3")); + // The new asset is the panel's selection. + assert!(has(&texts, "Export 1 Selected")); +} + +#[test] +fn the_plus_button_collects_the_selection_and_the_bin_removes_selected_assets() { + let (mut app, _, [a, b]) = app(false); + app.run("select.set", json!({"ids": [a, b]})).unwrap(); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + frame(&ctx, &mut app, vec![], false); + let texts = frame(&ctx, &mut app, vec![], false); + // The + is the bottom bar's second icon from the right. + let bar_y = at(&texts, "Export").y + 34.0; + frame(&ctx, &mut app, click(pos2(236.0 - 40.0, bar_y)), false); + assert_eq!(assets(&mut app).len(), 2, "+ adds one asset per object"); + let texts = frame(&ctx, &mut app, vec![], false); + // Click one: it alone is selected; the bin removes it (the art stays). + frame(&ctx, &mut app, click(at(&texts, "Asset 2")), false); + let texts = frame(&ctx, &mut app, vec![], false); + // The bin is the bottom bar's last icon. + frame(&ctx, &mut app, click(pos2(236.0 - 13.0, at(&texts, "Export 1 Selected").y + 34.0)), false); + let names: Vec = assets(&mut app).iter().map(|x| x["name"].clone()).collect(); + assert_eq!(names, [json!("Asset 1")]); + assert!(app.session.active().unwrap().doc.node(NodeId(b)).is_some()); +} + +#[test] +fn renaming_an_asset_runs_assets_rename() { + let (mut app, _, [a, _]) = app(false); + let id = app.run("assets.add", json!({"ids": [a]})).unwrap()["assets"][0].as_u64().unwrap(); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + frame(&ctx, &mut app, vec![], false); + // Double-click starts editing the name (as typed here); Enter commits it. + ctx.data_mut(|d| d.insert_temp(egui::Id::new("asset-export-rename"), (id, "Logo".to_string()))); + frame(&ctx, &mut app, vec![], false); + let enter = egui::Event::Key { key: egui::Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() }; + frame(&ctx, &mut app, vec![enter], false); + assert_eq!(assets(&mut app)[0]["name"], "Logo"); +} + +#[test] +fn the_rows_are_the_shared_export_settings() { + let (mut app, log, [a, b]) = app(true); + app.run("assets.add", json!({"ids": [a, b]})).unwrap(); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + frame(&ctx, &mut app, vec![], false); + let texts = frame(&ctx, &mut app, vec![], false); + // + Add Scale adds a 2x row to the document's export settings (Export for Screens' too). + frame(&ctx, &mut app, click(at(&texts, "+ Add Scale")), false); + let settings = app.run("document.exportSettings", json!({})).unwrap()["settings"].clone(); + assert_eq!(settings["formats"].as_array().unwrap().len(), 2); + assert_eq!(settings["formats"][1]["scale"], "2x"); + app.run("file.exportForScreens", json!({})).unwrap(); + assert_eq!(app.ui.dialog.as_ref().unwrap().fields["formats"][1]["scale"], "2x"); + crate::dialogs::cancel(&mut app); + // A preset set by an agent shows as its rows. + app.run("assets.settings.set", json!({"preset": "mobile"})).unwrap(); + let texts = frame(&ctx, &mut app, vec![], false); + assert!(has(&texts, "Mobile 1x/2x/3x") && has(&texts, "@3x")); + // Export (nothing selected: every asset) downloads one zip on the web. + let texts = frame(&ctx, &mut app, vec![], false); + frame(&ctx, &mut app, click(at(&texts, "Export")), false); + let log = log.borrow(); + assert_eq!(log.len(), 1); + assert_eq!(log[0].0, "Untitled-1.zip"); +} + +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn export_writes_into_the_picked_folder() { + let dir = std::env::temp_dir().join(format!("vc-asset-panel-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let folder = dir.to_string_lossy().replace('\\', "/"); + let (mut app, _, [a, _]) = app(false); + let picked = folder.clone(); + app.services.pick_folder = Some(Box::new(move || Some(picked.clone()))); + app.run("assets.add", json!({"ids": [a], "name": "Badge"})).unwrap(); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + frame(&ctx, &mut app, vec![], false); + let texts = frame(&ctx, &mut app, vec![], false); + frame(&ctx, &mut app, click(at(&texts, "Export")), false); + assert!(dir.join("Badge.png").is_file(), "{:?}", std::fs::read_dir(&dir).map(|d| d.count())); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index e0c33b9d..4ddb0462 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -107,6 +107,7 @@ pub const ICON_PANELS: &[(&str, &str, &str)] = &[ ("attributes", "Attributes", "settings"), ("colorThemes", "Color Themes", "sun"), (crate::panels::links::ID, "Links", "link"), + (crate::panels::asset_export::ID, "Asset Export", "share-2"), ]; /// Groups of icon panels separated by dividers in the collapsed column. diff --git a/docs/control-protocol.md b/docs/control-protocol.md index eee5bef4..9eab6465 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -32,6 +32,7 @@ | `engine.execute` `document.exportForOffice` | `{}` / `{path?, ppi?: 72 \| 150 \| 300, transparent?, artboard?}` | File → Save for Office Documents…: without params opens `saveForOffice` (fields `ppi`, `transparent`, `artboard`; OK picks the file); with params writes the artboard as a PNG (`path`, else a picked file) → `{path}`. Headless it returns `{dataBase64, width, height}` | | Background Save / Export | `app.save`, `app.export {path}`, `file.save`, `file.saveAs`, `file.saveCopy` | with the `backgroundSave` / `backgroundExport` preferences on (the default) the desktop app writes the file from a snapshot on a worker thread: the call returns `{path, background: true, status}` at once, `ui.inspect` lists the jobs still running in `background` (labels such as `"Saving a.vectorcraft"`), and the document counts as saved (as of the snapshot) once its file is written. Quit and Close wait for them. With the preferences off (`prefs.set`), saves return once written | | `engine.execute` `file.exportForScreens` | `{}` / `{…document.exportForScreens params}` | without params opens `exportForScreens` on the settings the document last exported with (`document.exportSettings`). Fields: `tab` (`artboards` \| `assets`), `select` (`all` \| `range` \| `full`), `range` ("1-3, 5"), `boards` (a bool per artboard; checking them sets `select` and `range`), `includeBleed`, `folder` (desktop; default the Desktop, else home), `openLocation`, `subfolders`, `prefix`, `preset` (`""` \| `mobile` \| `density`), `formats` (rows `{format, scale: "2x" \| "100w" \| "100h" \| "72ppi", suffix, quality?, folder?}`), `settings` (`{png \| png8 \| jpg \| webp \| gif \| svg \| pdf: {…options}}`, the gear's Format Settings; `__settings` names the format shown). `ui.dialog.confirm` exports (a bad range or no folder keeps it open); on the web OK reads "Download" and the files download (several as one `.zip`). With params it runs `document.exportForScreens`: no `folder` returns the files (or with `zip: true` one store-only zip) as `dataBase64`; with `openLocation` the first file is shown in the file manager | +| Export for Screens' Assets tab, Asset Export panel | `ui.dialog.set` `tab: "assets"`, `assets: [asset id…]`; `window.panel {panel: "assetExport"}` | on the Assets tab of `exportForScreens` the `assets` field lists the checked asset ids (all of them when it opens); `ui.dialog.confirm` runs `document.exportForScreens {assets}` (nothing checked keeps it open) and OK reads "Export Asset". The Asset Export panel acts through `assets.*` and shares the format rows with the dialog (`assets.settings.set`) | | `ui.dialog.*` on `importPdf` | `{field: "page" \| "allPages" \| "range" \| "cropTo" \| "password", value}` | opening (`app.open`, `file.open`) a PDF or `.ai` with several pages or a password, or placing one (`file.place` without `page`), opens the Import PDF dialog (`mode`: `open` or `place`; `pageCount`). While `__locked` is true, set `password` and `ui.dialog.confirm` to unlock it (a one-page file then opens or is placed). Then set `allPages: false` and `range` ("2-3, 5") to open some pages, or `page` to place one, and `cropTo` (`bounding`, `art`, `crop`, `trim`, `bleed`, `media`); `ui.dialog.confirm` opens or places them (placing keeps the other `file.place` params, such as `link`, `template` and `at`, in `__place`). `document.open {pages, cropTo, password}` and `file.place {page, crop, password}` do the same without the dialog | diff --git a/docs/mcp.md b/docs/mcp.md index 9f1daee1..3b4376be 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1412,3 +1412,24 @@ defaults); presets and remembered settings are preferences. {"name":"run_command","arguments":{"command":"document.exportForWeb","params":{"path":"/tmp/web/page.html","format":"png8","output":"html"}}} {"name":"run_command","arguments":{"command":"webExport.presets.save","params":{"name":"Banner","format":"jpg","quality":70}}} ``` + +## Asset Export + +Assets are pieces of art collected for export on their own (Window › Asset Export, Object › Collect for Export). +`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. + +`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 +`formats`, `preset`, `settings`, `prefix` or `subfolders` given win for that export. `assets.settings.set` changes +those shared settings (not an undo step). `document.exportForScreens {assets: [id…]}` does the same with its own params +(the dialog's Assets tab). + +```json +{"name":"run_command","arguments":{"command":"assets.add","params":{"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}}} +``` From 46dc67ca50417906ee51d7fe9ea4f85b8a3b2a43 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Mon, 5 Oct 2026 14:30:29 +0700 Subject: [PATCH 2/2] M4.66: File > Export Selection collects the selection and opens the Assets tab MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Export Selection… (file.exportSelection, a UI command) now collects the selected objects as assets, one per object (multiple: false makes one), reusing assets of art already collected, and opens Export for Screens on its Assets tab with just those checked. document.exportSelection stays the one-shot export for agents and scripts. Co-Authored-By: Claude Opus 5.5 --- .../src/dialogs/tests_screen_assets.rs | 45 ++++++++++++++++++- crates/ui-egui/src/menus.rs | 16 ++++++- docs/control-protocol.md | 1 + docs/mcp.md | 3 +- 4 files changed, 61 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/dialogs/tests_screen_assets.rs b/crates/ui-egui/src/dialogs/tests_screen_assets.rs index feedb177..b052d165 100644 --- a/crates/ui-egui/src/dialogs/tests_screen_assets.rs +++ b/crates/ui-egui/src/dialogs/tests_screen_assets.rs @@ -1,5 +1,5 @@ -//! Export for Screens' Assets tab, drawn headlessly: the checked assets, the OK label, desktop -//! folders and web downloads. +//! Export for Screens' Assets tab and File → Export Selection…, drawn headlessly: collecting the +//! selection, the checked assets, the OK label, desktop folders and web downloads. use std::cell::RefCell; use std::rc::Rc; @@ -45,6 +45,46 @@ fn assets(app: &mut VectorcraftApp) -> Vec { app.run("assets.list", json!({})).unwrap()["assets"].as_array().unwrap().clone() } +#[test] +fn export_selection_collects_the_selection_and_opens_the_assets_tab() { + let (mut app, log, _) = app(true); + assert!(crate::menus::enabled(&app, "file.exportSelection")); + let r = app.run("file.exportSelection", json!({})).unwrap(); + let list = assets(&mut app); + assert_eq!(list.len(), 2, "one asset per object"); + let ids: Vec = list.iter().map(|a| a["id"].clone()).collect(); + assert_eq!(r["assets"], json!(ids)); + assert_eq!(kind(&app), Some(export_for_screens::KIND)); + assert_eq!((field(&app, "tab"), field(&app, "assets")), (json!("assets"), json!(ids))); + // Collecting the same art again opens on the same assets. + super::cancel(&mut app); + app.run("file.exportSelection", json!({})).unwrap(); + assert_eq!(assets(&mut app).len(), 2); + assert_eq!(field(&app, "assets"), json!(ids)); + let text = painted_text(&mut app, |app, ui| super::show(app, ui.ctx())); + assert!(text.contains("Asset 1") && text.contains("Asset 2") && text.contains("Select All"), "{text}"); + // The web downloads both as one zip. + let r = confirm(&mut app).unwrap(); + assert_eq!(r["files"], json!(["Asset-1.png", "Asset-2.png"])); + assert_eq!(log.borrow().len(), 1); + assert!(app.ui.dialog.is_none()); + // Nothing selected: Export Selection is off; document.exportSelection still exports in one go. + app.run("select.set", json!({"ids": []})).unwrap(); + assert!(!crate::menus::enabled(&app, "file.exportSelection")); + app.run("select.set", json!({"ids": [list[0]["nodes"][0]]})).unwrap(); + assert!(app.session.execute("document.exportSelection", &json!({"format": "png"})).unwrap()["dataBase64"].is_string()); +} + +#[test] +fn export_selection_as_one_asset() { + let (mut app, _, ids) = app(true); + app.run("file.exportSelection", json!({"multiple": false})).unwrap(); + let list = assets(&mut app); + assert_eq!(list.len(), 1); + assert_eq!(list[0]["nodes"], json!(ids)); + assert_eq!(field(&app, "assets"), json!([list[0]["id"]])); +} + #[test] fn the_assets_tab_exports_the_checked_assets_into_the_folder() { let (mut app, log, _) = app(false); @@ -89,6 +129,7 @@ fn the_menus_reach_asset_export() { let (mut app, _, _) = app(false); let entries = crate::menus::menu_entries(&app); let find = |cmd: &str, label: &str| entries.iter().find(|e| e.command.as_deref() == Some(cmd) && e.label == label).map(|e| e.path.clone()); + assert_eq!(find("file.exportSelection", "Export Selection…"), Some(vec!["File".to_string()])); assert_eq!(find("assets.add", "As Single Asset"), Some(vec!["Object".to_string(), "Collect for Export".to_string()])); assert_eq!(find("assets.add", "As Multiple Assets"), Some(vec!["Object".to_string(), "Collect for Export".to_string()])); let window = entries.iter().find(|e| e.label == "Asset Export").unwrap(); diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index fd33d93a..0691d3e0 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -485,6 +485,12 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "", "{…document.exportForWeb settings} write the HTML page and its images to a temporary folder and open the page in the default browser (desktop) → {path}", ), + ( + "file.exportSelection", + "Export Selection…", + "", + "{multiple?: true} collect the selected objects as assets (assets.add: one per object; false: one of them all) and open Export for Screens on its Assets tab with them checked → {assets: [asset id…]}; document.exportSelection exports the selection in one go instead", + ), ]; /// Handle a UI command. `None` = not a UI command (the engine handles it). @@ -871,6 +877,13 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option crate::dialogs::save_for_web::open(app), "file.saveForWeb" => crate::dialogs::save_for_web::save(app, p.clone()), "file.saveForWeb.browser" => crate::dialogs::save_for_web::browser_preview(app, p), + // File → Export Selection…: the selection becomes assets, shown checked on Export for + // Screens' Assets tab. + "file.exportSelection" => app.run("assets.add", json!({ "multiple": p.get("multiple").and_then(Value::as_bool).unwrap_or(true) })).map(|r| { + let ids: Vec = r["assets"].as_array().into_iter().flatten().filter_map(Value::as_u64).collect(); + crate::dialogs::open_export_for_screens_assets(app, Some(&ids)); + json!({ "assets": ids }) + }), _ => return None, }; Some(r) @@ -1106,6 +1119,7 @@ pub fn enabled(app: &VectorcraftApp, id: &str) -> bool { "ui.packageDialog" | "docInfo.save" => app.session.active().is_some(), "ui.epsOptionsDialog" => app.session.active().is_some(), "file.saveForWeb" | "file.saveForWeb.browser" => app.session.active().is_some(), + "file.exportSelection" => app.session.active().is_some_and(|d| !d.selection.is_empty()), _ => true, } } @@ -1169,7 +1183,7 @@ pub fn menu_tree() -> Vec<(&'static str, Vec)> { c("Save for Web (Legacy)…", "file.saveForWeb"), ], ), - c("Export Selection…", "document.exportSelection"), + c("Export Selection…", "file.exportSelection"), Sep, c("Package…", "ui.packageDialog"), sub("Scripts", vec![todos("Other Script…", "Cmd+F12")]), diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 9eab6465..3e0494e4 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -33,6 +33,7 @@ | Background Save / Export | `app.save`, `app.export {path}`, `file.save`, `file.saveAs`, `file.saveCopy` | with the `backgroundSave` / `backgroundExport` preferences on (the default) the desktop app writes the file from a snapshot on a worker thread: the call returns `{path, background: true, status}` at once, `ui.inspect` lists the jobs still running in `background` (labels such as `"Saving a.vectorcraft"`), and the document counts as saved (as of the snapshot) once its file is written. Quit and Close wait for them. With the preferences off (`prefs.set`), saves return once written | | `engine.execute` `file.exportForScreens` | `{}` / `{…document.exportForScreens params}` | without params opens `exportForScreens` on the settings the document last exported with (`document.exportSettings`). Fields: `tab` (`artboards` \| `assets`), `select` (`all` \| `range` \| `full`), `range` ("1-3, 5"), `boards` (a bool per artboard; checking them sets `select` and `range`), `includeBleed`, `folder` (desktop; default the Desktop, else home), `openLocation`, `subfolders`, `prefix`, `preset` (`""` \| `mobile` \| `density`), `formats` (rows `{format, scale: "2x" \| "100w" \| "100h" \| "72ppi", suffix, quality?, folder?}`), `settings` (`{png \| png8 \| jpg \| webp \| gif \| svg \| pdf: {…options}}`, the gear's Format Settings; `__settings` names the format shown). `ui.dialog.confirm` exports (a bad range or no folder keeps it open); on the web OK reads "Download" and the files download (several as one `.zip`). With params it runs `document.exportForScreens`: no `folder` returns the files (or with `zip: true` one store-only zip) as `dataBase64`; with `openLocation` the first file is shown in the file manager | | Export for Screens' Assets tab, Asset Export panel | `ui.dialog.set` `tab: "assets"`, `assets: [asset id…]`; `window.panel {panel: "assetExport"}` | on the Assets tab of `exportForScreens` the `assets` field lists the checked asset ids (all of them when it opens); `ui.dialog.confirm` runs `document.exportForScreens {assets}` (nothing checked keeps it open) and OK reads "Export Asset". The Asset Export panel acts through `assets.*` and shares the format rows with the dialog (`assets.settings.set`) | +| `engine.execute` `file.exportSelection` | `{multiple?}` | File › Export Selection…: collects the selected objects as assets (`assets.add`; one per object unless `multiple` is false) and opens `exportForScreens` on its Assets tab with just them checked → `{assets}`. `document.exportSelection` still exports the selection in one go, without the dialog | | `ui.dialog.*` on `importPdf` | `{field: "page" \| "allPages" \| "range" \| "cropTo" \| "password", value}` | opening (`app.open`, `file.open`) a PDF or `.ai` with several pages or a password, or placing one (`file.place` without `page`), opens the Import PDF dialog (`mode`: `open` or `place`; `pageCount`). While `__locked` is true, set `password` and `ui.dialog.confirm` to unlock it (a one-page file then opens or is placed). Then set `allPages: false` and `range` ("2-3, 5") to open some pages, or `page` to place one, and `cropTo` (`bounding`, `art`, `crop`, `trim`, `bleed`, `media`); `ui.dialog.confirm` opens or places them (placing keeps the other `file.place` params, such as `link`, `template` and `at`, in `__place`). `document.open {pages, cropTo, password}` and `file.place {page, crop, password}` do the same without the dialog | diff --git a/docs/mcp.md b/docs/mcp.md index 3b4376be..3b797f00 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1415,7 +1415,8 @@ 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). +Assets are pieces of art collected for export on their own (Window › Asset Export, Object › Collect for Export, 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