Merge pull request #112 from storytold/pr/m4-asset-export
M4.65–M4.66: Asset Export and Export Selection
This commit is contained in:
commit
0f161926cf
|
|
@ -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<NodeId>,
|
||||
}
|
||||
|
||||
/// 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::<u64>().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<Item = NodeId>) -> Vec<NodeId> {
|
||||
let mut v: Vec<(Vec<usize>, 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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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<serde_json::Value>,
|
||||
/// 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<ExportAsset>,
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<NodeId> {
|
||||
let mut v: Vec<(Vec<usize>, 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())
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<CommandSpec> {
|
||||
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<String> {
|
||||
let n: String = name.trim().chars().take(MAX_NAME).collect();
|
||||
(!n.is_empty()).then_some(n)
|
||||
}
|
||||
|
||||
fn add(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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<Vec<NodeId>> = 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::<Vec<_>>(), "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<Vec<u64>> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
let d = &s.doc()?.doc;
|
||||
let rows: Vec<Value> = 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::<Vec<_>>(),
|
||||
"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<Map<String, Value>> {
|
||||
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<String, Value>, p: &Map<String, Value>) {
|
||||
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<Value> {
|
||||
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<u64> = 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<Value> {
|
||||
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)? }))
|
||||
}
|
||||
|
|
@ -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<Arc<Node>> = 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<NodeId> = 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<Arc<Node>> = 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
|
||||
|
|
|
|||
|
|
@ -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<CommandSpec> {
|
|||
"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<String> {
|
||||
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<Item = (&'a str, String)>) -> Vec<String> {
|
||||
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.. {
|
||||
|
|
|
|||
|
|
@ -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<String>,
|
||||
prefix: Option<String>,
|
||||
/// Asset ids (Asset Export) to write instead of artboards.
|
||||
assets: Option<Vec<u64>>,
|
||||
}
|
||||
|
||||
impl ScreenParams {
|
||||
fn of(p: &Value) -> Result<Self> {
|
||||
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<Vec<Row>> {
|
||||
let settings = p.get("settings").cloned().unwrap_or(Value::Null);
|
||||
let rows: Vec<Value> = 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<Row> = rows.iter().map(|r| Row::parse(s, r, &settings)).collect::<Result<_>>()?;
|
||||
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<Value> {
|
||||
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<Value> = 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<Row> = rows.iter().map(|r| Row::parse(s, r, &settings)).collect::<Result<_>>()?;
|
||||
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<Value> {
|
||||
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<Value> {
|
|||
};
|
||||
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::<Result<Vec<_>>>()?;
|
||||
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<Value> {
|
|||
}
|
||||
}
|
||||
}
|
||||
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<Value> {
|
|||
})
|
||||
}
|
||||
|
||||
/// 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<String, Value>) -> 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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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<Value> {
|
||||
run(s, "assets.list", json!({}))["assets"].as_array().unwrap().clone()
|
||||
}
|
||||
|
||||
fn names(s: &mut Session) -> Vec<String> {
|
||||
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<u8>)> {
|
||||
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<u64> = 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::<Vec<_>>(), ["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::<Vec<_>>(), ["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::<Vec<_>>(), ["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);
|
||||
}
|
||||
|
|
@ -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"),
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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<String, Value>) -> (Vec<Value>, 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::<Vec<_>>()))
|
||||
.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<bool> = (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<Value, String> {
|
|||
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<u64> {
|
||||
let on: Vec<u64> = 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<Value> = 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.
|
||||
|
|
|
|||
|
|
@ -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)]
|
||||
|
|
|
|||
|
|
@ -0,0 +1,141 @@
|
|||
//! 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;
|
||||
|
||||
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<RefCell<Vec<(String, Vec<u8>)>>>;
|
||||
|
||||
/// 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<Value> {
|
||||
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<Value> = 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);
|
||||
app.run("assets.add", json!({})).unwrap();
|
||||
let ids: Vec<u64> = 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("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();
|
||||
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);
|
||||
}
|
||||
|
|
@ -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<Value, String> {
|
||||
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<Value, String> {
|
||||
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() {
|
||||
|
|
|
|||
|
|
@ -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<R
|
|||
"file.saveForWeb" if p.as_object().is_none_or(|o| o.is_empty()) => 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<u64> = 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<Item>)> {
|
|||
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")]),
|
||||
|
|
@ -1458,6 +1472,13 @@ pub fn menu_tree() -> Vec<(&'static str, Vec<Item>)> {
|
|||
],
|
||||
),
|
||||
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 +1711,7 @@ pub fn menu_tree() -> Vec<(&'static str, Vec<Item>)> {
|
|||
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"),
|
||||
|
|
|
|||
|
|
@ -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<u64> {
|
||||
let mut sel: Vec<u64> = 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<usize>;
|
||||
/// 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<vectorcraft_render::Renderer> = 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<u64, Look>)> = RefCell::new(Default::default());
|
||||
static CACHE: RefCell<HashMap<(Look, u32), egui::TextureHandle>> = 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<NodeId>, 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::<PanelDrag>() 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::<PanelDrag>().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<Value> {
|
||||
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<Vec<NodeId>>, single: bool) {
|
||||
let mut p = json!({ "multiple": !single });
|
||||
if let Some(ids) = ids {
|
||||
p["ids"] = json!(ids.iter().map(|id| id.0).collect::<Vec<_>>());
|
||||
}
|
||||
if let Some(r) = run(app, "assets.add", p) {
|
||||
let ids: Vec<u64> = 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<u64>, 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<Settings>, 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<u64> = 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::<u64>::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<u64> {
|
||||
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<u64> = 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::<u64>::new());
|
||||
}
|
||||
ui.separator();
|
||||
if menu_item(ui, "Export for Screens…", true, false) {
|
||||
open_export_for_screens_assets(app, Some(&sel_or_all(&sel, &all)));
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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<RefCell<Vec<(String, Vec<u8>)>>>;
|
||||
|
||||
/// 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<egui::Event>, 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<egui::Event> {
|
||||
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<egui::Event> {
|
||||
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::<Vec<_>>()))
|
||||
.1
|
||||
.center()
|
||||
}
|
||||
|
||||
fn assets(app: &mut VectorcraftApp) -> Vec<Value> {
|
||||
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<Value> = 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);
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -32,6 +32,8 @@
|
|||
| `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`) |
|
||||
| `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 |
|
||||
|
||||
|
|
|
|||
22
docs/mcp.md
22
docs/mcp.md
|
|
@ -1412,3 +1412,25 @@ 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, File ›
|
||||
Export Selection…).
|
||||
`assets.add {ids?, multiple?}` collects the selected objects (or `ids`): one asset per object, or with
|
||||
`multiple: false` one asset of them all, named after the object or `Asset 1`, `Asset 2`…; art already collected keeps
|
||||
its asset. Assets name their objects, so they follow edits: moving the art moves the crop, and deleting it
|
||||
removes it from its asset (an asset left with nothing goes). `assets.list` gives each asset's id, name, objects and
|
||||
crop (`bounds`); `assets.rename {asset, name}` and `assets.remove {assets}` are undo steps.
|
||||
|
||||
`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}}}
|
||||
```
|
||||
|
|
|
|||
Loading…
Reference in New Issue