mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-14 19:26:56 -04:00
Merge pull request #756 from storytold/feat/745-libraries
Fix #745: a working Libraries panel of graphics, colours and text styles
This commit is contained in:
27 files changed
+1451
-19
No files matched your search
+1
-1
@@ -231,7 +231,7 @@ about 1–1.5 agent-hours each, so the other rows (estimated on the older scale)
|
||||
| Print, colour management, separations, flattener | 3 | 80% | print preview fidelity (flattener presets in PDF and print, overprint in PDF and composite print, Print dialog, presets, PostScript, marks, separations, Print Tiling, print overprint/flattener/bitmap options and printer profile are done) | 1–2 |
|
||||
| Automation | 3 | 60% | Variables (data merge), scripting surface, batch | 8–12 |
|
||||
| UI chrome (panels, contextual Properties, workspaces, prefs) | 6 | 78% | macOS native menu dismissal fixed (M14.8); remaining: Variables, SVG Interactivity, Properties per context, Consolidate All Windows, floating panels outside the app window and docked into any group, context menus beyond the canvas (Links, Asset Export, CSS Properties, Attributes and the canvas context menu are done), about 20 Preferences options stored but not yet acted on (#394: Spacing Guides, Show Font Names in English, Highlight Alternate Glyphs, Display Print Size, Open Documents As Tabs, Real-time Drawing, hyphenation, Appearance of Black, devices…) | 14–22 |
|
||||
| Libraries, Links, Package | 2 | 50% | a local Libraries panel (no cloud by design); Links and Package are done; placed VectorCraft documents are done (linked by default, vectors in every output, previews, Break Link, Expand, Edit Original, refresh on save) | 5–8 |
|
||||
| Libraries, Links, Package | 2 | 80% | the Libraries panel is local only (no cloud by design: libraries are files on this machine) and has no shared libraries, linked graphics or gradients and patterns as items yet; its graphics, colours and text styles are done (#745); Links and Package are done; placed VectorCraft documents are done (linked by default, vectors in every output, previews, Break Link, Expand, Edit Original, refresh on save) | 2–3 |
|
||||
| **Feature parity** | **103** | **~75%** | | **~210–330** |
|
||||
| Interaction-fidelity pass (side by side with Illustrator: every tool modifier, cursor, dialog, Properties context) | | | | 60–90 |
|
||||
| Hardening (big-file corpus, fuzzing, cross-platform and browser QA, accessibility, packaging) | | | | 50–80 |
|
||||
|
||||
@@ -489,6 +489,12 @@ fn main() -> std::process::ExitCode {
|
||||
app.session.swatch_libraries.set_user_dir(swatches);
|
||||
let styles = prefs_path().and_then(|p| Some(p.parent()?.join("Graphic Styles").to_string_lossy().to_string()));
|
||||
app.session.style_libraries.set_user_dir(styles);
|
||||
// So do the Libraries panel's libraries; runs without preferences (agents' test
|
||||
// runs) keep theirs for the session only, never touching the user's.
|
||||
if prefs_enabled() {
|
||||
let libraries = prefs_path().and_then(|p| Some(p.parent()?.join("Libraries").to_string_lossy().to_string()));
|
||||
app.session.libraries.set_dir(libraries);
|
||||
}
|
||||
// Data Recovery copies live next to the preferences too (none for runs without
|
||||
// preferences, such as agents' test runs, unless the recoveryFolder preference is set).
|
||||
if std::env::var_os("VECTORCRAFT_NO_PREFS").is_none() {
|
||||
|
||||
@@ -741,7 +741,8 @@ impl Document {
|
||||
}
|
||||
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);
|
||||
// An id of u64::MAX (a damaged file) can't overflow: ids after it are reused at worst.
|
||||
self.next_id = self.next_id.max(max.saturating_add(1));
|
||||
}
|
||||
|
||||
/// Find a node anywhere in the tree.
|
||||
|
||||
@@ -154,11 +154,18 @@ impl Clipboard {
|
||||
}
|
||||
vectorcraft_brush::store(&mut d, &lib);
|
||||
}
|
||||
let mut layer = Node::layer(NodeId(u64::MAX), "Clipboard", vectorcraft_doc::LayerColor::Preset(0));
|
||||
// The layer's id is above every copied object's, so the document saves and reopens as
|
||||
// a native file.
|
||||
let mut top = 0;
|
||||
for n in &self.nodes {
|
||||
n.walk(&mut |c| top = top.max(c.id.0));
|
||||
}
|
||||
let mut layer = Node::layer(NodeId(top.saturating_add(1)), "Clipboard", vectorcraft_doc::LayerColor::Preset(0));
|
||||
if let Some(ch) = layer.children_mut() {
|
||||
*ch = self.nodes.iter().cloned().map(Arc::new).collect();
|
||||
}
|
||||
d.layers = vec![Arc::new(layer)];
|
||||
d.fix_next_id();
|
||||
d
|
||||
}
|
||||
}
|
||||
|
||||
@@ -287,6 +287,8 @@ enum PasteMode {
|
||||
Back,
|
||||
InPlace,
|
||||
AllArtboards,
|
||||
/// A graphic from the Libraries panel: centred where it is placed, as Paste centres.
|
||||
Library,
|
||||
}
|
||||
|
||||
impl PasteMode {
|
||||
@@ -297,6 +299,7 @@ impl PasteMode {
|
||||
PasteMode::InPlace => "Paste in Place",
|
||||
PasteMode::AllArtboards => "Paste on All Artboards",
|
||||
PasteMode::Offset => "Paste",
|
||||
PasteMode::Library => "Place from Library",
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -310,6 +313,14 @@ fn paste(s: &mut Session, p: &Value, mode: PasteMode) -> Result<Value> {
|
||||
r
|
||||
}
|
||||
|
||||
/// Place the objects of `clip` (a library graphic) into the active document centred on `center`,
|
||||
/// as one undo step: their resources come along as a paste's do, a swatch whose name the document
|
||||
/// gives another colour merged into the document's.
|
||||
pub(crate) fn place_clip(s: &mut Session, clip: &Clipboard, center: vectorcraft_geom::Point) -> Result<Value> {
|
||||
let choices = SwatchChoices::parse(PasteMode::Library.label(), &json!({}))?;
|
||||
paste_clip(s, &json!({ "center": [center.x, center.y] }), PasteMode::Library, clip, &choices)
|
||||
}
|
||||
|
||||
/// Paste in Place, in Front or in Back onto artboard `artboard` (the active one, #693): where the
|
||||
/// objects were on the artboard they were copied from, on that one; without it (or a copy from no
|
||||
/// artboard), where they were.
|
||||
@@ -348,7 +359,7 @@ fn paste_clip(s: &mut Session, p: &Value, mode: PasteMode, clip: &Clipboard, cho
|
||||
};
|
||||
let placements: Vec<Affine> = match (mode, board) {
|
||||
(_, Some((_, dv))) => vec![Affine::translate(dv)],
|
||||
(PasteMode::Offset, None) => vec![match point_param(p, "center") {
|
||||
(PasteMode::Offset | PasteMode::Library, None) => vec![match point_param(p, "center") {
|
||||
Some(c) => clip.bounds().map_or(Affine::IDENTITY, |b| Affine::translate(c - b.center())),
|
||||
None => Affine::translate((f64_or(p, "dx", off), f64_or(p, "dy", off))),
|
||||
}],
|
||||
|
||||
@@ -54,6 +54,7 @@ pub(crate) use load::native_file;
|
||||
pub(crate) use load::source;
|
||||
pub use load::{Loaded, RasterImage, detect, file_name, load, load_with, open_bytes, open_bytes_with, open_template, raster_image};
|
||||
pub use load::{losses_summary, overwrite_losses};
|
||||
pub(crate) use native::preview_png;
|
||||
pub use native::with_compression_pref;
|
||||
pub use pdfimport::{LoadOptions, page_document};
|
||||
pub(crate) use save::job_for;
|
||||
|
||||
@@ -186,7 +186,7 @@ pub(super) fn ai_native(cmd: &str, f: &Format, doc: &Document, p: &Value) -> Res
|
||||
|
||||
/// The first artboard (else the art) as a PNG fitted into [`PREVIEW_MAX`] pixels (`None`: nothing
|
||||
/// to show).
|
||||
fn preview_png(doc: &Document) -> Result<Option<Vec<u8>>> {
|
||||
pub(crate) fn preview_png(doc: &Document) -> Result<Option<Vec<u8>>> {
|
||||
let Some(r) = doc.artboards.first().map(|a| a.rect).or_else(|| vectorcraft_render::encode::art_bounds(doc)) else { return Ok(None) };
|
||||
let scale = f64::from(PREVIEW_MAX) / r.width().max(r.height());
|
||||
if vectorcraft_render::raster_size(r, scale).is_err() {
|
||||
|
||||
@@ -0,0 +1,699 @@
|
||||
//! Libraries (Window › Libraries): local libraries of graphics, colours and text styles that every
|
||||
//! document can use. A library is one `.vclibrary` file (JSON) in the library folder next to the
|
||||
//! preferences, which the desktop app sets; without a folder (the web, headless sessions) libraries
|
||||
//! live for the session only. A graphic is kept as a native document with a PNG thumbnail: adding
|
||||
//! one copies the selection as Copy does, and placing it pastes a copy as Paste does (its images,
|
||||
//! symbols, patterns and swatches come along; a swatch name the document gives another colour is
|
||||
//! merged into the document's).
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Map, Value, json};
|
||||
use vectorcraft_color::{Color, Paint};
|
||||
use vectorcraft_doc::TextStyleDef;
|
||||
|
||||
use super::*;
|
||||
use crate::Clipboard;
|
||||
|
||||
/// The extension of a library file.
|
||||
pub const LIBRARY_EXT: &str = "vclibrary";
|
||||
/// Bytes a library file may have when read (graphics with large images included).
|
||||
const MAX_FILE: u64 = 512 << 20;
|
||||
/// Items of one kind a library may hold.
|
||||
const MAX_ITEMS: usize = 10_000;
|
||||
|
||||
/// A library: its name and its items, newest last.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Library {
|
||||
pub name: String,
|
||||
#[serde(default)]
|
||||
pub colors: Vec<LibraryColor>,
|
||||
#[serde(default)]
|
||||
pub char_styles: Vec<TextStyleDef>,
|
||||
#[serde(default)]
|
||||
pub para_styles: Vec<TextStyleDef>,
|
||||
#[serde(default)]
|
||||
pub graphics: Vec<LibraryGraphic>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct LibraryColor {
|
||||
pub name: String,
|
||||
pub color: Color,
|
||||
}
|
||||
|
||||
/// A graphic: the art as a native document (its own resources included) and its thumbnail.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LibraryGraphic {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
/// Size of the art (points).
|
||||
pub width: f64,
|
||||
pub height: f64,
|
||||
/// The native document, base64.
|
||||
pub data: String,
|
||||
/// A PNG of the art, at most 256 pixels a side, base64 (empty when it draws nothing).
|
||||
#[serde(default)]
|
||||
pub thumbnail: String,
|
||||
}
|
||||
|
||||
/// The libraries of a session ([`Session::libraries`]).
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct Libraries {
|
||||
/// The library folder: none in the web app and headless sessions.
|
||||
dir: Option<String>,
|
||||
/// (id: the file's stem, library), in name order.
|
||||
libs: Vec<(String, Library)>,
|
||||
/// The library the panel shows, which commands without a `library` act on.
|
||||
current: Option<String>,
|
||||
}
|
||||
|
||||
impl Libraries {
|
||||
pub fn dir(&self) -> Option<&str> {
|
||||
self.dir.as_deref()
|
||||
}
|
||||
|
||||
/// Set the library folder and read its libraries (unreadable files are skipped).
|
||||
pub fn set_dir(&mut self, dir: Option<String>) {
|
||||
self.dir = dir;
|
||||
self.libs.clear();
|
||||
if let Some(dir) = self.dir.clone() {
|
||||
for path in library_paths(&dir) {
|
||||
let id = stem_of(&path);
|
||||
let lib = super::fileio::file_stamp(&path)
|
||||
.filter(|(len, _)| *len <= MAX_FILE)
|
||||
.and_then(|_| super::fileio::read_file(&path).ok())
|
||||
.and_then(|b| serde_json::from_slice::<Library>(&b).ok());
|
||||
if let Some(mut lib) = lib {
|
||||
lib.cap();
|
||||
self.libs.push((id, lib));
|
||||
}
|
||||
}
|
||||
}
|
||||
self.sort();
|
||||
}
|
||||
|
||||
fn sort(&mut self) {
|
||||
self.libs.sort_by(|a, b| a.1.name.to_lowercase().cmp(&b.1.name.to_lowercase()).then(a.0.cmp(&b.0)));
|
||||
}
|
||||
|
||||
/// The libraries, as (id, library), in name order.
|
||||
pub fn all(&self) -> &[(String, Library)] {
|
||||
&self.libs
|
||||
}
|
||||
|
||||
/// The library the panel shows: the one chosen last, else the first.
|
||||
pub fn current(&self) -> Option<&str> {
|
||||
self.current.as_deref().filter(|c| self.get(c).is_some()).or_else(|| self.libs.first().map(|(i, _)| i.as_str()))
|
||||
}
|
||||
|
||||
pub fn get(&self, id: &str) -> Option<&Library> {
|
||||
self.libs.iter().find(|(i, _)| i == id).map(|(_, l)| l)
|
||||
}
|
||||
|
||||
/// Library `key` by id, else by name (ignoring case).
|
||||
fn find(&self, key: &str) -> Option<String> {
|
||||
self.libs.iter().find(|(i, _)| i == key).or_else(|| self.libs.iter().find(|(_, l)| l.name.eq_ignore_ascii_case(key))).map(|(i, _)| i.clone())
|
||||
}
|
||||
|
||||
/// A new empty library called `name` → its id.
|
||||
fn create(&mut self, name: &str) -> Result<String> {
|
||||
let base: String = name.chars().map(|c| if c.is_alphanumeric() || c == ' ' || c == '-' || c == '_' { c } else { '-' }).collect();
|
||||
let base = base.trim();
|
||||
let base = if base.is_empty() { "Library" } else { base };
|
||||
let taken = |id: &str| self.libs.iter().any(|(i, _)| i.eq_ignore_ascii_case(id)) || self.file_exists(id);
|
||||
let id = std::iter::once(base.to_string()).chain((2..).map(|n| format!("{base} {n}"))).find(|id| !taken(id)).unwrap_or_default();
|
||||
self.libs.push((id.clone(), Library { name: name.to_string(), ..Default::default() }));
|
||||
if let Err(e) = self.save(&id) {
|
||||
self.libs.retain(|(i, _)| i != &id);
|
||||
return Err(e);
|
||||
}
|
||||
self.sort();
|
||||
self.current = Some(id.clone());
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn file_exists(&self, id: &str) -> bool {
|
||||
self.path(id).is_some_and(|p| std::path::Path::new(&p).exists())
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn file_exists(&self, _: &str) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn path(&self, id: &str) -> Option<String> {
|
||||
let dir = self.dir.as_deref()?;
|
||||
Some(std::path::Path::new(dir).join(format!("{id}.{LIBRARY_EXT}")).to_string_lossy().into_owned())
|
||||
}
|
||||
|
||||
/// Write library `id` to its file (nothing to do without a folder).
|
||||
fn save(&self, id: &str) -> Result<()> {
|
||||
let (Some(path), Some(lib)) = (self.path(id), self.get(id)) else { return Ok(()) };
|
||||
if let Some(dir) = self.dir.as_deref() {
|
||||
super::fileio::create_dir(dir)?;
|
||||
}
|
||||
let bytes = serde_json::to_vec(lib).map_err(|e| EngineError::Other(e.to_string()))?;
|
||||
super::fileio::write_file(&path, &bytes)
|
||||
}
|
||||
|
||||
/// Change library `id` with `f` and save it; on a failed save the change is undone.
|
||||
fn change<T>(&mut self, id: &str, f: impl FnOnce(&mut Library) -> Result<T>) -> Result<T> {
|
||||
let i = self.libs.iter().position(|(i, _)| i == id).ok_or_else(|| EngineError::Other(format!("no library `{id}`")))?;
|
||||
let before = self.libs.get(i).map(|(_, l)| l.clone()).unwrap_or_default();
|
||||
let r = match self.libs.get_mut(i) {
|
||||
Some((_, lib)) => f(lib)?,
|
||||
None => return Err(EngineError::Other(format!("no library `{id}`"))),
|
||||
};
|
||||
if let Err(e) = self.save(id) {
|
||||
if let Some((_, lib)) = self.libs.get_mut(i) {
|
||||
*lib = before;
|
||||
}
|
||||
return Err(e);
|
||||
}
|
||||
self.sort();
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
fn delete(&mut self, id: &str) -> Result<()> {
|
||||
if let Some(path) = self.path(id) {
|
||||
remove_file(&path)?;
|
||||
}
|
||||
self.libs.retain(|(i, _)| i != id);
|
||||
if self.current.as_deref() == Some(id) {
|
||||
self.current = None;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Library {
|
||||
/// At most [`MAX_ITEMS`] of each kind (a hand-edited file may hold more).
|
||||
fn cap(&mut self) {
|
||||
self.colors.truncate(MAX_ITEMS);
|
||||
self.char_styles.truncate(MAX_ITEMS);
|
||||
self.para_styles.truncate(MAX_ITEMS);
|
||||
self.graphics.truncate(MAX_ITEMS);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn library_paths(dir: &str) -> Vec<String> {
|
||||
let Ok(rd) = std::fs::read_dir(dir) else { return vec![] };
|
||||
let mut v: Vec<String> = rd
|
||||
.filter_map(|e| e.ok().map(|e| e.path()))
|
||||
.filter(|p| p.extension().is_some_and(|x| x.to_string_lossy().eq_ignore_ascii_case(LIBRARY_EXT)))
|
||||
.map(|p| p.to_string_lossy().into_owned())
|
||||
.collect();
|
||||
v.sort();
|
||||
v
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn library_paths(_: &str) -> Vec<String> {
|
||||
vec![]
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn remove_file(path: &str) -> Result<()> {
|
||||
match std::fs::remove_file(path) {
|
||||
Ok(()) => Ok(()),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
|
||||
Err(e) => Err(EngineError::Other(format!("{path}: {e}"))),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn remove_file(_: &str) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn stem_of(path: &str) -> String {
|
||||
std::path::Path::new(path).file_stem().map_or_else(String::new, |s| s.to_string_lossy().into_owned())
|
||||
}
|
||||
|
||||
/// The kinds of library item.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum Kind {
|
||||
Graphic,
|
||||
FillColor,
|
||||
StrokeColor,
|
||||
CharStyle,
|
||||
ParaStyle,
|
||||
}
|
||||
|
||||
impl Kind {
|
||||
fn of(p: &Value, cmd: &str) -> Result<Self> {
|
||||
Ok(match str_param(p, "kind") {
|
||||
Some("graphic") => Kind::Graphic,
|
||||
Some("fillColor") => Kind::FillColor,
|
||||
Some("strokeColor") => Kind::StrokeColor,
|
||||
Some("color") => Kind::FillColor,
|
||||
Some("charStyle") => Kind::CharStyle,
|
||||
Some("paraStyle") => Kind::ParaStyle,
|
||||
_ => return Err(bad(cmd, "kind must be graphic, fillColor, strokeColor, charStyle or paraStyle")),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(
|
||||
query "library.list",
|
||||
"Libraries",
|
||||
[],
|
||||
None,
|
||||
"{} → {libraries: [{id, name, graphics, colors, charStyles, paraStyles} (counts)], current: the current library's id or null, folder: the library folder, or null when libraries last for the session only (the web, headless)}",
|
||||
always,
|
||||
list
|
||||
),
|
||||
cmd!(
|
||||
query "library.get",
|
||||
"Library",
|
||||
[],
|
||||
None,
|
||||
"{library?: id or name (default: the current library)} → {id, name, graphics: [{id, name, width, height, thumbnail: PNG base64}], colors: [{name, hex, color}], charStyles: [{name, attrs}], paraStyles: [{name, attrs}]}",
|
||||
always,
|
||||
get
|
||||
),
|
||||
cmd!(
|
||||
"library.setCurrent",
|
||||
"Show Library",
|
||||
[],
|
||||
None,
|
||||
"{library: id or name} the library the Libraries panel shows, which library commands without a `library` act on → {id}",
|
||||
always,
|
||||
set_current
|
||||
),
|
||||
cmd!(
|
||||
"library.create",
|
||||
"Create New Library",
|
||||
[],
|
||||
None,
|
||||
"{name?: \"My Library\"} a new empty library (its file in the library folder), made the current one → {id, name}",
|
||||
always,
|
||||
create
|
||||
),
|
||||
cmd!(
|
||||
"library.rename",
|
||||
"Rename Library",
|
||||
[],
|
||||
None,
|
||||
"{library?, name} rename a library (default: the current one) → {id, name}",
|
||||
always,
|
||||
rename
|
||||
),
|
||||
cmd!(
|
||||
"library.delete",
|
||||
"Delete Library",
|
||||
[],
|
||||
None,
|
||||
"{library?} delete a library (default: the current one) and its file; not undoable → null",
|
||||
always,
|
||||
delete
|
||||
),
|
||||
cmd!(
|
||||
"library.add",
|
||||
"Add to Library",
|
||||
[],
|
||||
None,
|
||||
"{library?: id or name (default: the current library, made when there is none), kind: graphic|fillColor|strokeColor|charStyle|paraStyle, ids?: objects (default: the selection), name?} add an item from the selection: graphic: a copy of the objects with every resource they use; fillColor/strokeColor: the first selected object's solid fill/stroke colour (else the default fill/stroke); charStyle/paraStyle: the selected text's character/paragraph attributes (the Type tool's range, else the first selected text object). An item the library already has (same kind and contents) isn't added again → {library, kind, name, id?: graphic id, existing: bool}",
|
||||
has_doc,
|
||||
add
|
||||
),
|
||||
cmd!(
|
||||
"library.use",
|
||||
"Use Library Item",
|
||||
[],
|
||||
None,
|
||||
"{library?, kind, item: name (graphic: id or name), center?: [x, y] (graphic; default: the first artboard's centre), to?: fill|stroke (colours; default: the kind's)} graphic: place a copy centred on `center`, its resources pasted along, selected, one undo step; colours: paint the selection (and the defaults) as paint.setFill/paint.setStroke; charStyle/paraStyle: add the style to the document (a name taken by different attributes gets a number) and apply it to the selected text, one undo step → {kind, ids?, style?}",
|
||||
has_doc,
|
||||
use_item
|
||||
),
|
||||
cmd!("library.removeItem", "Delete Library Item", [], None, "{library?, kind, item: name (graphic: id or name)} → null", always, remove_item),
|
||||
]
|
||||
}
|
||||
|
||||
/// The library `p` names (`library`: id or name), else the current one.
|
||||
fn library_id(s: &Session, p: &Value, cmd: &str) -> Result<String> {
|
||||
match str_param(p, "library") {
|
||||
Some(key) => s.libraries.find(key).ok_or_else(|| bad(cmd, format!("no library `{key}`"))),
|
||||
None => s.libraries.current().map(str::to_string).ok_or_else(|| bad(cmd, "there is no library: create one first")),
|
||||
}
|
||||
}
|
||||
|
||||
fn set_current(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "library.setCurrent";
|
||||
let key = str_param(p, "library").ok_or_else(|| bad(C, "missing `library`"))?;
|
||||
let id = s.libraries.find(key).ok_or_else(|| bad(C, format!("no library `{key}`")))?;
|
||||
s.libraries.current = Some(id.clone());
|
||||
Ok(json!({ "id": id }))
|
||||
}
|
||||
|
||||
fn counts(lib: &Library) -> Value {
|
||||
json!({
|
||||
"graphics": lib.graphics.len(),
|
||||
"colors": lib.colors.len(),
|
||||
"charStyles": lib.char_styles.len(),
|
||||
"paraStyles": lib.para_styles.len(),
|
||||
})
|
||||
}
|
||||
|
||||
fn list(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let libraries: Vec<Value> = s
|
||||
.libraries
|
||||
.all()
|
||||
.iter()
|
||||
.map(|(id, lib)| {
|
||||
let mut v = json!({ "id": id, "name": lib.name });
|
||||
if let (Some(o), Value::Object(c)) = (v.as_object_mut(), counts(lib)) {
|
||||
o.extend(c);
|
||||
}
|
||||
v
|
||||
})
|
||||
.collect();
|
||||
Ok(json!({ "libraries": libraries, "current": s.libraries.current(), "folder": s.libraries.dir() }))
|
||||
}
|
||||
|
||||
fn get(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let id = library_id(s, p, "library.get")?;
|
||||
let lib = s.libraries.get(&id).ok_or_else(|| bad("library.get", format!("no library `{id}`")))?;
|
||||
Ok(json!({
|
||||
"id": id,
|
||||
"name": lib.name,
|
||||
"graphics": lib.graphics.iter().map(|g| json!({"id": g.id, "name": g.name, "width": g.width, "height": g.height, "thumbnail": g.thumbnail})).collect::<Vec<_>>(),
|
||||
"colors": lib.colors.iter().map(|c| json!({"name": c.name, "hex": c.color.to_hex(), "color": c.color})).collect::<Vec<_>>(),
|
||||
"charStyles": lib.char_styles.iter().map(|t| json!({"name": t.name, "attrs": t.attrs})).collect::<Vec<_>>(),
|
||||
"paraStyles": lib.para_styles.iter().map(|t| json!({"name": t.name, "attrs": t.attrs})).collect::<Vec<_>>(),
|
||||
}))
|
||||
}
|
||||
|
||||
fn create(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let name = str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()).unwrap_or("My Library").to_string();
|
||||
let id = s.libraries.create(&name)?;
|
||||
Ok(json!({ "id": id, "name": name }))
|
||||
}
|
||||
|
||||
fn rename(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "library.rename";
|
||||
let id = library_id(s, p, C)?;
|
||||
let name = str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()).ok_or_else(|| bad(C, "missing `name`"))?.to_string();
|
||||
let n = name.clone();
|
||||
s.libraries.change(&id, |l| {
|
||||
l.name = n;
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(json!({ "id": id, "name": name }))
|
||||
}
|
||||
|
||||
fn delete(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let id = library_id(s, p, "library.delete")?;
|
||||
s.libraries.delete(&id)?;
|
||||
ok()
|
||||
}
|
||||
|
||||
/// The library `p` names, else the current one, else a new "My Library".
|
||||
fn target_library(s: &mut Session, p: &Value, cmd: &str) -> Result<String> {
|
||||
if p.get("library").is_some_and(|v| !v.is_null()) || s.libraries.current().is_some() {
|
||||
return library_id(s, p, cmd);
|
||||
}
|
||||
s.libraries.create("My Library")
|
||||
}
|
||||
|
||||
/// `base`, or `base 2`, `base 3`… the first that `taken` doesn't hold.
|
||||
fn unique(base: &str, taken: impl Fn(&str) -> bool) -> String {
|
||||
std::iter::once(base.to_string()).chain((2..).map(|n| format!("{base} {n}"))).find(|n| !taken(n)).unwrap_or_else(|| base.to_string())
|
||||
}
|
||||
|
||||
fn add(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "library.add";
|
||||
let kind = Kind::of(p, C)?;
|
||||
let given = str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()).map(str::to_string);
|
||||
// Read what is added before a library is made for it, so a failure makes none.
|
||||
let item = match kind {
|
||||
Kind::Graphic => Item::Graphic(graphic_of(s, p, C)?),
|
||||
Kind::FillColor | Kind::StrokeColor => Item::Color(color_of(s, p, kind == Kind::StrokeColor, C)?),
|
||||
Kind::CharStyle | Kind::ParaStyle => {
|
||||
let para = kind == Kind::ParaStyle;
|
||||
let attrs = super::textstyles::selected_style_attrs(s, para)?.ok_or_else(|| bad(C, "select some type first"))?;
|
||||
Item::Style(para, attrs)
|
||||
}
|
||||
};
|
||||
let id = target_library(s, p, C)?;
|
||||
let kind_name = str_param(p, "kind").unwrap_or_default().to_string();
|
||||
let r = s.libraries.change(&id, |lib| {
|
||||
let full = |n: usize| if n >= MAX_ITEMS { Err(bad(C, format!("the library holds {MAX_ITEMS} items of this kind already"))) } else { Ok(()) };
|
||||
Ok(match item {
|
||||
Item::Graphic(mut g) => {
|
||||
full(lib.graphics.len())?;
|
||||
if let Some(old) = lib.graphics.iter().find(|o| o.data == g.data) {
|
||||
return Ok(json!({ "name": old.name, "id": old.id, "existing": true }));
|
||||
}
|
||||
let base = given.clone().unwrap_or_else(|| g.name.clone());
|
||||
g.name = unique(&base, |n| lib.graphics.iter().any(|o| o.name == n));
|
||||
g.id = unique("graphic", |n| lib.graphics.iter().any(|o| o.id == n)).replace(' ', "-");
|
||||
let v = json!({ "name": g.name, "id": g.id, "existing": false });
|
||||
lib.graphics.push(g);
|
||||
v
|
||||
}
|
||||
Item::Color((name, color)) => {
|
||||
full(lib.colors.len())?;
|
||||
if let Some(old) = lib.colors.iter().find(|o| o.color == color) {
|
||||
return Ok(json!({ "name": old.name, "existing": true }));
|
||||
}
|
||||
let name = unique(&given.clone().unwrap_or(name), |n| lib.colors.iter().any(|o| o.name == n));
|
||||
lib.colors.push(LibraryColor { name: name.clone(), color });
|
||||
json!({ "name": name, "existing": false })
|
||||
}
|
||||
Item::Style(para, attrs) => {
|
||||
let list = if para { &mut lib.para_styles } else { &mut lib.char_styles };
|
||||
full(list.len())?;
|
||||
if let Some(old) = list.iter().find(|o| o.attrs == attrs) {
|
||||
return Ok(json!({ "name": old.name, "existing": true }));
|
||||
}
|
||||
let base = given.clone().unwrap_or_else(|| style_name(&attrs, para));
|
||||
let name = unique(&base, |n| list.iter().any(|o| o.name == n));
|
||||
list.push(TextStyleDef { name: name.clone(), attrs });
|
||||
json!({ "name": name, "existing": false })
|
||||
}
|
||||
})
|
||||
})?;
|
||||
let mut r = r;
|
||||
r["library"] = json!(id);
|
||||
r["kind"] = json!(kind_name);
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
enum Item {
|
||||
Graphic(LibraryGraphic),
|
||||
Color((String, Color)),
|
||||
Style(bool, Map<String, Value>),
|
||||
}
|
||||
|
||||
/// The selected objects (or `ids`) as a library graphic (its id and name set by the caller).
|
||||
fn graphic_of(s: &Session, p: &Value, cmd: &str) -> Result<LibraryGraphic> {
|
||||
let st = s.doc()?;
|
||||
let ids = match ids_param(p, "ids") {
|
||||
Some(ids) => ids,
|
||||
None => st.selection.in_paint_order(&st.doc),
|
||||
};
|
||||
let roots = super::edit::roots_of(&st.doc, ids);
|
||||
if roots.is_empty() {
|
||||
return Err(bad(cmd, "select the art to add first"));
|
||||
}
|
||||
let clip = Clipboard::copy(st, &roots);
|
||||
let bounds = clip.bounds().ok_or_else(|| bad(cmd, "the selection draws nothing"))?;
|
||||
let mut doc = clip.to_document();
|
||||
let board = bounds.inflate(1.0, 1.0);
|
||||
// One artboard round the art: what the thumbnail shows.
|
||||
doc.artboards.truncate(1);
|
||||
match doc.artboards.first_mut() {
|
||||
Some(a) => a.rect = board,
|
||||
None => return Err(bad(cmd, "the graphic's document has no artboard")),
|
||||
}
|
||||
let name = match roots.as_slice() {
|
||||
[one] => st.doc.node(*one).map(|n| n.display_name()).unwrap_or_default(),
|
||||
_ => String::new(),
|
||||
};
|
||||
let name = if name.trim().is_empty() || name.starts_with('<') { "Graphic".to_string() } else { name };
|
||||
let thumbnail = super::fileio::preview_png(&doc)?.map(|b| vectorcraft_format::base64_encode(&b)).unwrap_or_default();
|
||||
let data = vectorcraft_format::base64_encode(&vectorcraft_format::save(&doc, false));
|
||||
Ok(LibraryGraphic { id: String::new(), name, width: bounds.width(), height: bounds.height(), data, thumbnail })
|
||||
}
|
||||
|
||||
/// The first selected object's solid fill (`stroke`: stroke) colour, else the default one, with a
|
||||
/// name: its swatch's, else its hex.
|
||||
fn color_of(s: &Session, p: &Value, stroke: bool, cmd: &str) -> Result<(String, Color)> {
|
||||
// A colour given (a swatch dropped on the panel).
|
||||
if let Some(c) = p.get("color").filter(|v| !v.is_null()) {
|
||||
let color = color_value(c).ok_or_else(|| bad(cmd, "bad colour"))?;
|
||||
return Ok((color.to_hex(), color));
|
||||
}
|
||||
let st = s.doc()?;
|
||||
let ids = match ids_param(p, "ids") {
|
||||
Some(ids) => ids,
|
||||
None => st.selection.objects.clone(),
|
||||
};
|
||||
let from_art = ids.iter().filter_map(|id| st.doc.node(*id)).find_map(|n| {
|
||||
let paint = if stroke { n.appearance.stroke().map(|x| &x.paint) } else { n.appearance.fill().map(|f| &f.paint) };
|
||||
paint.cloned()
|
||||
});
|
||||
let paint = match from_art {
|
||||
Some(p) => p,
|
||||
None => {
|
||||
let d = &s.paint;
|
||||
if stroke { d.stroke.clone() } else { d.fill.clone() }
|
||||
}
|
||||
};
|
||||
match paint {
|
||||
Paint::Solid { color, swatch, .. } => {
|
||||
let name = swatch.filter(|n| !n.is_empty()).unwrap_or_else(|| color.to_hex());
|
||||
Ok((name, color))
|
||||
}
|
||||
Paint::None => Err(bad(cmd, if stroke { "the selection has no stroke colour" } else { "the selection has no fill colour" })),
|
||||
_ => Err(bad(cmd, "only solid colours can be added (not gradients or patterns)")),
|
||||
}
|
||||
}
|
||||
|
||||
/// A character style's name from its font, style and size ("Source Sans 3 Bold 24 pt"); a
|
||||
/// paragraph style's from its alignment and size too, else "Paragraph Style".
|
||||
fn style_name(attrs: &Map<String, Value>, para: bool) -> String {
|
||||
let s = |k: &str| attrs.get(k).and_then(Value::as_str).unwrap_or_default().to_string();
|
||||
let size = attrs.get("size").and_then(Value::as_f64).map(|v| format!("{} pt", (v * 100.0).round() / 100.0));
|
||||
let words: Vec<String> = [s("font_family"), s("font_style")].into_iter().chain(size).filter(|w| !w.is_empty()).collect();
|
||||
match (para, words.is_empty()) {
|
||||
(false, false) => words.join(" "),
|
||||
(false, true) => "Character Style".into(),
|
||||
(true, _) => "Paragraph Style".into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The library item `p` names: (library id, kind).
|
||||
fn item_param<'a>(s: &Session, p: &'a Value, cmd: &str) -> Result<(String, Kind, &'a str)> {
|
||||
let id = library_id(s, p, cmd)?;
|
||||
let kind = Kind::of(p, cmd)?;
|
||||
let item = str_param(p, "item").ok_or_else(|| bad(cmd, "missing `item`"))?;
|
||||
Ok((id, kind, item))
|
||||
}
|
||||
|
||||
fn use_item(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "library.use";
|
||||
// A replay (the journal) carries what the item was, so it doesn't depend on the library.
|
||||
let recorded = p.get("recorded").cloned();
|
||||
let (kind, data) = match recorded {
|
||||
Some(r) => (Kind::of(p, C)?, r),
|
||||
None => {
|
||||
let (id, kind, item) = item_param(s, p, C)?;
|
||||
let lib = s.libraries.get(&id).ok_or_else(|| bad(C, format!("no library `{id}`")))?;
|
||||
let missing = || bad(C, format!("the library has no item `{item}`"));
|
||||
let data = match kind {
|
||||
Kind::Graphic => {
|
||||
let g =
|
||||
lib.graphics.iter().find(|g| g.id == item).or_else(|| lib.graphics.iter().find(|g| g.name == item)).ok_or_else(missing)?;
|
||||
json!({ "data": g.data })
|
||||
}
|
||||
Kind::FillColor | Kind::StrokeColor => {
|
||||
let c = lib.colors.iter().find(|c| c.name == item).ok_or_else(missing)?;
|
||||
json!({ "color": c.color })
|
||||
}
|
||||
Kind::CharStyle | Kind::ParaStyle => {
|
||||
let list = if kind == Kind::ParaStyle { &lib.para_styles } else { &lib.char_styles };
|
||||
let t = list.iter().find(|t| t.name == item).ok_or_else(missing)?;
|
||||
json!({ "name": t.name, "attrs": t.attrs })
|
||||
}
|
||||
};
|
||||
s.note_journal("recorded", data.clone());
|
||||
(kind, data)
|
||||
}
|
||||
};
|
||||
match kind {
|
||||
Kind::Graphic => {
|
||||
let bytes = data
|
||||
.get("data")
|
||||
.and_then(Value::as_str)
|
||||
.and_then(vectorcraft_format::base64_decode)
|
||||
.ok_or_else(|| bad(C, "the graphic's data is damaged"))?;
|
||||
let doc = vectorcraft_format::load(&bytes).map_err(|e| bad(C, format!("the graphic can't be read: {e}")))?;
|
||||
let clip = Clipboard::from_document(&doc);
|
||||
if clip.is_empty() {
|
||||
return Err(bad(C, "the graphic is empty"));
|
||||
}
|
||||
let center = match point_param(p, "center") {
|
||||
Some(c) => c,
|
||||
None => s.doc()?.doc.artboards.first().map(|a| a.rect.center()).unwrap_or_default(),
|
||||
};
|
||||
let mut r = super::edit::place_clip(s, &clip, center)?;
|
||||
r["kind"] = json!("graphic");
|
||||
Ok(r)
|
||||
}
|
||||
Kind::FillColor | Kind::StrokeColor => {
|
||||
let stroke = match str_param(p, "to") {
|
||||
Some("fill") => false,
|
||||
Some("stroke") => true,
|
||||
Some(_) => return Err(bad(C, "to must be fill or stroke")),
|
||||
None => kind == Kind::StrokeColor,
|
||||
};
|
||||
let color = data.get("color").cloned().ok_or_else(|| bad(C, "no colour"))?;
|
||||
s.execute(if stroke { "paint.setStroke" } else { "paint.setFill" }, &json!({ "color": color }))?;
|
||||
Ok(json!({ "kind": if stroke { "strokeColor" } else { "fillColor" } }))
|
||||
}
|
||||
Kind::CharStyle | Kind::ParaStyle => {
|
||||
let para = kind == Kind::ParaStyle;
|
||||
let name = data.get("name").and_then(Value::as_str).unwrap_or("Style").to_string();
|
||||
let attrs = data.get("attrs").and_then(Value::as_object).cloned().unwrap_or_default();
|
||||
let doc = &s.doc()?.doc;
|
||||
// The document's style of that name when it is the same; else a new one.
|
||||
let same = super::textstyles::style_attrs(doc, para, &name).is_some_and(|a| a == attrs);
|
||||
let style = if same { name } else { unique(&name, |n| super::textstyles::style_attrs(doc, para, n).is_some()) };
|
||||
let prefix = if para { "paraStyle" } else { "charStyle" };
|
||||
in_one_step(s, |s| {
|
||||
if !same {
|
||||
s.execute(&format!("{prefix}.new"), &json!({ "name": style, "attrs": attrs }))?;
|
||||
}
|
||||
s.execute(&format!("{prefix}.apply"), &json!({ "name": style }))
|
||||
})?;
|
||||
Ok(json!({ "kind": if para { "paraStyle" } else { "charStyle" }, "style": style }))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Run `f`'s commands as one undo step (undone together when it fails), unless an undo group is
|
||||
/// already open (its own step then holds them).
|
||||
fn in_one_step<T>(s: &mut Session, f: impl FnOnce(&mut Session) -> Result<T>) -> Result<T> {
|
||||
let open = s.active().is_some_and(|st| st.undo_group.is_some());
|
||||
if !open {
|
||||
s.begin_undo_group();
|
||||
}
|
||||
let r = f(s);
|
||||
if !open {
|
||||
s.end_undo_group(r.is_err());
|
||||
}
|
||||
r
|
||||
}
|
||||
|
||||
fn remove_item(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "library.removeItem";
|
||||
let (id, kind, item) = item_param(s, p, C)?;
|
||||
let item = item.to_string();
|
||||
s.libraries.change(&id, |lib| {
|
||||
let before = (lib.graphics.len(), lib.colors.len(), lib.char_styles.len(), lib.para_styles.len());
|
||||
match kind {
|
||||
Kind::Graphic => {
|
||||
let at = lib.graphics.iter().position(|g| g.id == item).or_else(|| lib.graphics.iter().position(|g| g.name == item));
|
||||
if let Some(i) = at {
|
||||
lib.graphics.remove(i);
|
||||
}
|
||||
}
|
||||
Kind::FillColor | Kind::StrokeColor => lib.colors.retain(|c| c.name != item),
|
||||
Kind::CharStyle => lib.char_styles.retain(|t| t.name != item),
|
||||
Kind::ParaStyle => lib.para_styles.retain(|t| t.name != item),
|
||||
}
|
||||
if before == (lib.graphics.len(), lib.colors.len(), lib.char_styles.len(), lib.para_styles.len()) {
|
||||
return Err(bad(C, format!("the library has no item `{item}`")));
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
ok()
|
||||
}
|
||||
@@ -32,6 +32,7 @@ pub mod help;
|
||||
pub(crate) mod inline;
|
||||
mod layer;
|
||||
mod layerpanel;
|
||||
pub mod library;
|
||||
pub mod links;
|
||||
mod live;
|
||||
pub(crate) mod maskedit;
|
||||
@@ -245,6 +246,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
|
||||
v.extend(freeform::specs());
|
||||
v.extend(flatten::specs());
|
||||
v.extend(stylelib::specs());
|
||||
v.extend(library::specs());
|
||||
v.extend(expand::specs());
|
||||
v.extend(attributes::specs());
|
||||
v.extend(newart::specs());
|
||||
|
||||
@@ -241,6 +241,17 @@ fn selection_attrs(s: &Session, p: &Value, kind: Kind) -> Result<Option<Map<Stri
|
||||
}))
|
||||
}
|
||||
|
||||
/// The selected text's character (`para`: paragraph) attributes, every one of them: what a
|
||||
/// library keeps of a text style. `None` without text selected.
|
||||
pub(crate) fn selected_style_attrs(s: &Session, para: bool) -> Result<Option<Map<String, Value>>> {
|
||||
selection_attrs(s, &json!({}), if para { Kind::Para } else { Kind::Char })
|
||||
}
|
||||
|
||||
/// Whether the document has a character (`para`: paragraph) style `name`, and its attributes.
|
||||
pub(crate) fn style_attrs(d: &Document, para: bool, name: &str) -> Option<Map<String, Value>> {
|
||||
if para { Kind::Para } else { Kind::Char }.attrs(d, name)
|
||||
}
|
||||
|
||||
fn name_param<'a>(p: &'a Value, kind: Kind, verb: &str) -> Result<&'a str> {
|
||||
str_param(p, "name").ok_or_else(|| bad(&kind.cmd(verb), "missing `name`"))
|
||||
}
|
||||
|
||||
@@ -829,6 +829,8 @@ pub struct Session {
|
||||
pub(crate) freeform_point: Option<(usize, cmd::gradient::StopOwner)>,
|
||||
/// User Defined and loaded graphic style libraries (Window → Graphic Style Libraries); not saved.
|
||||
pub style_libraries: cmd::stylelib::Libraries,
|
||||
/// The Libraries panel's libraries of graphics, colours and text styles (`library.*`).
|
||||
pub libraries: cmd::library::Libraries,
|
||||
/// URLs recently given in the Attributes panel (`attributes.set {url}`), newest first; not saved.
|
||||
pub recent_urls: Vec<String>,
|
||||
/// The language the UI is drawn in (a language code, never `auto`), set by the UI each frame;
|
||||
@@ -899,6 +901,7 @@ impl Session {
|
||||
swatch_libraries: Default::default(),
|
||||
freeform_point: None,
|
||||
style_libraries: Default::default(),
|
||||
libraries: Default::default(),
|
||||
recent_urls: vec![],
|
||||
ui_language: None,
|
||||
journal_note: Default::default(),
|
||||
@@ -1475,6 +1478,8 @@ mod tests_layerclip;
|
||||
#[cfg(test)]
|
||||
mod tests_layers;
|
||||
#[cfg(test)]
|
||||
mod tests_library;
|
||||
#[cfg(test)]
|
||||
mod tests_linked_stops;
|
||||
#[cfg(test)]
|
||||
mod tests_links;
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
//! The Libraries panel's libraries (#745): making, renaming and deleting them, adding graphics,
|
||||
//! colours and text styles from the selection, using them in another document, and keeping them
|
||||
//! in files across sessions.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::*;
|
||||
|
||||
fn session() -> Session {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 200, "height": 200})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
fn run(s: &mut Session, id: &str, p: Value) -> Value {
|
||||
s.execute(id, &p).unwrap_or_else(|e| panic!("{id} {p}: {e}"))
|
||||
}
|
||||
|
||||
fn objects(s: &Session) -> usize {
|
||||
s.doc().unwrap().doc.layers[0].children().map_or(0, |c| c.len())
|
||||
}
|
||||
|
||||
fn hex_of(s: &Session, id: &Value, stroke: bool) -> String {
|
||||
let n = s.doc().unwrap().doc.node(NodeId(id.as_u64().unwrap())).unwrap().clone();
|
||||
let paint = if stroke { n.appearance.stroke().unwrap().paint.clone() } else { n.appearance.fill().unwrap().paint.clone() };
|
||||
paint.color().unwrap().to_hex()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn libraries_are_made_renamed_and_deleted() {
|
||||
let mut s = session();
|
||||
assert_eq!(run(&mut s, "library.list", json!({}))["libraries"], json!([]));
|
||||
let id = run(&mut s, "library.create", json!({"name": "Brand"}))["id"].as_str().unwrap().to_string();
|
||||
let other = run(&mut s, "library.create", json!({"name": "Brand"}))["id"].as_str().unwrap().to_string();
|
||||
assert_ne!(id, other, "two libraries of one name are two files");
|
||||
run(&mut s, "library.rename", json!({"library": other, "name": "Client"}));
|
||||
let list = run(&mut s, "library.list", json!({}));
|
||||
let names: Vec<&str> = list["libraries"].as_array().unwrap().iter().map(|l| l["name"].as_str().unwrap()).collect();
|
||||
assert_eq!(names, ["Brand", "Client"]);
|
||||
assert!(list["folder"].is_null(), "no folder: libraries last for the session");
|
||||
run(&mut s, "library.delete", json!({"library": "client"}));
|
||||
assert_eq!(run(&mut s, "library.list", json!({}))["libraries"].as_array().unwrap().len(), 1);
|
||||
assert!(s.execute("library.get", &json!({"library": "Client"})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_graphic_goes_into_a_library_and_comes_out_in_another_document() {
|
||||
let mut s = session();
|
||||
let id = run(&mut s, "shape.ellipse", json!({"x": 10, "y": 10, "width": 40, "height": 30}))["id"].clone();
|
||||
run(&mut s, "paint.setFill", json!({"color": "#cc3300", "ids": [id]}));
|
||||
run(&mut s, "select.set", json!({"ids": [id]}));
|
||||
// With no library yet, adding makes "My Library".
|
||||
let added = run(&mut s, "library.add", json!({"kind": "graphic"}));
|
||||
assert_eq!(added["existing"], false);
|
||||
let lib = added["library"].as_str().unwrap().to_string();
|
||||
let item = added["id"].as_str().unwrap().to_string();
|
||||
let got = run(&mut s, "library.get", json!({"library": lib}));
|
||||
assert_eq!(got["name"], "My Library");
|
||||
let g = &got["graphics"][0];
|
||||
// Its size as it draws: the 1 pt stroke included.
|
||||
assert!((g["width"].as_f64().unwrap() - 41.0).abs() < 1e-6 && (g["height"].as_f64().unwrap() - 31.0).abs() < 1e-6, "{g}");
|
||||
assert!(!g["thumbnail"].as_str().unwrap().is_empty(), "a thumbnail of the art");
|
||||
// The same art again is the item already there.
|
||||
assert_eq!(run(&mut s, "library.add", json!({"kind": "graphic"}))["existing"], true);
|
||||
// Placed in another document, centred where asked, selected, one undo step.
|
||||
run(&mut s, "file.new", json!({"width": 300, "height": 300}));
|
||||
let placed = run(&mut s, "library.use", json!({"library": lib, "kind": "graphic", "item": item, "center": [150, 100]}));
|
||||
let new_id = placed["ids"][0].clone();
|
||||
let st = s.doc().unwrap();
|
||||
let b = st.doc.node(NodeId(new_id.as_u64().unwrap())).unwrap().geometric_bounds().unwrap();
|
||||
assert!((b.center().x - 150.0).abs() < 1e-6 && (b.center().y - 100.0).abs() < 1e-6, "{b:?}");
|
||||
assert_eq!(st.selection.objects, vec![NodeId(new_id.as_u64().unwrap())]);
|
||||
assert_eq!(hex_of(&s, &new_id, false), "#cc3300");
|
||||
assert_eq!(objects(&s), 1);
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(objects(&s), 0);
|
||||
// A graphic is removed by id.
|
||||
run(&mut s, "library.removeItem", json!({"library": lib, "kind": "graphic", "item": item}));
|
||||
assert!(s.execute("library.use", &json!({"library": lib, "kind": "graphic", "item": item})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn colours_come_from_the_selection_and_paint_it() {
|
||||
let mut s = session();
|
||||
let lib = run(&mut s, "library.create", json!({}))["id"].as_str().unwrap().to_string();
|
||||
let a = run(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 20, "height": 20}))["id"].clone();
|
||||
run(&mut s, "paint.setFill", json!({"color": "#123456", "ids": [a]}));
|
||||
run(&mut s, "paint.setStroke", json!({"color": "#abcdef", "ids": [a]}));
|
||||
run(&mut s, "select.set", json!({"ids": [a]}));
|
||||
assert_eq!(run(&mut s, "library.add", json!({"library": lib, "kind": "fillColor"}))["name"], "#123456");
|
||||
assert_eq!(run(&mut s, "library.add", json!({"library": lib, "kind": "strokeColor"}))["name"], "#abcdef");
|
||||
assert_eq!(run(&mut s, "library.add", json!({"library": lib, "kind": "fillColor"}))["existing"], true);
|
||||
let b = run(&mut s, "shape.rectangle", json!({"x": 50, "y": 0, "width": 20, "height": 20}))["id"].clone();
|
||||
run(&mut s, "select.set", json!({"ids": [b]}));
|
||||
run(&mut s, "library.use", json!({"library": lib, "kind": "fillColor", "item": "#123456"}));
|
||||
run(&mut s, "library.use", json!({"library": lib, "kind": "fillColor", "item": "#123456", "to": "stroke"}));
|
||||
assert_eq!(hex_of(&s, &b, false), "#123456");
|
||||
assert_eq!(hex_of(&s, &b, true), "#123456");
|
||||
assert!(s.execute("library.use", &json!({"library": lib, "kind": "fillColor", "item": "#nope"})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_styles_come_from_the_selected_type_and_apply_in_one_step() {
|
||||
let mut s = session();
|
||||
let lib = run(&mut s, "library.create", json!({}))["id"].as_str().unwrap().to_string();
|
||||
assert!(s.execute("library.add", &json!({"library": lib, "kind": "charStyle"})).is_err(), "no type selected");
|
||||
let t = run(&mut s, "text.create", json!({"x": 10, "y": 50, "text": "Heading", "size": 31}))["id"].clone();
|
||||
run(&mut s, "select.set", json!({"ids": [t]}));
|
||||
let name = run(&mut s, "library.add", json!({"library": lib, "kind": "charStyle"}))["name"].as_str().unwrap().to_string();
|
||||
assert!(name.ends_with("31 pt"), "{name}");
|
||||
run(&mut s, "library.add", json!({"library": lib, "kind": "paraStyle"}));
|
||||
// Another document: the style is added and applied, and one undo takes both back.
|
||||
run(&mut s, "file.new", json!({"width": 200, "height": 200}));
|
||||
let u = run(&mut s, "text.create", json!({"x": 10, "y": 50, "text": "Body", "size": 9}))["id"].clone();
|
||||
run(&mut s, "select.set", json!({"ids": [u]}));
|
||||
let undo = s.doc().unwrap().history.undo.len();
|
||||
let r = run(&mut s, "library.use", json!({"library": lib, "kind": "charStyle", "item": name}));
|
||||
assert_eq!(r["style"], name);
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "one undo step");
|
||||
let size = |s: &Session| match &s.doc().unwrap().doc.node(NodeId(u.as_u64().unwrap())).unwrap().kind {
|
||||
vectorcraft_doc::NodeKind::Text(t) => t.runs[0].style.size,
|
||||
_ => 0.0,
|
||||
};
|
||||
assert_eq!(size(&s), 31.0);
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(size(&s), 9.0);
|
||||
assert!(s.doc().unwrap().doc.char_styles.iter().all(|c| c.name != name), "the style went with the undo");
|
||||
// A style of that name with other attributes in the document: the library's comes in numbered.
|
||||
run(&mut s, "charStyle.new", json!({"name": name, "attrs": {"size": 5}}));
|
||||
let r = run(&mut s, "library.use", json!({"library": lib, "kind": "charStyle", "item": name}));
|
||||
assert_eq!(r["style"], format!("{name} 2"));
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[test]
|
||||
fn libraries_are_kept_in_files_across_sessions() {
|
||||
let dir = std::env::temp_dir().join(format!("vc-libraries-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let folder = dir.to_string_lossy().to_string();
|
||||
let mut s = session();
|
||||
s.libraries.set_dir(Some(folder.clone()));
|
||||
let lib = run(&mut s, "library.create", json!({"name": "Kept"}))["id"].as_str().unwrap().to_string();
|
||||
let id = run(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 10, "height": 10}))["id"].clone();
|
||||
run(&mut s, "select.set", json!({"ids": [id]}));
|
||||
run(&mut s, "library.add", json!({"library": lib, "kind": "graphic", "name": "Square"}));
|
||||
run(&mut s, "library.add", json!({"library": lib, "kind": "fillColor"}));
|
||||
let mut t = session();
|
||||
t.libraries.set_dir(Some(folder.clone()));
|
||||
let got = run(&mut t, "library.get", json!({"library": "Kept"}));
|
||||
assert_eq!(got["graphics"][0]["name"], "Square");
|
||||
assert_eq!(got["colors"].as_array().unwrap().len(), 1);
|
||||
run(&mut t, "library.delete", json!({"library": "Kept"}));
|
||||
let mut u = session();
|
||||
u.libraries.set_dir(Some(folder));
|
||||
assert_eq!(run(&mut u, "library.list", json!({}))["libraries"], json!([]), "deleting removes the file");
|
||||
// A damaged file is skipped.
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
std::fs::write(dir.join("Bad.vclibrary"), b"{not json").unwrap();
|
||||
let mut v = session();
|
||||
v.libraries.set_dir(Some(dir.to_string_lossy().to_string()));
|
||||
assert_eq!(run(&mut v, "library.list", json!({}))["libraries"], json!([]));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
@@ -1295,6 +1295,14 @@ fn panel_drop(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Response, xf: &Xf)
|
||||
return;
|
||||
}
|
||||
let Some(d) = resp.dnd_release_payload::<widgets::PanelDrag>() else { return };
|
||||
// A Libraries panel graphic: a copy centred where it is dropped.
|
||||
if let widgets::PanelDrag::LibraryGraphic { library, item } = &*d {
|
||||
let at = xf.to_doc(pos);
|
||||
if let Err(e) = app.run("library.use", json!({"library": library, "kind": "graphic", "item": item, "center": [at.x, at.y]})) {
|
||||
app.status(e);
|
||||
}
|
||||
return;
|
||||
}
|
||||
// A symbol from the Symbols panel: an instance centred where it is dropped, on art or not.
|
||||
if let widgets::PanelDrag::Symbol(name) = &*d {
|
||||
let at = xf.to_doc(pos);
|
||||
@@ -1333,7 +1341,7 @@ fn panel_drop(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Response, xf: &Xf)
|
||||
}
|
||||
// Art dragged back onto the canvas: its move was already dropped. (A symbol was placed
|
||||
// above.)
|
||||
widgets::PanelDrag::Art(_) | widgets::PanelDrag::Symbol(_) => return,
|
||||
widgets::PanelDrag::Art(_) | widgets::PanelDrag::Symbol(_) | widgets::PanelDrag::LibraryGraphic { .. } => return,
|
||||
};
|
||||
if let Err(e) = app.run(cmd, params) {
|
||||
app.status(e);
|
||||
|
||||
@@ -2648,6 +2648,24 @@
|
||||
Swatch Tiles Mosaicos de muestras
|
||||
Swatches Muestras
|
||||
Switch workspace Cambiar espacio de trabajo
|
||||
Add Content Añadir contenido
|
||||
Click to apply to the selected type Haz clic para aplicarlo al texto seleccionado
|
||||
Create New Library Crear nueva biblioteca
|
||||
Create New Library… Crear nueva biblioteca…
|
||||
Rename Library… Cambiar nombre de la biblioteca…
|
||||
Delete the library “{name}”? ¿Eliminar la biblioteca “{name}”?
|
||||
Drag art or swatches here, or use + to add the selection's colors and text styles. Arrastra aquí ilustraciones o muestras, o usa + para añadir los colores y estilos de texto de la selección.
|
||||
Its graphics, colors and text styles are deleted from this machine. Sus gráficos, colores y estilos de texto se eliminan de este equipo.
|
||||
Keep graphics, colors and text styles here to use them in any document. Libraries are stored on this machine — no account required. Guarda aquí gráficos, colores y estilos de texto para usarlos en cualquier documento. Las bibliotecas se guardan en este equipo; no hace falta una cuenta.
|
||||
This library is empty Esta biblioteca está vacía
|
||||
Add to Library Añadir a la biblioteca
|
||||
Delete Library Eliminar biblioteca
|
||||
Delete Library Item Eliminar elemento de la biblioteca
|
||||
Library Biblioteca
|
||||
Rename Library Cambiar nombre de la biblioteca
|
||||
Show Library Mostrar biblioteca
|
||||
Use Library Item Usar elemento de la biblioteca
|
||||
Graphic Gráfico
|
||||
Show all open documents Mostrar todos los documentos abiertos
|
||||
Symbol Instance Instancia de símbolo
|
||||
Symbol Libraries Bibliotecas de símbolos
|
||||
@@ -3130,6 +3148,7 @@ axis Both Ambos
|
||||
# stays English for agents and tests. `{_1}`, `{_2}` … stand for the `{}` of the format string, in order
|
||||
# (`complete_languages_translate_every_message` lists any that are missing). Command ids, parameter
|
||||
# names and values in backticks or code (`colorModel rgb`, `document.export`) stay as they are.
|
||||
@msg no library `{id}` no hay ninguna biblioteca `{id}`
|
||||
@msg select some type first selecciona primero algún texto
|
||||
@msg {_1} has no {_2} style {_1} no tiene el estilo {_2}
|
||||
@msg a layer couldn't be read no se pudo leer una capa
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -2645,6 +2645,24 @@
|
||||
Swatch Tiles Carreaux de nuances
|
||||
Swatches Nuancier
|
||||
Switch workspace Changer d’espace de travail
|
||||
Add Content Ajouter du contenu
|
||||
Click to apply to the selected type Cliquez pour l’appliquer au texte sélectionné
|
||||
Create New Library Créer une bibliothèque
|
||||
Create New Library… Créer une bibliothèque…
|
||||
Rename Library… Renommer la bibliothèque…
|
||||
Delete the library “{name}”? Supprimer la bibliothèque « {name} » ?
|
||||
Drag art or swatches here, or use + to add the selection's colors and text styles. Faites glisser ici des illustrations ou des nuances, ou utilisez + pour ajouter les couleurs et les styles de texte de la sélection.
|
||||
Its graphics, colors and text styles are deleted from this machine. Ses graphismes, couleurs et styles de texte sont supprimés de cet ordinateur.
|
||||
Keep graphics, colors and text styles here to use them in any document. Libraries are stored on this machine — no account required. Conservez ici des graphismes, des couleurs et des styles de texte pour les utiliser dans n’importe quel document. Les bibliothèques sont stockées sur cet ordinateur, sans compte.
|
||||
This library is empty Cette bibliothèque est vide
|
||||
Add to Library Ajouter à la bibliothèque
|
||||
Delete Library Supprimer la bibliothèque
|
||||
Delete Library Item Supprimer l’élément de la bibliothèque
|
||||
Library Bibliothèque
|
||||
Rename Library Renommer la bibliothèque
|
||||
Show Library Afficher la bibliothèque
|
||||
Use Library Item Utiliser l’élément de la bibliothèque
|
||||
Graphic Graphisme
|
||||
Show all open documents Afficher tous les documents ouverts
|
||||
Symbol Instance Occurrence de symbole
|
||||
Symbol Libraries Bibliothèques de symboles
|
||||
@@ -3127,6 +3145,7 @@ axis Both Les deux
|
||||
# stays English for agents and tests. `{_1}`, `{_2}` … stand for the `{}` of the format string, in order
|
||||
# (`complete_languages_translate_every_message` lists any that are missing). Command ids, parameter
|
||||
# names and values in backticks or code (`colorModel rgb`, `document.export`) stay as they are.
|
||||
@msg no library `{id}` aucune bibliothèque `{id}`
|
||||
@msg select some type first sélectionnez d’abord du texte
|
||||
@msg {_1} has no {_2} style {_1} n’a pas de style {_2}
|
||||
@msg a layer couldn't be read un calque n’a pas pu être lu
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -2648,6 +2648,24 @@
|
||||
Swatch Tiles Riquadri campioni
|
||||
Swatches Campioni
|
||||
Switch workspace Cambia area di lavoro
|
||||
Add Content Aggiungi contenuto
|
||||
Click to apply to the selected type Fai clic per applicarlo al testo selezionato
|
||||
Create New Library Crea nuova libreria
|
||||
Create New Library… Crea nuova libreria…
|
||||
Rename Library… Rinomina libreria…
|
||||
Delete the library “{name}”? Eliminare la libreria “{name}”?
|
||||
Drag art or swatches here, or use + to add the selection's colors and text styles. Trascina qui grafica o campioni, oppure usa + per aggiungere i colori e gli stili di testo della selezione.
|
||||
Its graphics, colors and text styles are deleted from this machine. Grafiche, colori e stili di testo vengono eliminati da questo computer.
|
||||
Keep graphics, colors and text styles here to use them in any document. Libraries are stored on this machine — no account required. Conserva qui grafiche, colori e stili di testo per usarli in qualsiasi documento. Le librerie restano su questo computer: non serve un account.
|
||||
This library is empty Questa libreria è vuota
|
||||
Add to Library Aggiungi alla libreria
|
||||
Delete Library Elimina libreria
|
||||
Delete Library Item Elimina elemento della libreria
|
||||
Library Libreria
|
||||
Rename Library Rinomina libreria
|
||||
Show Library Mostra libreria
|
||||
Use Library Item Usa elemento della libreria
|
||||
Graphic Grafica
|
||||
Show all open documents Mostra tutti i documenti aperti
|
||||
Symbol Instance Istanza di simbolo
|
||||
Symbol Libraries Librerie simboli
|
||||
@@ -3130,6 +3148,7 @@ axis Both Entrambi
|
||||
# stays English for agents and tests. `{_1}`, `{_2}` … stand for the `{}` of the format string, in order
|
||||
# (`complete_languages_translate_every_message` lists any that are missing). Command ids, parameter
|
||||
# names and values in backticks or code (`colorModel rgb`, `document.export`) stay as they are.
|
||||
@msg no library `{id}` nessuna libreria `{id}`
|
||||
@msg select some type first seleziona prima del testo
|
||||
@msg {_1} has no {_2} style {_1} non ha lo stile {_2}
|
||||
@msg a layer couldn't be read non è stato possibile leggere un livello
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -2422,6 +2422,24 @@
|
||||
Swatch Options… スウォッチオプション…
|
||||
Swatch Tiles スウォッチタイル
|
||||
Switch workspace ワークスペースを切り替え
|
||||
Add Content コンテンツを追加
|
||||
Click to apply to the selected type クリックして選択したテキストに適用
|
||||
Create New Library 新規ライブラリを作成
|
||||
Create New Library… 新規ライブラリを作成…
|
||||
Rename Library… ライブラリ名を変更…
|
||||
Delete the library “{name}”? ライブラリ「{name}」を削除しますか?
|
||||
Drag art or swatches here, or use + to add the selection's colors and text styles. ここにアートやスウォッチをドラッグするか、+ で選択範囲のカラーやテキストスタイルを追加します。
|
||||
Its graphics, colors and text styles are deleted from this machine. グラフィック、カラー、テキストスタイルはこのコンピューターから削除されます。
|
||||
Keep graphics, colors and text styles here to use them in any document. Libraries are stored on this machine — no account required. グラフィック、カラー、テキストスタイルをここに保存すると、どのドキュメントでも使えます。ライブラリはこのコンピューターに保存され、アカウントは不要です。
|
||||
This library is empty このライブラリは空です
|
||||
Add to Library ライブラリに追加
|
||||
Delete Library ライブラリを削除
|
||||
Delete Library Item ライブラリ項目を削除
|
||||
Library ライブラリ
|
||||
Rename Library ライブラリ名を変更
|
||||
Show Library ライブラリを表示
|
||||
Use Library Item ライブラリ項目を使用
|
||||
Graphic グラフィック
|
||||
Show all open documents 開いているすべてのドキュメントを表示
|
||||
Symbol Libraries (on the roadmap) シンボルライブラリ (今後対応予定)
|
||||
Symbol Screener Tool シンボルスクリーナーツール
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -2644,6 +2644,24 @@
|
||||
Swatch Tiles Плитки образцов
|
||||
Swatches Образцы
|
||||
Switch workspace Переключить рабочую среду
|
||||
Add Content Добавить содержимое
|
||||
Click to apply to the selected type Щёлкните, чтобы применить к выделенному тексту
|
||||
Create New Library Создать библиотеку
|
||||
Create New Library… Создать библиотеку…
|
||||
Rename Library… Переименовать библиотеку…
|
||||
Delete the library “{name}”? Удалить библиотеку «{name}»?
|
||||
Drag art or swatches here, or use + to add the selection's colors and text styles. Перетащите сюда графику или образцы или нажмите +, чтобы добавить цвета и стили текста выделенного.
|
||||
Its graphics, colors and text styles are deleted from this machine. Её графика, цвета и стили текста будут удалены с этого компьютера.
|
||||
Keep graphics, colors and text styles here to use them in any document. Libraries are stored on this machine — no account required. Храните здесь графику, цвета и стили текста, чтобы использовать их в любом документе. Библиотеки хранятся на этом компьютере — учётная запись не нужна.
|
||||
This library is empty Эта библиотека пуста
|
||||
Add to Library Добавить в библиотеку
|
||||
Delete Library Удалить библиотеку
|
||||
Delete Library Item Удалить элемент библиотеки
|
||||
Library Библиотека
|
||||
Rename Library Переименовать библиотеку
|
||||
Show Library Показать библиотеку
|
||||
Use Library Item Использовать элемент библиотеки
|
||||
Graphic Графика
|
||||
Show all open documents Показать все открытые документы
|
||||
Symbol Instance Экземпляр символа
|
||||
Symbol Libraries Библиотеки символов
|
||||
@@ -3126,6 +3144,7 @@ axis Both Оба
|
||||
# stays English for agents and tests. `{_1}`, `{_2}` … stand for the `{}` of the format string, in order
|
||||
# (`complete_languages_translate_every_message` lists any that are missing). Command ids, parameter
|
||||
# names and values in backticks or code (`colorModel rgb`, `document.export`) stay as they are.
|
||||
@msg no library `{id}` нет библиотеки `{id}`
|
||||
@msg select some type first сначала выделите текст
|
||||
@msg {_1} has no {_2} style у {_1} нет начертания {_2}
|
||||
@msg a layer couldn't be read не удалось прочитать слой
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -2640,6 +2640,24 @@
|
||||
Swatch Tiles Плитки зразків
|
||||
Swatches Зразки
|
||||
Switch workspace Переключити робоче середовище
|
||||
Add Content Додати вміст
|
||||
Click to apply to the selected type Клацніть, щоб застосувати до виділеного тексту
|
||||
Create New Library Створити бібліотеку
|
||||
Create New Library… Створити бібліотеку…
|
||||
Rename Library… Перейменувати бібліотеку…
|
||||
Delete the library “{name}”? Видалити бібліотеку «{name}»?
|
||||
Drag art or swatches here, or use + to add the selection's colors and text styles. Перетягніть сюди графіку чи зразки або натисніть +, щоб додати кольори й стилі тексту виділеного.
|
||||
Its graphics, colors and text styles are deleted from this machine. Її графіку, кольори та стилі тексту буде видалено з цього комп’ютера.
|
||||
Keep graphics, colors and text styles here to use them in any document. Libraries are stored on this machine — no account required. Зберігайте тут графіку, кольори й стилі тексту, щоб використовувати їх у будь-якому документі. Бібліотеки зберігаються на цьому комп’ютері — обліковий запис не потрібен.
|
||||
This library is empty Ця бібліотека порожня
|
||||
Add to Library Додати до бібліотеки
|
||||
Delete Library Видалити бібліотеку
|
||||
Delete Library Item Видалити елемент бібліотеки
|
||||
Library Бібліотека
|
||||
Rename Library Перейменувати бібліотеку
|
||||
Show Library Показати бібліотеку
|
||||
Use Library Item Використати елемент бібліотеки
|
||||
Graphic Графіка
|
||||
Show all open documents Показати всі відкриті документи
|
||||
Symbol Instance Примірник символ
|
||||
Symbol Libraries Бібліотеки символів
|
||||
@@ -3113,6 +3131,7 @@ axis Both Обидві
|
||||
Drag to float the toolbar Перетягніть, щоб зробити панель інструментів плаваючою
|
||||
Drag to the window's left edge to dock Перетягніть до лівого краю вікна для прикріплення
|
||||
Float Tool Group Зробити групу інструментів плаваючою
|
||||
@msg no library `{id}` немає бібліотеки `{id}`
|
||||
@msg select some type first спочатку виділіть текст
|
||||
@msg {_1} has no {_2} style {_1} не має накреслення {_2}
|
||||
@msg a layer couldn't be read не вдалося прочитати шар
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 621 and column 40.
|
@@ -2565,6 +2565,24 @@
|
||||
Swatch Tiles 色板拼贴
|
||||
Swatches 色板
|
||||
Switch workspace 切换工作区
|
||||
Add Content 添加内容
|
||||
Click to apply to the selected type 单击以应用到所选文字
|
||||
Create New Library 创建新库
|
||||
Create New Library… 创建新库…
|
||||
Rename Library… 重命名库…
|
||||
Delete the library “{name}”? 要删除库“{name}”吗?
|
||||
Drag art or swatches here, or use + to add the selection's colors and text styles. 将图稿或色板拖到此处,或使用 + 添加所选对象的颜色和文字样式。
|
||||
Its graphics, colors and text styles are deleted from this machine. 其图形、颜色和文字样式将从此计算机中删除。
|
||||
Keep graphics, colors and text styles here to use them in any document. Libraries are stored on this machine — no account required. 在此保存图形、颜色和文字样式,即可在任何文档中使用。库存储在此计算机上,无需帐户。
|
||||
This library is empty 此库为空
|
||||
Add to Library 添加到库
|
||||
Delete Library 删除库
|
||||
Delete Library Item 删除库项目
|
||||
Library 库
|
||||
Rename Library 重命名库
|
||||
Show Library 显示库
|
||||
Use Library Item 使用库项目
|
||||
Graphic 图形
|
||||
Show all open documents 显示所有打开的文档
|
||||
Symbol Instance 符号实例
|
||||
Symbol Libraries 符号库
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -2651,6 +2651,24 @@
|
||||
Swatch Tiles 色票拼貼
|
||||
Swatches 色票
|
||||
Switch workspace 切換工作區
|
||||
Add Content 新增內容
|
||||
Click to apply to the selected type 按一下以套用到所選文字
|
||||
Create New Library 建立新資料庫
|
||||
Create New Library… 建立新資料庫…
|
||||
Rename Library… 重新命名資料庫…
|
||||
Delete the library “{name}”? 要刪除資料庫「{name}」嗎?
|
||||
Drag art or swatches here, or use + to add the selection's colors and text styles. 將圖稿或色票拖到此處,或使用 + 加入所選物件的顏色和文字樣式。
|
||||
Its graphics, colors and text styles are deleted from this machine. 其圖形、顏色和文字樣式將從此電腦中刪除。
|
||||
Keep graphics, colors and text styles here to use them in any document. Libraries are stored on this machine — no account required. 在此保存圖形、顏色和文字樣式,即可在任何文件中使用。資料庫儲存在此電腦上,無需帳號。
|
||||
This library is empty 此資料庫是空的
|
||||
Add to Library 加入資料庫
|
||||
Delete Library 刪除資料庫
|
||||
Delete Library Item 刪除資料庫項目
|
||||
Library 資料庫
|
||||
Rename Library 重新命名資料庫
|
||||
Show Library 顯示資料庫
|
||||
Use Library Item 使用資料庫項目
|
||||
Graphic 圖形
|
||||
Show all open documents 顯示所有開啟的文件
|
||||
Symbol Instance 符號範例
|
||||
Symbol Libraries 符號資料庫
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -351,7 +351,7 @@ fn zone_input(ui: &Ui, zone: &Response, d: &Document, ev: &mut Events) {
|
||||
PanelDrag::GraphicStyle(n) => (Some(n), None),
|
||||
PanelDrag::Art(ids) => (None, ids.first().copied()),
|
||||
PanelDrag::Appearance(id) => (None, Some(*id)),
|
||||
PanelDrag::Paint { .. } | PanelDrag::Symbol(_) | PanelDrag::Brush { .. } => (None, None),
|
||||
PanelDrag::Paint { .. } | PanelDrag::Symbol(_) | PanelDrag::Brush { .. } | PanelDrag::LibraryGraphic { .. } => (None, None),
|
||||
};
|
||||
if style.is_none() && source.is_none() {
|
||||
return;
|
||||
|
||||
@@ -0,0 +1,348 @@
|
||||
//! Libraries panel (Window › Libraries, #745): the current library's colours, character and
|
||||
//! paragraph styles and graphics, from the engine's `library.*` commands. Art dragged off the canvas
|
||||
//! onto the panel, and swatches dragged in, are added to it; the + button adds the selection's
|
||||
//! graphic, fill or stroke colour or text styles. A colour click paints the selection through the
|
||||
//! active Fill/Stroke proxy, a style click applies it to the selected type, and a graphic is
|
||||
//! dragged onto the canvas (or double-clicked) to place a copy. Libraries are kept on this machine.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use egui::{Rect, Sense, Stroke, StrokeKind, Ui, vec2};
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_color::Paint;
|
||||
use vectorcraft_engine::cmd::library::Library;
|
||||
|
||||
use crate::theme::Tokens;
|
||||
use crate::widgets::{self, PanelDrag, menu_item};
|
||||
use crate::{VectorcraftApp, icons};
|
||||
|
||||
/// The size of a graphic's tile.
|
||||
const TILE: f32 = 56.0;
|
||||
/// The size of a colour chip.
|
||||
const CHIP: f32 = 22.0;
|
||||
|
||||
/// The current library: its id and a copy of it (the panel acts on it after reading).
|
||||
fn current(app: &VectorcraftApp) -> Option<(String, Library)> {
|
||||
let libs = &app.session.libraries;
|
||||
let id = libs.current()?;
|
||||
Some((id.to_string(), libs.get(id)?.clone()))
|
||||
}
|
||||
|
||||
/// Run `cmd`, reporting a failure in the status bar.
|
||||
fn run(app: &mut VectorcraftApp, cmd: &str, p: Value) {
|
||||
if let Err(e) = app.run(cmd, p) {
|
||||
app.status(e);
|
||||
}
|
||||
}
|
||||
|
||||
/// The name dialog for a new library, or for renaming the current one.
|
||||
fn ask_name(app: &mut VectorcraftApp, rename: Option<&str>) {
|
||||
let (cmd, label, name) = match rename {
|
||||
Some(n) => ("library.rename", "Rename Library", n.to_string()),
|
||||
None => ("library.create", "Create New Library", "My Library".to_string()),
|
||||
};
|
||||
app.ui.dialog = Some(crate::state::Dialog::new("command", json!({ "__command": cmd, "__label": label, "name": name })));
|
||||
}
|
||||
|
||||
pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let Some((lib_id, lib)) = current(app) else {
|
||||
ui.add_space(16.0);
|
||||
ui.vertical_centered(|ui| {
|
||||
icons::icon(ui, "library", 40.0, t.text_dim);
|
||||
ui.add_space(8.0);
|
||||
ui.label(egui::RichText::new(tl!("Local Libraries")).size(14.0).color(t.text));
|
||||
widgets::dim_label(ui, tl!("Keep graphics, colors and text styles here to use them in any document. Libraries are stored on this machine — no account required."));
|
||||
ui.add_space(8.0);
|
||||
if ui.button(tl!("Create New Library")).clicked() {
|
||||
ask_name(app, None);
|
||||
}
|
||||
});
|
||||
return;
|
||||
};
|
||||
// The library picker.
|
||||
let libs: Vec<(String, String)> = app.session.libraries.all().iter().map(|(i, l)| (i.clone(), l.name.clone())).collect();
|
||||
let names: Vec<&str> = libs.iter().map(|(_, n)| n.as_str()).collect();
|
||||
if let Some(i) = widgets::dropdown_names(ui, "lib-picker", &lib.name, &names, ui.available_width())
|
||||
&& let Some((id, _)) = libs.get(i)
|
||||
{
|
||||
run(app, "library.setCurrent", json!({ "library": id }));
|
||||
}
|
||||
ui.add_space(4.0);
|
||||
let empty = lib.colors.is_empty() && lib.char_styles.is_empty() && lib.para_styles.is_empty() && lib.graphics.is_empty();
|
||||
let mut act: Option<(&'static str, Value)> = None;
|
||||
let fill_active = app.session.fill_active;
|
||||
let list_rect = widgets::list_box(ui, |ui| {
|
||||
ui.set_min_height(140.0);
|
||||
ui.set_width(ui.available_width());
|
||||
if empty {
|
||||
super::empty_state(
|
||||
ui,
|
||||
"library",
|
||||
tl!("This library is empty"),
|
||||
tl!("Drag art or swatches here, or use + to add the selection's colors and text styles."),
|
||||
);
|
||||
return ui.min_rect();
|
||||
}
|
||||
egui::Frame::NONE.inner_margin(egui::Margin::same(6)).show(ui, |ui| {
|
||||
if !lib.colors.is_empty() {
|
||||
heading(ui, tl!("Colors"));
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
ui.spacing_mut().item_spacing = vec2(4.0, 4.0);
|
||||
for c in &lib.colors {
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(CHIP, CHIP), Sense::click_and_drag());
|
||||
let paint = Paint::solid(c.color);
|
||||
widgets::swatch_tile(ui, r, &paint, false, false);
|
||||
ui.painter().rect_stroke(r, 0.0, Stroke::new(1.0, t.border), StrokeKind::Inside);
|
||||
widgets::drag_source(ui, &resp, || PanelDrag::Paint {
|
||||
paint: paint.clone(),
|
||||
params: json!({ "color": c.color }),
|
||||
rows: None,
|
||||
});
|
||||
let resp = resp.on_hover_text(&c.name);
|
||||
if resp.clicked() {
|
||||
act = Some((
|
||||
"library.use",
|
||||
json!({ "kind": "fillColor", "item": c.name, "to": if fill_active { "fill" } else { "stroke" } }),
|
||||
));
|
||||
}
|
||||
resp.context_menu(|ui| {
|
||||
if menu_item(ui, "Delete", true, false) {
|
||||
act = Some(("library.removeItem", json!({ "kind": "fillColor", "item": c.name })));
|
||||
ui.close();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
ui.add_space(6.0);
|
||||
}
|
||||
for (para, list, title) in [(false, &lib.char_styles, tl!("Character Styles")), (true, &lib.para_styles, tl!("Paragraph Styles"))] {
|
||||
if list.is_empty() {
|
||||
continue;
|
||||
}
|
||||
heading(ui, title);
|
||||
let kind = if para { "paraStyle" } else { "charStyle" };
|
||||
for st in list {
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 22.0), Sense::click());
|
||||
if resp.hovered() {
|
||||
ui.painter().rect_filled(r, 0.0, t.hover);
|
||||
}
|
||||
icons::paint(
|
||||
ui,
|
||||
if para { "pilcrow" } else { "type" },
|
||||
Rect::from_center_size(r.left_center() + vec2(10.0, 0.0), vec2(14.0, 14.0)),
|
||||
t.icon,
|
||||
);
|
||||
ui.painter().text(
|
||||
r.left_center() + vec2(24.0, 0.0),
|
||||
egui::Align2::LEFT_CENTER,
|
||||
&st.name,
|
||||
egui::FontId::proportional(12.5),
|
||||
t.text,
|
||||
);
|
||||
let resp = resp.on_hover_text(tl!("Click to apply to the selected type"));
|
||||
if resp.clicked() {
|
||||
act = Some(("library.use", json!({ "kind": kind, "item": st.name })));
|
||||
}
|
||||
resp.context_menu(|ui| {
|
||||
if menu_item(ui, "Delete", true, false) {
|
||||
act = Some(("library.removeItem", json!({ "kind": kind, "item": st.name })));
|
||||
ui.close();
|
||||
}
|
||||
});
|
||||
}
|
||||
ui.add_space(6.0);
|
||||
}
|
||||
if !lib.graphics.is_empty() {
|
||||
heading(ui, tl!("Graphics"));
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
ui.spacing_mut().item_spacing = vec2(4.0, 4.0);
|
||||
for g in &lib.graphics {
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(TILE, TILE), Sense::click_and_drag());
|
||||
tile(ui, r, &lib_id, g);
|
||||
ui.painter().rect_stroke(r, 0.0, Stroke::new(1.0, if resp.hovered() { t.accent } else { t.border }), StrokeKind::Inside);
|
||||
// Dragged onto the canvas, a copy is placed where it is dropped.
|
||||
widgets::drag_source(ui, &resp, || PanelDrag::LibraryGraphic { library: lib_id.clone(), item: g.id.clone() });
|
||||
let resp = resp.on_hover_text(&g.name);
|
||||
if resp.double_clicked() {
|
||||
act = Some(("library.use", json!({ "kind": "graphic", "item": g.id })));
|
||||
}
|
||||
resp.context_menu(|ui| {
|
||||
if menu_item(ui, "Place", true, false) {
|
||||
act = Some(("library.use", json!({ "kind": "graphic", "item": g.id })));
|
||||
ui.close();
|
||||
}
|
||||
if menu_item(ui, "Delete", true, false) {
|
||||
act = Some(("library.removeItem", json!({ "kind": "graphic", "item": g.id })));
|
||||
ui.close();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
ui.min_rect()
|
||||
});
|
||||
// Art dragged off the canvas, or a swatch, dropped on the list is added to the library.
|
||||
let zone = ui.interact(list_rect, ui.id().with("libraries-drop"), Sense::hover());
|
||||
if let Some(ids) = widgets::art_drop(ui, &zone) {
|
||||
act = Some(("library.add", json!({ "kind": "graphic", "ids": ids })));
|
||||
}
|
||||
if let Some(drag) = zone.dnd_hover_payload::<PanelDrag>()
|
||||
&& let PanelDrag::Paint { paint: Paint::Solid { color, .. }, .. } = &*drag
|
||||
{
|
||||
ui.painter().rect_stroke(zone.rect, 0.0, Stroke::new(1.5, t.accent), StrokeKind::Inside);
|
||||
let color = *color;
|
||||
if zone.dnd_release_payload::<PanelDrag>().is_some() {
|
||||
act = Some(("library.add", json!({ "kind": "fillColor", "color": color })));
|
||||
}
|
||||
}
|
||||
if let Some((cmd, mut p)) = act {
|
||||
p["library"] = json!(lib_id);
|
||||
if cmd == "library.use" && p["kind"] == "graphic" {
|
||||
// Placed at the centre of the view.
|
||||
if let Some(v) = app.view() {
|
||||
p["center"] = json!([v.center.x, v.center.y]);
|
||||
}
|
||||
}
|
||||
run(app, cmd, p);
|
||||
}
|
||||
let has_doc = app.session.active().is_some();
|
||||
widgets::bottom_bar(ui, |ui| {
|
||||
let add = widgets::icon_button_enabled(ui, "plus", tl!("Add Content"), false, has_doc, 24.0);
|
||||
egui::Popup::menu(&add).show(|ui| {
|
||||
for (label, kind) in [
|
||||
("Graphic", "graphic"),
|
||||
("Fill Color", "fillColor"),
|
||||
("Stroke Color", "strokeColor"),
|
||||
("Character Style", "charStyle"),
|
||||
("Paragraph Style", "paraStyle"),
|
||||
] {
|
||||
if menu_item(ui, label, true, false) {
|
||||
run(app, "library.add", json!({ "library": lib_id, "kind": kind }));
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// A section heading in the list.
|
||||
fn heading(ui: &mut Ui, text: &str) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
ui.label(egui::RichText::new(text).size(11.5).color(t.text_dim));
|
||||
ui.add_space(2.0);
|
||||
}
|
||||
|
||||
/// Graphic `g`'s thumbnail on white in `r`.
|
||||
fn tile(ui: &Ui, r: Rect, library: &str, g: &vectorcraft_engine::cmd::library::LibraryGraphic) {
|
||||
ui.painter().rect_filled(r, 0.0, egui::Color32::WHITE);
|
||||
if let Some(tex) = thumbnail(ui.ctx(), library, g) {
|
||||
let size = tex.size_vec2();
|
||||
let k = ((r.width() - 6.0) / size.x).min((r.height() - 6.0) / size.y).min(1.0);
|
||||
let fit = Rect::from_center_size(r.center(), size * k);
|
||||
ui.painter().image(tex.id(), fit, Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)), egui::Color32::WHITE);
|
||||
}
|
||||
}
|
||||
|
||||
/// The texture of graphic `g`'s PNG thumbnail, decoded once.
|
||||
fn thumbnail(ctx: &egui::Context, library: &str, g: &vectorcraft_engine::cmd::library::LibraryGraphic) -> Option<egui::TextureHandle> {
|
||||
thread_local! {
|
||||
static CACHE: crate::graphics::TexCache<HashMap<(String, String, usize), Option<egui::TextureHandle>>> = crate::graphics::TexCache::default();
|
||||
}
|
||||
let key = (library.to_string(), g.id.clone(), g.thumbnail.len());
|
||||
if let Some(t) = CACHE.with(|c| c.borrow().get(&key).cloned()) {
|
||||
return t;
|
||||
}
|
||||
let tex = vectorcraft_format::base64_decode(&g.thumbnail)
|
||||
.and_then(|png| image::load_from_memory_with_format(&png, image::ImageFormat::Png).ok())
|
||||
.map(|img| {
|
||||
let rgba = img.to_rgba8();
|
||||
let image = egui::ColorImage::from_rgba_unmultiplied([rgba.width() as usize, rgba.height() as usize], rgba.as_raw());
|
||||
ctx.load_texture(format!("library-{library}-{}", g.id), image, egui::TextureOptions::LINEAR)
|
||||
});
|
||||
CACHE.with(|c| {
|
||||
let mut c = c.borrow_mut();
|
||||
if c.len() > 512 {
|
||||
c.clear();
|
||||
}
|
||||
c.insert(key, tex.clone());
|
||||
});
|
||||
tex
|
||||
}
|
||||
|
||||
/// Graphic `item` of `library`'s thumbnail in `r` (the chip a dragged graphic shows at the pointer).
|
||||
pub(crate) fn chip(app: &VectorcraftApp, ui: &Ui, r: Rect, library: &str, item: &str) {
|
||||
if let Some(g) = app.session.libraries.get(library).and_then(|l| l.graphics.iter().find(|g| g.id == item)) {
|
||||
tile(ui, r, library, g);
|
||||
}
|
||||
}
|
||||
|
||||
/// The panel's ≡ menu: Create New Library, Rename Library…, Delete Library.
|
||||
pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
let cur = current(app);
|
||||
if menu_item(ui, "Create New Library…", true, false) {
|
||||
ask_name(app, None);
|
||||
ui.close();
|
||||
}
|
||||
if menu_item(ui, "Rename Library…", cur.is_some(), false)
|
||||
&& let Some((_, lib)) = &cur
|
||||
{
|
||||
ask_name(app, Some(&lib.name));
|
||||
ui.close();
|
||||
}
|
||||
if menu_item(ui, "Delete Library", cur.is_some(), false)
|
||||
&& let Some((id, lib)) = &cur
|
||||
{
|
||||
let message = crate::i18n::fmt(tl!("Delete the library “{name}”?"), &[("name", &lib.name)]);
|
||||
crate::dialogs::confirm::ask(
|
||||
app,
|
||||
&message,
|
||||
tl!("Its graphics, colors and text styles are deleted from this machine."),
|
||||
"library.delete",
|
||||
json!({ "library": id }),
|
||||
);
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use serde_json::json;
|
||||
use vectorcraft_engine::Session;
|
||||
|
||||
use super::*;
|
||||
|
||||
fn frame(app: &mut VectorcraftApp) {
|
||||
let ctx = egui::Context::default();
|
||||
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| {
|
||||
show(app, ui);
|
||||
menu(app, ui);
|
||||
});
|
||||
out.textures_delta.clear();
|
||||
}
|
||||
|
||||
/// The panel draws with no library, an empty one and one holding every kind of item (#745).
|
||||
#[test]
|
||||
fn the_panel_draws_its_libraries() {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.run("file.new", json!({"width": 200, "height": 200})).unwrap();
|
||||
frame(&mut app);
|
||||
app.run("library.create", json!({"name": "Brand"})).unwrap();
|
||||
frame(&mut app);
|
||||
let id = app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 30, "height": 20})).unwrap()["id"].clone();
|
||||
app.run("select.set", json!({"ids": [id]})).unwrap();
|
||||
app.run("library.add", json!({"kind": "graphic"})).unwrap();
|
||||
app.run("library.add", json!({"kind": "fillColor"})).unwrap();
|
||||
let t = app.run("text.create", json!({"x": 10, "y": 80, "text": "Type"})).unwrap()["id"].clone();
|
||||
app.run("select.set", json!({"ids": [t]})).unwrap();
|
||||
app.run("library.add", json!({"kind": "charStyle"})).unwrap();
|
||||
app.run("library.add", json!({"kind": "paraStyle"})).unwrap();
|
||||
frame(&mut app);
|
||||
let lib = app.session.libraries.get("Brand").unwrap();
|
||||
assert_eq!((lib.graphics.len(), lib.colors.len(), lib.char_styles.len(), lib.para_styles.len()), (1, 1, 1, 1));
|
||||
// The thumbnail decodes.
|
||||
let ctx = egui::Context::default();
|
||||
assert!(thumbnail(&ctx, "Brand", &lib.graphics[0]).is_some());
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,7 @@ pub mod history;
|
||||
pub mod image_trace;
|
||||
pub mod info;
|
||||
pub mod layers;
|
||||
pub mod libraries;
|
||||
pub mod library_panel;
|
||||
pub mod links;
|
||||
pub mod magic_wand;
|
||||
@@ -169,6 +170,7 @@ pub fn panel_menu_items(app: &mut VectorcraftApp, ui: &mut Ui, id: &str) -> bool
|
||||
"paraStyles" => text_styles::menu(app, ui, text_styles::Kind::Para),
|
||||
"magicWand" => magic_wand::menu(app, ui),
|
||||
"tabs" => tabs::menu(app, ui),
|
||||
"libraries" => libraries::menu(app, ui),
|
||||
flattener_preview::ID => flattener_preview::menu(app, ui),
|
||||
"attributes" => attributes::menu(app, ui),
|
||||
"colorThemes" => color_themes::menu(app, ui),
|
||||
@@ -208,18 +210,8 @@ pub fn panel_menu(app: &mut VectorcraftApp, ui: &mut Ui, id: &str, rect: Rect) {
|
||||
});
|
||||
}
|
||||
|
||||
pub fn libraries(_app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
ui.add_space(20.0);
|
||||
ui.vertical_centered(|ui| {
|
||||
icons::icon(ui, "library", 40.0, t.text_dim);
|
||||
ui.add_space(8.0);
|
||||
ui.label(egui::RichText::new(tl!("Local Libraries")).size(14.0).color(t.text));
|
||||
dim_label(
|
||||
ui,
|
||||
"Drag art, colors and text styles here to reuse them across documents. Libraries are stored on this machine — no account required.",
|
||||
);
|
||||
});
|
||||
pub fn libraries(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
libraries::show(app, ui);
|
||||
}
|
||||
|
||||
// ---------- shared helpers ----------
|
||||
|
||||
@@ -581,6 +581,7 @@ pub(crate) fn drag_preview(app: &VectorcraftApp, ctx: &egui::Context) {
|
||||
None => return,
|
||||
},
|
||||
PanelDrag::Brush { def, .. } => return pointer_chip(ctx, |ui, r| super::brushes::chip(ui, r, def)),
|
||||
PanelDrag::LibraryGraphic { library, item } => return pointer_chip(ctx, |ui, r| super::libraries::chip(app, ui, r, library, item)),
|
||||
};
|
||||
pointer_chip(ctx, |ui, r| {
|
||||
if registration {
|
||||
|
||||
@@ -1463,6 +1463,9 @@ pub enum PanelDrag {
|
||||
/// A Brushes panel brush (`def`: its definition, for the chip at the pointer): the path it is
|
||||
/// dropped on takes it (`brush.apply`).
|
||||
Brush { name: String, def: serde_json::Value },
|
||||
/// A Libraries panel graphic (`item`: its id in `library`): the canvas places a copy centred
|
||||
/// where it is dropped (`library.use`).
|
||||
LibraryGraphic { library: String, item: String },
|
||||
}
|
||||
|
||||
/// A panel list `zone` that takes art dragged off the canvas: outlined while art is held over it;
|
||||
|
||||
+25
@@ -809,6 +809,31 @@ with); without `path` it returns `{data}`, and `user: true` saves into the user
|
||||
(category `user`, User Defined). `graphicStyle.loadLibrary {path? | data? | dataBase64?, name?}` loads a `.vcstyles`
|
||||
file, or another document's graphic styles, as a library to add from.
|
||||
|
||||
## Libraries
|
||||
|
||||
The Libraries panel's libraries hold graphics, colours and character and paragraph styles that any document can use.
|
||||
They are kept on this machine: each is a `.vclibrary` file (JSON) in the `Libraries` folder next to the preferences
|
||||
(none in the web app and headless sessions, where they last for the session). `library.list` lists them (`id`, `name`,
|
||||
the count of each kind, `current`, `folder`); `library.get {library?}` returns one's items (graphics with `id`, `name`,
|
||||
size and a PNG `thumbnail` in base64; colours with `name`, `hex` and `color`; styles with `name` and `attrs`).
|
||||
`library.create {name?}`, `library.rename {library?, name}`, `library.delete {library?}` and
|
||||
`library.setCurrent {library}` manage them; `library` is an id or a name, and without it the current library (the one
|
||||
the panel shows) is meant.
|
||||
|
||||
`library.add {library?, kind, ids?, name?, color?}` adds from the selection: `graphic` (a copy of the objects with the
|
||||
images, symbols, patterns, swatches, styles and brushes they use, as Copy takes them), `fillColor` / `strokeColor` (the
|
||||
first selected object's solid colour, else the default one, or `color`), `charStyle` / `paraStyle` (the selected
|
||||
text's attributes). An item the library already has is reported as `existing`. `library.use {library?, kind, item,
|
||||
center?, to?}` uses one: a graphic is placed centred on `center` (pasted with its resources; a swatch name the document
|
||||
gives another colour merges into the document's), a colour paints the selection's fill or stroke, and a style is added
|
||||
to the document (numbered when its name is taken by other attributes) and applied to the selected text, each as one
|
||||
undo step. `library.removeItem {library?, kind, item}` removes one.
|
||||
|
||||
```json
|
||||
{"name":"run_command","arguments":{"command":"library.add","params":{"kind":"graphic","name":"Logo"}}}
|
||||
{"name":"run_command","arguments":{"command":"library.use","params":{"kind":"graphic","item":"Logo","center":[300,200]}}}
|
||||
```
|
||||
|
||||
## Strokes on type
|
||||
|
||||
`stroke.set` without an `item` gives type its characters' stroke: weight, cap, join, miter limit and the dash
|
||||
|
||||
Reference in new issue
Block a user