Fix #745: a working Libraries panel of graphics, colours and text styles

The Libraries panel was a placeholder: it invited dragging art onto it,
but nothing happened. It now keeps local libraries (no account, no
cloud), as Illustrator's Libraries panel does:

- drag art off the canvas (or a swatch) onto the panel to add it, or use
  + to add the selection's graphic, fill or stroke colour, character
  style or paragraph style;
- drag a graphic onto the canvas (or double-click it) to place a copy,
  click a colour to paint the selection through the active Fill/Stroke
  proxy, click a style to apply it to the selected type;
- the library picker and the panel menu create, rename and delete
  libraries; each is a .vclibrary file in a Libraries folder next to the
  preferences (session-only in the web app, headless sessions and runs
  without preferences).

Engine: library.list/get/setCurrent/create/rename/delete/add/use/
removeItem. A graphic is stored as a native document with a thumbnail:
adding copies as Copy does and placing pastes as Paste does, resources
and swatch-name conflicts included. Using a style adds it to the
document and applies it in one undo step.

Clipboard::to_document gave its layer the id u64::MAX, which made a
saved copy overflow Document::fix_next_id when read back; the layer now
takes an id above the objects', and fix_next_id saturates.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
bflatasticandClaude Opus 5.5 committed 2026-10-09 20:12:59 +07:00
1 parent c2711df30b
commit 7255ac394d
27 files changed
+1451 -19

No files matched your search

+1 -1
View File
@@ -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 |
+6
View File
@@ -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() {
+2 -1
View File
@@ -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.
+8 -1
View File
@@ -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
}
}
+12 -1
View File
@@ -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))),
}],
+1
View File
@@ -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;
+1 -1
View File
@@ -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() {
+699
View File
@@ -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()
}
+2
View File
@@ -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());
+11
View File
@@ -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`"))
}
+5
View File
@@ -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;
+163
View File
@@ -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);
}
+9 -1
View File
@@ -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);
+19
View File
@@ -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.
+19
View File
@@ -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.
+19
View File
@@ -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.
+18
View File
@@ -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.
+19
View File
@@ -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.
+19
View File
@@ -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.
+18
View File
@@ -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.
+18
View File
@@ -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.
+1 -1
View File
@@ -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;
+348
View File
@@ -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());
}
}
+4 -12
View File
@@ -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 ----------
+1
View File
@@ -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 {
+3
View File
@@ -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
View File
@@ -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