mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-14 19:26:56 -04:00
Fix #926: the Libraries panel's search, groups and library export/import
Search: a search field at the top of the Libraries panel filters the
current library's colours, character and paragraph styles and graphics
by name, ignoring case; groups without a match are hidden, groups with
one open, and a search nothing matches says so ("No results").
Groups: items can be put in user-named groups, kept in the library file
(`groups: [{name, items: [{kind, item}]}]`, left out when there are
none, so files written before groups load as they are). New commands:
library.createGroup {library?, name?, items?}, library.renameGroup
{library?, group, name}, library.deleteGroup {library?, group} (its
items stay, ungrouped) and library.moveItem {library?, kind, item,
group?}. An item is in one group at most; deleting an item takes it out
of its group. The panel shows each group as a collapsible section with
its item count after the ungrouped items (rename and delete on its
context menu), each item's context menu has Move to Group (No Group,
the groups, New Group...), and a New Group button sits in the footer.
library.get lists the groups, library.list counts them. The generic
command dialog takes fixed params in `__params` for this.
Export / import: library.export {library?, path?} writes a library
(colours, styles, graphics with art and thumbnails, groups) as a
.vclibrary file, the JSON the library folder keeps (without a path:
{data}); library.import {path | data | dataBase64, name?} adds one as a
new library, made current, numbered on a name clash ("Brand (2)").
The panel menu has Import Library... and Export Library..., through the
app's file dialogs (the web downloads, and picks through File > Open,
which now lists Libraries files). Imported and folder files are made
safe: at most 10,000 items of a kind and 1,000 groups, names clipped and
made unique, sizes finite, thumbnails kept only when their PNG header
says at most 1024 px a side, group entries naming missing items or
unknown kinds dropped; a file over 512 MB or that isn't a library is an
error. Paths go through the engine's file helpers (automation roots).
The importer is fuzzed (import_fuzz: garbage and mutated libraries).
Also fixed: the third graphic added to a library got the second's id
(`graphic-2` twice).
Docs: the commands' params docs, docs/mcp.md, ROADMAP.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
c738400ebb
commit
d62ecfe33a
19 files changed
+1242
-160
No files matched your search
+2
-2
@@ -4,7 +4,7 @@ VectorCraft is a clean-room, open-source, pure-Rust reimplementation of the Adob
|
||||
|
||||
This file tracks **how far we are and what's left**. Time estimates are wall-clock hours of continuous Claude Opus 5.5 agent work (including builds and the CI gate), given both for **one agent** and for **4–6 parallel agents** on disjoint crates. They are counted from the remaining work (see [Parity estimate](#parity-estimate)), calibrated against measured throughput, and updated as work lands.
|
||||
|
||||
_Last updated: 2026-10-10 (after placed documents (File › Place of a `.vectorcraft` file, linked), M4.14–M4.98, M8.1–M8.20, M14.4–M14.7, the MCP prompts, resource templates, completions and logging, the 2026-10-07 issue fixes, the Layers panel rework and the 2026-10-08 community contributions: Pen and shape-tool modifiers, path editing, Layers, Artboards, Swatches/Brushes and dialogs, Japanese composition and PDF text, Hebrew/Arabic type, interface languages (Spanish among them), the font menu, saved selections, MCP and EPS, the missing-font export warning (#319), open paths in the Shape Builder (#374), PDF/`.ai` import keeping strokes live and hidden and nested layers (#372), per-corner Live Corners (#381), SVG type opening at the size it draws at (#396), the Wayland clipboard with copied-file paste (#398), ruler guides that can be selected, moved and deleted (#414), two batches of Preferences options that were stored but unused (#394), skeleton reads with `document.find` for large documents over MCP (#416) and anchors dragged with Direct Selection snapping to points, segments and smart guides, clipped SVG layers opening as clipping layers (#420), PDF/`.ai` layers read on pages of tens of millions of operators (#419), Document Color Mode putting RGB greys on the black plate on request and converting pattern tiles (#421) and movable dialogs with Offset Path's Joins dropdown (#423) and numeric fields scrubbed by dragging their label (#400), stroke weight presets in the stroke unit (#426), the Appearance panel's Opacity popup (#430), type on a path brackets, flip and Type on a Path Options that keep the type in place (#429), Layers rows dragged with a ghost and dropped on the trash or New Layer (#435), tool flyouts opening on a right or corner press (#436), tool flyouts torn off into floating toolbars (#437), the Shape Builder's erase mode deleting edges too (#439), installed fonts registered outside the font folders and fonts installed while the app runs listed in the font menus (#443), alike circular corners on rectangles that aren't square (#442), tool cursors as OS cursors so the pointer no longer trails over the canvas (#444), artboards copied and pasted with their art (#446), guides dragged out of the rulers snapping to the art's side midpoints and the artboards, artboard guides (#451), the toolbar's double arrow and Window › Toolbars › Double Column switching between one and two columns in any window height (#455), the Smart Guides display options and Snapping Tolerance (#394), Font Size and Leading combo boxes with preset lists in the Character and Properties panels and the Control bar (#458), screenshots pasted from the clipboard on macOS, Windows and the web (#457), the Properties panel's Fill and Stroke matching the Control bar's, Illustrator's Window menu panel keys (Shift+F7 for Align…) and Alt-wheel zoom whatever the wheel preference (Discord feedback), the point/area type widget (Discord feedback), groups in legacy Illustrator .ai files (Discord feedback), a rotating desktop log file (#465), image controls in the Control bar and Properties panel: Image Trace with its presets, Mask, Crop Image, Embed/Unembed, and an Image Trace object's preset, Expand and Release (Discord feedback), the Pen working as the Anchor Point tool with Alt held over a selected path's handles and anchors, and anchor buttons that convert, connect and cut paths at the selected anchors (Discord feedback), Japanese hanging punctuation (burasagari, #463), Escape leaving Presentation Mode with no screen mode saved across restarts, documents dropped on the desktop app opening as tabs of their own and a note that an `.ai` file's art off its artboards isn't in the part read (#472), Add/Delete Anchor Point tool icons drawn from the Pen tool's (#479), EPS import running more of PostScript Level 3 (patterns, Type 3 fonts, mesh shadings, masked images, VM and resource probes), and a preview that stands in saying why (Discord feedback, #466, #469, #474), chords with modifiers recorded in Edit › Keyboard Shortcuts (#487), Type Object Selection by Path Only (#394), Anti-aliased Artwork (#394), Show Pixel Grid (#394), pen and touch taps pressed and lifted within one frame clicking where they tapped and the Linux/Wayland pen-tablet workaround (#491), bounding-box resizes landing on the other objects' anchors and centres with labels, and the Rotate, Scale, Shear and Reflect tools snapping their drags (#482), Shift-drag marquees that toggle the objects or anchors they reach and a Lasso whose Alt subtraction keeps the rest of the selection (#483), the mouse wheel stepping the focused numeric field, the dialogs' and preferences' drag values made such fields (#485), Show The Home Screen When No Documents Are Open (#394), Numbers Without Units Are Points (#394), Auto-Collapse Iconic Panels (#394), the Contextual Task Bar moved by its handle and its More Options menu pinning and resetting its position, a designed macOS DMG windoLine truncated
|
||||
_Last updated: 2026-10-10 (after placed documents (File › Place of a `.vectorcraft` file, linked), M4.14–M4.98, M8.1–M8.20, M14.4–M14.7, the MCP prompts, resource templates, completions and logging, the 2026-10-07 issue fixes, the Layers panel rework and the 2026-10-08 community contributions: Pen and shape-tool modifiers, path editing, Layers, Artboards, Swatches/Brushes and dialogs, Japanese composition and PDF text, Hebrew/Arabic type, interface languages (Spanish among them), the font menu, saved selections, MCP and EPS, the missing-font export warning (#319), open paths in the Shape Builder (#374), PDF/`.ai` import keeping strokes live and hidden and nested layers (#372), per-corner Live Corners (#381), SVG type opening at the size it draws at (#396), the Wayland clipboard with copied-file paste (#398), ruler guides that can be selected, moved and deleted (#414), two batches of Preferences options that were stored but unused (#394), skeleton reads with `document.find` for large documents over MCP (#416) and anchors dragged with Direct Selection snapping to points, segments and smart guides, clipped SVG layers opening as clipping layers (#420), PDF/`.ai` layers read on pages of tens of millions of operators (#419), Document Color Mode putting RGB greys on the black plate on request and converting pattern tiles (#421) and movable dialogs with Offset Path's Joins dropdown (#423) and numeric fields scrubbed by dragging their label (#400), stroke weight presets in the stroke unit (#426), the Appearance panel's Opacity popup (#430), type on a path brackets, flip and Type on a Path Options that keep the type in place (#429), Layers rows dragged with a ghost and dropped on the trash or New Layer (#435), tool flyouts opening on a right or corner press (#436), tool flyouts torn off into floating toolbars (#437), the Shape Builder's erase mode deleting edges too (#439), installed fonts registered outside the font folders and fonts installed while the app runs listed in the font menus (#443), alike circular corners on rectangles that aren't square (#442), tool cursors as OS cursors so the pointer no longer trails over the canvas (#444), artboards copied and pasted with their art (#446), guides dragged out of the rulers snapping to the art's side midpoints and the artboards, artboard guides (#451), the toolbar's double arrow and Window › Toolbars › Double Column switching between one and two columns in any window height (#455), the Smart Guides display options and Snapping Tolerance (#394), Font Size and Leading combo boxes with preset lists in the Character and Properties panels and the Control bar (#458), screenshots pasted from the clipboard on macOS, Windows and the web (#457), the Properties panel's Fill and Stroke matching the Control bar's, Illustrator's Window menu panel keys (Shift+F7 for Align…) and Alt-wheel zoom whatever the wheel preference (Discord feedback), the point/area type widget (Discord feedback), groups in legacy Illustrator .ai files (Discord feedback), a rotating desktop log file (#465), image controls in the Control bar and Properties panel: Image Trace with its presets, Mask, Crop Image, Embed/Unembed, and an Image Trace object's preset, Expand and Release (Discord feedback), the Pen working as the Anchor Point tool with Alt held over a selected path's handles and anchors, and anchor buttons that convert, connect and cut paths at the selected anchors (Discord feedback), Japanese hanging punctuation (burasagari, #463), Escape leaving Presentation Mode with no screen mode saved across restarts, documents dropped on the desktop app opening as tabs of their own and a note that an `.ai` file's art off its artboards isn't in the part read (#472), Add/Delete Anchor Point tool icons drawn from the Pen tool's (#479), EPS import running more of PostScript Level 3 (patterns, Type 3 fonts, mesh shadings, masked images, VM and resource probes), and a preview that stands in saying why (Discord feedback, #466, #469, #474), chords with modifiers recorded in Edit › Keyboard Shortcuts (#487), Type Object Selection by Path Only (#394), Anti-aliased Artwork (#394), Show Pixel Grid (#394), pen and touch taps pressed and lifted within one frame clicking where they tapped and the Linux/Wayland pen-tablet workaround (#491), bounding-box resizes landing on the other objects' anchors and centres with labels, and the Rotate, Scale, Shear and Reflect tools snapping their drags (#482), Shift-drag marquees that toggle the objects or anchors they reach and a Lasso whose Alt subtraction keeps the rest of the selection (#483), the mouse wheel stepping the focused numeric field, the dialogs' and preferences' drag values made such fields (#485), Show The Home Screen When No Documents Are Open (#394), Numbers Without Units Are Points (#394), Auto-Collapse Iconic Panels (#394), the Contextual Task Bar moved by its handle and its More Options menu pinning and resetting its position, a designed macOS DMG windoLine truncated
|
||||
|
||||
## Where we are
|
||||
|
||||
@@ -237,7 +237,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 17 Preferences options stored but not yet acted on (#394: Highlight Alternate Glyphs, Open Documents As Tabs, Real-time Drawing, hyphenation language, Appearance of Black, devices…) | 14–22 |
|
||||
| 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 |
|
||||
| Libraries, Links, Package | 2 | 82% | 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 (#745) and its search, groups and library export/import as `.vclibrary` files (#926) are done; 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 |
|
||||
|
||||
@@ -652,11 +652,13 @@ fn format_filters() -> impl Iterator<Item = (&'static str, &'static [&'static st
|
||||
}
|
||||
|
||||
/// Open-dialog filters: "All readable files" first, then one per readable format, then swatch
|
||||
/// libraries (which open in the library panel), flattener, PDF and print presets (imported).
|
||||
/// libraries (which open in the library panel), Libraries panel libraries, flattener, PDF and print
|
||||
/// presets (imported).
|
||||
pub fn open_filters() -> impl Iterator<Item = (&'static str, &'static [&'static str])> {
|
||||
std::iter::once(("All readable files", OPEN_EXTS))
|
||||
.chain(format_filters())
|
||||
.chain(std::iter::once(("Swatch libraries", super::swatchlib::LIBRARY_EXTS)))
|
||||
.chain(std::iter::once(("Libraries", super::library::LIBRARY_EXTS)))
|
||||
.chain(std::iter::once(("Flattener presets", super::flatten::PRESET_EXTS)))
|
||||
.chain(std::iter::once(("PDF presets", super::pdfcmds::PRESET_EXTS)))
|
||||
.chain(std::iter::once(("Print presets", super::printpresets::PRESET_EXTS)))
|
||||
|
||||
@@ -157,7 +157,11 @@ fn open_exts_cover_every_readable_format() {
|
||||
}
|
||||
let filters: Vec<_> = open_filters().collect();
|
||||
assert_eq!(filters[0], ("All readable files", OPEN_EXTS));
|
||||
assert_eq!(filters.len(), 6 + FORMATS.iter().filter(|f| f.read).count(), "and swatch libraries, flattener, PDF and print presets, plug-ins");
|
||||
assert_eq!(
|
||||
filters.len(),
|
||||
7 + FORMATS.iter().filter(|f| f.read).count(),
|
||||
"and swatch libraries, libraries, flattener, PDF and print presets, plug-ins"
|
||||
);
|
||||
assert_eq!(filters.last(), Some(&("Plug-ins", crate::cmd::plugin::EXTS)), "File › Open installs plug-ins");
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,10 @@
|
||||
//! 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).
|
||||
//! merged into the document's). Items can be put in user-named groups, and a library can be
|
||||
//! exported to a `.vclibrary` file and imported from one (#926).
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Map, Value, json};
|
||||
@@ -16,10 +19,20 @@ use crate::Clipboard;
|
||||
|
||||
/// The extension of a library file.
|
||||
pub const LIBRARY_EXT: &str = "vclibrary";
|
||||
/// The extensions of library files (open dialogs).
|
||||
pub const LIBRARY_EXTS: &[&str] = &[LIBRARY_EXT];
|
||||
/// 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;
|
||||
/// Groups a library may hold.
|
||||
const MAX_GROUPS: usize = 1_000;
|
||||
/// Characters of a library, group or item name kept from a file.
|
||||
const MAX_NAME: usize = 256;
|
||||
/// Base64 bytes a graphic's thumbnail may have (a 256-pixel PNG is far smaller).
|
||||
const MAX_THUMBNAIL: usize = 4 << 20;
|
||||
/// Pixels a side a thumbnail may claim (they are made at most 256).
|
||||
const MAX_THUMBNAIL_SIDE: u32 = 1024;
|
||||
|
||||
/// A library: its name and its items, newest last.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
@@ -34,6 +47,38 @@ pub struct Library {
|
||||
pub para_styles: Vec<TextStyleDef>,
|
||||
#[serde(default)]
|
||||
pub graphics: Vec<LibraryGraphic>,
|
||||
/// User-named groups of its items, in the order they were made (#926); an item is in one
|
||||
/// group at most, the others are ungrouped. Files written before groups have none.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub groups: Vec<LibraryGroup>,
|
||||
}
|
||||
|
||||
/// A user-named group of a library's items.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub struct LibraryGroup {
|
||||
pub name: String,
|
||||
#[serde(default)]
|
||||
pub items: Vec<ItemRef>,
|
||||
}
|
||||
|
||||
/// A library item: its kind and its key (its name; a graphic's id).
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub struct ItemRef {
|
||||
pub kind: ItemKind,
|
||||
pub item: String,
|
||||
}
|
||||
|
||||
/// What a library item is, as groups name it.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum ItemKind {
|
||||
Color,
|
||||
CharStyle,
|
||||
ParaStyle,
|
||||
Graphic,
|
||||
/// A kind this version doesn't know (a newer one wrote it): dropped when the file is read.
|
||||
#[serde(other)]
|
||||
Unknown,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
@@ -87,7 +132,7 @@ impl Libraries {
|
||||
})
|
||||
.and_then(|b| serde_json::from_slice::<Library>(&b).ok());
|
||||
if let Some(mut lib) = lib {
|
||||
lib.cap();
|
||||
lib.sanitize();
|
||||
self.libs.push((id, lib));
|
||||
}
|
||||
}
|
||||
@@ -120,12 +165,15 @@ impl Libraries {
|
||||
|
||||
/// 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 };
|
||||
self.insert(Library { name: name.to_string(), ..Default::default() })
|
||||
}
|
||||
|
||||
/// `lib` as a new library, its file named after it, made the current one → its id.
|
||||
fn insert(&mut self, lib: Library) -> Result<String> {
|
||||
let base = file_stem_for(&lib.name);
|
||||
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() }));
|
||||
let id = std::iter::once(base.clone()).chain((2..).map(|n| format!("{base} {n}"))).find(|id| !taken(id)).unwrap_or_default();
|
||||
self.libs.push((id.clone(), lib));
|
||||
if let Err(e) = self.save(&id) {
|
||||
self.libs.retain(|(i, _)| i != &id);
|
||||
return Err(e);
|
||||
@@ -194,15 +242,123 @@ impl Libraries {
|
||||
}
|
||||
|
||||
impl Library {
|
||||
/// At most [`MAX_ITEMS`] of each kind (a hand-edited file may hold more).
|
||||
fn cap(&mut self) {
|
||||
/// Every item as (kind, key: its name, a graphic's id, name shown): colours, then character
|
||||
/// and paragraph styles, then graphics.
|
||||
pub fn items(&self) -> impl Iterator<Item = (ItemKind, &str, &str)> {
|
||||
fn styles(kind: ItemKind, list: &[TextStyleDef]) -> impl Iterator<Item = (ItemKind, &str, &str)> {
|
||||
list.iter().map(move |t| (kind, t.name.as_str(), t.name.as_str()))
|
||||
}
|
||||
self.colors
|
||||
.iter()
|
||||
.map(|c| (ItemKind::Color, c.name.as_str(), c.name.as_str()))
|
||||
.chain(styles(ItemKind::CharStyle, &self.char_styles))
|
||||
.chain(styles(ItemKind::ParaStyle, &self.para_styles))
|
||||
.chain(self.graphics.iter().map(|g| (ItemKind::Graphic, g.id.as_str(), g.name.as_str())))
|
||||
}
|
||||
|
||||
/// The group of each grouped item: (kind, key) → its index in [`Library::groups`].
|
||||
pub fn group_index(&self) -> HashMap<(ItemKind, &str), usize> {
|
||||
self.groups.iter().enumerate().flat_map(|(i, g)| g.items.iter().map(move |r| ((r.kind, r.item.as_str()), i))).collect()
|
||||
}
|
||||
|
||||
/// Group `name` (else the one of that name ignoring case): its index.
|
||||
fn group_position(&self, name: &str) -> Option<usize> {
|
||||
let lower = name.to_lowercase();
|
||||
self.groups.iter().position(|g| g.name == name).or_else(|| self.groups.iter().position(|g| g.name.to_lowercase() == lower))
|
||||
}
|
||||
|
||||
/// Item `item` of `kind` (a graphic by id, else by name) as a group names it.
|
||||
fn item_ref(&self, kind: ItemKind, item: &str) -> Option<ItemRef> {
|
||||
let found = self.items().find(|(k, key, _)| *k == kind && *key == item);
|
||||
let found = found.or_else(|| self.items().find(|(k, _, name)| *k == kind && kind == ItemKind::Graphic && *name == item));
|
||||
found.map(|(kind, key, _)| ItemRef { kind, item: key.to_string() })
|
||||
}
|
||||
|
||||
/// Take `r` out of its group.
|
||||
fn ungroup(&mut self, r: &ItemRef) {
|
||||
for g in &mut self.groups {
|
||||
g.items.retain(|x| x != r);
|
||||
}
|
||||
}
|
||||
|
||||
/// Make a library read from a file (hand-edited, imported) safe and consistent: at most
|
||||
/// [`MAX_ITEMS`] items of each kind and [`MAX_GROUPS`] groups, names clipped to [`MAX_NAME`]
|
||||
/// characters and unique within their kind (a repeated one gets a number), sizes finite, a
|
||||
/// thumbnail only when it is a small PNG, and groups holding only items there are, each in one
|
||||
/// group at most.
|
||||
fn sanitize(&mut self) {
|
||||
clip(&mut self.name);
|
||||
self.colors.truncate(MAX_ITEMS);
|
||||
self.char_styles.truncate(MAX_ITEMS);
|
||||
self.para_styles.truncate(MAX_ITEMS);
|
||||
self.graphics.truncate(MAX_ITEMS);
|
||||
self.groups.truncate(MAX_GROUPS);
|
||||
uniquify(self.colors.iter_mut().map(|c| &mut c.name), "Color", " ", false);
|
||||
uniquify(self.char_styles.iter_mut().map(|t| &mut t.name), "Character Style", " ", false);
|
||||
uniquify(self.para_styles.iter_mut().map(|t| &mut t.name), "Paragraph Style", " ", false);
|
||||
uniquify(self.graphics.iter_mut().map(|g| &mut g.name), "Graphic", " ", false);
|
||||
uniquify(self.graphics.iter_mut().map(|g| &mut g.id), "graphic", "-", false);
|
||||
uniquify(self.groups.iter_mut().map(|g| &mut g.name), "Group", " ", true);
|
||||
for g in &mut self.graphics {
|
||||
for v in [&mut g.width, &mut g.height] {
|
||||
if !(v.is_finite() && *v >= 0.0) {
|
||||
*v = 0.0;
|
||||
}
|
||||
}
|
||||
if !thumbnail_ok(&g.thumbnail) {
|
||||
g.thumbnail.clear();
|
||||
}
|
||||
}
|
||||
let mut groups = std::mem::take(&mut self.groups);
|
||||
let present: HashSet<(ItemKind, &str)> = self.items().map(|(k, key, _)| (k, key)).collect();
|
||||
let mut seen = HashSet::new();
|
||||
for g in &mut groups {
|
||||
g.items.retain(|r| present.contains(&(r.kind, r.item.as_str())) && seen.insert(r.clone()));
|
||||
}
|
||||
self.groups = groups;
|
||||
}
|
||||
}
|
||||
|
||||
/// `name` cut to [`MAX_NAME`] characters.
|
||||
fn clip(name: &mut String) {
|
||||
if let Some((at, _)) = name.char_indices().nth(MAX_NAME) {
|
||||
name.truncate(at);
|
||||
}
|
||||
}
|
||||
|
||||
/// Clip each of `names` ([`clip`]) and make it unique among them (ignoring case when `fold`): an
|
||||
/// empty one becomes `fallback`, a repeated one gets a number after `sep` (`Blue 2`).
|
||||
fn uniquify<'a>(names: impl Iterator<Item = &'a mut String>, fallback: &str, sep: &str, fold: bool) {
|
||||
let key = |n: &str| if fold { n.to_lowercase() } else { n.to_string() };
|
||||
let mut seen = HashSet::new();
|
||||
for name in names {
|
||||
clip(name);
|
||||
if name.trim().is_empty() {
|
||||
*name = fallback.to_string();
|
||||
}
|
||||
if seen.contains(&key(name)) {
|
||||
let base = name.clone();
|
||||
*name = (2..).map(|n| format!("{base}{sep}{n}")).find(|n| !seen.contains(&key(n))).unwrap_or_default();
|
||||
}
|
||||
seen.insert(key(name));
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `b64` is empty or a PNG of at most [`MAX_THUMBNAIL_SIDE`] pixels a side, judged by its
|
||||
/// header: the panel decodes it.
|
||||
fn thumbnail_ok(b64: &str) -> bool {
|
||||
if b64.is_empty() {
|
||||
return true;
|
||||
}
|
||||
// 32 base64 characters: 24 bytes, the signature then the IHDR chunk's length, type, width and
|
||||
// height.
|
||||
let Some(head) = b64.get(..32).filter(|_| b64.len() <= MAX_THUMBNAIL).and_then(vectorcraft_format::base64_decode) else { return false };
|
||||
let side = |at: usize| {
|
||||
head.get(at..at + 4).and_then(|b| <[u8; 4]>::try_from(b).ok()).map(u32::from_be_bytes).is_some_and(|v| (1..=MAX_THUMBNAIL_SIDE).contains(&v))
|
||||
};
|
||||
head.starts_with(b"\x89PNG\r\n\x1a\n") && head.get(12..16) == Some(b"IHDR".as_slice()) && side(16) && side(20)
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn library_paths(dir: &str) -> Vec<String> {
|
||||
let Ok(rd) = std::fs::read_dir(dir) else { return vec![] };
|
||||
@@ -234,6 +390,14 @@ fn remove_file(_: &str) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Library `name` as a file name without its extension: letters, digits, spaces, `-` and `_`
|
||||
/// kept, other characters as `-` ("Library" when nothing is left).
|
||||
pub fn file_stem_for(name: &str) -> String {
|
||||
let safe: String = name.chars().map(|c| if c.is_alphanumeric() || c == ' ' || c == '-' || c == '_' { c } else { '-' }).collect();
|
||||
let safe = safe.trim();
|
||||
if safe.is_empty() { "Library".to_string() } else { safe.to_string() }
|
||||
}
|
||||
|
||||
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())
|
||||
}
|
||||
@@ -260,6 +424,16 @@ impl Kind {
|
||||
_ => return Err(bad(cmd, "kind must be graphic, fillColor, strokeColor, charStyle or paraStyle")),
|
||||
})
|
||||
}
|
||||
|
||||
/// The kind as a group names it (fill and stroke colours are one list).
|
||||
fn item_kind(self) -> ItemKind {
|
||||
match self {
|
||||
Kind::Graphic => ItemKind::Graphic,
|
||||
Kind::FillColor | Kind::StrokeColor => ItemKind::Color,
|
||||
Kind::CharStyle => ItemKind::CharStyle,
|
||||
Kind::ParaStyle => ItemKind::ParaStyle,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
@@ -269,7 +443,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"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)}",
|
||||
"{} → {libraries: [{id, name, graphics, colors, charStyles, paraStyles, groups} (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
|
||||
),
|
||||
@@ -278,7 +452,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"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}]}",
|
||||
"{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}], groups: [{name, items: [{kind: color|charStyle|paraStyle|graphic, item: name (graphic: id)}]}] (an item is in one group at most; the others are ungrouped)}",
|
||||
always,
|
||||
get
|
||||
),
|
||||
@@ -336,7 +510,69 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
has_doc,
|
||||
use_item
|
||||
),
|
||||
cmd!("library.removeItem", "Delete Library Item", [], None, "{library?, kind, item: name (graphic: id or name)} → null", always, remove_item),
|
||||
cmd!(
|
||||
"library.removeItem",
|
||||
"Delete Library Item",
|
||||
[],
|
||||
None,
|
||||
"{library?, kind, item: name (graphic: id or name)} delete an item (out of its group too) → null",
|
||||
always,
|
||||
remove_item
|
||||
),
|
||||
cmd!(
|
||||
"library.createGroup",
|
||||
"New Library Group",
|
||||
[],
|
||||
None,
|
||||
"{library?, name?: \"Group\" (a name the library's groups have, ignoring case, gets a number), items?: [{kind, item}] (moved into it out of their groups)} a new group of items, after the others → {library, group: its name}",
|
||||
always,
|
||||
create_group
|
||||
),
|
||||
cmd!(
|
||||
"library.renameGroup",
|
||||
"Rename Library Group",
|
||||
[],
|
||||
None,
|
||||
"{library?, group: name, name} rename a group (another group's name, ignoring case, is an error) → {library, group: the new name}",
|
||||
always,
|
||||
rename_group
|
||||
),
|
||||
cmd!(
|
||||
"library.deleteGroup",
|
||||
"Delete Library Group",
|
||||
[],
|
||||
None,
|
||||
"{library?, group: name} delete a group: its items stay in the library, ungrouped → {library, ungrouped: how many}",
|
||||
always,
|
||||
delete_group
|
||||
),
|
||||
cmd!(
|
||||
"library.moveItem",
|
||||
"Move Library Item to Group",
|
||||
[],
|
||||
None,
|
||||
"{library?, kind, item: name (graphic: id or name), group?: name (null or none: out of its group)} put an item in a group (out of the one it was in) → {library, kind, item, group: name or null}",
|
||||
always,
|
||||
move_item
|
||||
),
|
||||
cmd!(
|
||||
query "library.export",
|
||||
"Export Library",
|
||||
[],
|
||||
None,
|
||||
"{library?: id or name (default: the current library), path?} write a library (its colours, styles, graphics with their art and thumbnails, and groups) as a .vclibrary file (JSON, as libraries are kept) → {path, name}; without path → {data: the file's text, name}",
|
||||
always,
|
||||
export
|
||||
),
|
||||
cmd!(
|
||||
query "library.import",
|
||||
"Import Library",
|
||||
[],
|
||||
None,
|
||||
"{path? | data?: file text | dataBase64?, name?: the library's name (default: the file's)} add the library of a .vclibrary file (as library.export writes) as a new library, made the current one; a name another library has gets a number (`Brand (2)`) → {id, name, graphics, colors, charStyles, paraStyles, groups (counts)}",
|
||||
always,
|
||||
import
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
@@ -362,6 +598,7 @@ fn counts(lib: &Library) -> Value {
|
||||
"colors": lib.colors.len(),
|
||||
"charStyles": lib.char_styles.len(),
|
||||
"paraStyles": lib.para_styles.len(),
|
||||
"groups": lib.groups.len(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -391,6 +628,7 @@ fn get(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
"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<_>>(),
|
||||
"groups": lib.groups,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -457,7 +695,8 @@ fn add(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
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(' ', "-");
|
||||
// Ids are `graphic`, `graphic-2`…: taken ones are looked for as they are kept.
|
||||
g.id = unique("graphic", |n| lib.graphics.iter().any(|o| o.id == n.replace(' ', "-"))).replace(' ', "-");
|
||||
let v = json!({ "name": g.name, "id": g.id, "existing": false });
|
||||
lib.graphics.push(g);
|
||||
v
|
||||
@@ -682,22 +921,171 @@ fn remove_item(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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());
|
||||
let r = lib.item_ref(kind.item_kind(), &item).ok_or_else(|| bad(C, format!("the library has no item `{item}`")))?;
|
||||
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}`")));
|
||||
Kind::Graphic => lib.graphics.retain(|g| g.id != r.item),
|
||||
Kind::FillColor | Kind::StrokeColor => lib.colors.retain(|c| c.name != r.item),
|
||||
Kind::CharStyle => lib.char_styles.retain(|t| t.name != r.item),
|
||||
Kind::ParaStyle => lib.para_styles.retain(|t| t.name != r.item),
|
||||
}
|
||||
lib.ungroup(&r);
|
||||
Ok(())
|
||||
})?;
|
||||
ok()
|
||||
}
|
||||
|
||||
/// `p`'s `name`, trimmed and clipped, else `default`.
|
||||
fn name_param(p: &Value, default: &str) -> String {
|
||||
let mut name = str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()).unwrap_or(default).to_string();
|
||||
clip(&mut name);
|
||||
name
|
||||
}
|
||||
|
||||
/// `p`'s `group`.
|
||||
fn group_param<'a>(p: &'a Value, cmd: &str) -> Result<&'a str> {
|
||||
str_param(p, "group").ok_or_else(|| bad(cmd, "missing `group`"))
|
||||
}
|
||||
|
||||
/// Group `name` of `lib`: its index.
|
||||
fn group_at(lib: &Library, name: &str, cmd: &str) -> Result<usize> {
|
||||
lib.group_position(name).ok_or_else(|| bad(cmd, format!("the library has no group `{name}`")))
|
||||
}
|
||||
|
||||
fn create_group(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "library.createGroup";
|
||||
let id = library_id(s, p, C)?;
|
||||
let base = name_param(p, "Group");
|
||||
let wanted = match p.get("items") {
|
||||
None | Some(Value::Null) => vec![],
|
||||
Some(Value::Array(items)) if items.len() <= 4 * MAX_ITEMS => items
|
||||
.iter()
|
||||
.map(|v| Ok((Kind::of(v, C)?.item_kind(), str_param(v, "item").ok_or_else(|| bad(C, "each of `items` is {kind, item}"))?)))
|
||||
.collect::<Result<Vec<_>>>()?,
|
||||
Some(_) => return Err(bad(C, "`items` is a list of {kind, item}")),
|
||||
};
|
||||
let name = s.libraries.change(&id, |lib| {
|
||||
if lib.groups.len() >= MAX_GROUPS {
|
||||
return Err(bad(C, format!("the library holds {MAX_GROUPS} groups already")));
|
||||
}
|
||||
let refs = wanted
|
||||
.iter()
|
||||
.map(|&(kind, item)| lib.item_ref(kind, item).ok_or_else(|| bad(C, format!("the library has no item `{item}`"))))
|
||||
.collect::<Result<Vec<_>>>()?;
|
||||
let name = unique(&base, |n| lib.group_position(n).is_some());
|
||||
let mut items = vec![];
|
||||
let mut seen = HashSet::new();
|
||||
for r in refs {
|
||||
lib.ungroup(&r);
|
||||
if seen.insert(r.clone()) {
|
||||
items.push(r);
|
||||
}
|
||||
}
|
||||
lib.groups.push(LibraryGroup { name: name.clone(), items });
|
||||
Ok(name)
|
||||
})?;
|
||||
Ok(json!({ "library": id, "group": name }))
|
||||
}
|
||||
|
||||
fn rename_group(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "library.renameGroup";
|
||||
let id = library_id(s, p, C)?;
|
||||
let group = group_param(p, C)?;
|
||||
if str_param(p, "name").is_none_or(|n| n.trim().is_empty()) {
|
||||
return Err(bad(C, "missing `name`"));
|
||||
}
|
||||
let name = name_param(p, "");
|
||||
s.libraries.change(&id, |lib| {
|
||||
let i = group_at(lib, group, C)?;
|
||||
if lib.group_position(&name).is_some_and(|j| j != i) {
|
||||
return Err(bad(C, format!("the library has a group `{name}` already")));
|
||||
}
|
||||
if let Some(g) = lib.groups.get_mut(i) {
|
||||
g.name.clone_from(&name);
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(json!({ "library": id, "group": name }))
|
||||
}
|
||||
|
||||
fn delete_group(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "library.deleteGroup";
|
||||
let id = library_id(s, p, C)?;
|
||||
let group = group_param(p, C)?;
|
||||
let ungrouped = s.libraries.change(&id, |lib| {
|
||||
let i = group_at(lib, group, C)?;
|
||||
Ok(if i < lib.groups.len() { lib.groups.remove(i).items.len() } else { 0 })
|
||||
})?;
|
||||
Ok(json!({ "library": id, "ungrouped": ungrouped }))
|
||||
}
|
||||
|
||||
fn move_item(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "library.moveItem";
|
||||
let (id, kind, item) = item_param(s, p, C)?;
|
||||
let group = str_param(p, "group");
|
||||
let (key, to) = s.libraries.change(&id, |lib| {
|
||||
let r = lib.item_ref(kind.item_kind(), item).ok_or_else(|| bad(C, format!("the library has no item `{item}`")))?;
|
||||
let at = group.map(|g| group_at(lib, g, C)).transpose()?;
|
||||
lib.ungroup(&r);
|
||||
let key = r.item.clone();
|
||||
let to = at.and_then(|i| lib.groups.get_mut(i)).map(|g| {
|
||||
g.items.push(r);
|
||||
g.name.clone()
|
||||
});
|
||||
Ok((key, to))
|
||||
})?;
|
||||
Ok(json!({ "library": id, "kind": str_param(p, "kind"), "item": key, "group": to }))
|
||||
}
|
||||
|
||||
fn export(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "library.export";
|
||||
let id = library_id(s, p, C)?;
|
||||
let lib = s.libraries.get(&id).ok_or_else(|| bad(C, format!("no library `{id}`")))?;
|
||||
let text = serde_json::to_string(lib).map_err(|e| bad(C, e.to_string()))?;
|
||||
let name = lib.name.clone();
|
||||
match str_param(p, "path") {
|
||||
Some(path) => {
|
||||
super::fileio::write_file(path, text.as_bytes())?;
|
||||
Ok(json!({ "path": path, "name": name }))
|
||||
}
|
||||
None => Ok(json!({ "data": text, "name": name })),
|
||||
}
|
||||
}
|
||||
|
||||
fn import(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "library.import";
|
||||
let too_big = || bad(C, format!("the file is larger than {} MB", MAX_FILE >> 20));
|
||||
let bytes: std::borrow::Cow<[u8]> = match (str_param(p, "path"), str_param(p, "data"), str_param(p, "dataBase64")) {
|
||||
(Some(path), ..) => {
|
||||
if super::fileio::file_stamp(path).is_some_and(|(len, _)| len > MAX_FILE) {
|
||||
return Err(too_big());
|
||||
}
|
||||
super::fileio::read_file(path)?.into()
|
||||
}
|
||||
(None, Some(text), _) => text.as_bytes().into(),
|
||||
(None, None, Some(b64)) => vectorcraft_format::base64_decode(b64).ok_or_else(|| bad(C, "bad dataBase64"))?.into(),
|
||||
_ => return Err(bad(C, "give `path`, `data` or `dataBase64`")),
|
||||
};
|
||||
if bytes.len() as u64 > MAX_FILE {
|
||||
return Err(too_big());
|
||||
}
|
||||
let not_library = |e: String| bad(C, format!("not a library file: {e}"));
|
||||
let v: Value = serde_json::from_slice(&bytes).map_err(|e| not_library(e.to_string()))?;
|
||||
drop(bytes);
|
||||
// A library names itself; another file of ours (swatches, styles) says what it is.
|
||||
if !v.get("name").is_some_and(Value::is_string) || v.get("format").is_some_and(|f| f.as_str() != Some(LIBRARY_EXT)) {
|
||||
return Err(not_library("it has no library name".into()));
|
||||
}
|
||||
let mut lib: Library = serde_json::from_value(v).map_err(|e| not_library(e.to_string()))?;
|
||||
lib.sanitize();
|
||||
let base = str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()).unwrap_or(lib.name.trim()).to_string();
|
||||
let mut base = if base.is_empty() { "Library".to_string() } else { base };
|
||||
clip(&mut base);
|
||||
let taken = |n: &str| s.libraries.all().iter().any(|(_, l)| l.name.to_lowercase() == n.to_lowercase());
|
||||
lib.name = std::iter::once(base.clone()).chain((2..).map(|n| format!("{base} ({n})"))).find(|n| !taken(n)).unwrap_or(base);
|
||||
let mut r = json!({ "name": lib.name });
|
||||
if let (Some(o), Value::Object(c)) = (r.as_object_mut(), counts(&lib)) {
|
||||
o.extend(c);
|
||||
}
|
||||
r["id"] = json!(s.libraries.insert(lib)?);
|
||||
Ok(r)
|
||||
}
|
||||
@@ -161,3 +161,181 @@ fn libraries_are_kept_in_files_across_sessions() {
|
||||
assert_eq!(run(&mut v, "library.list", json!({}))["libraries"], json!([]));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// A library "Brand" holding a graphic, a colour and a character style, each added from the
|
||||
/// selection → (session, the graphic's id, the colour's name, the style's name).
|
||||
fn brand() -> (Session, String, String, String) {
|
||||
let mut s = session();
|
||||
run(&mut s, "library.create", json!({"name": "Brand"}));
|
||||
let r = run(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 20, "height": 20}))["id"].clone();
|
||||
run(&mut s, "paint.setFill", json!({"color": "#336699", "ids": [r]}));
|
||||
run(&mut s, "select.set", json!({"ids": [r]}));
|
||||
let g = run(&mut s, "library.add", json!({"kind": "graphic", "name": "Logo"}))["id"].as_str().unwrap().to_string();
|
||||
let c = run(&mut s, "library.add", json!({"kind": "fillColor"}))["name"].as_str().unwrap().to_string();
|
||||
let t = run(&mut s, "text.create", json!({"x": 10, "y": 80, "text": "Type", "size": 18}))["id"].clone();
|
||||
run(&mut s, "select.set", json!({"ids": [t]}));
|
||||
let st = run(&mut s, "library.add", json!({"kind": "charStyle"}))["name"].as_str().unwrap().to_string();
|
||||
(s, g, c, st)
|
||||
}
|
||||
|
||||
/// The current library's groups as `library.get` reports them: (name, [(kind, item)]).
|
||||
fn groups(s: &mut Session) -> Vec<(String, Vec<(String, String)>)> {
|
||||
let got = run(s, "library.get", json!({}));
|
||||
got["groups"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|g| {
|
||||
let items = g["items"].as_array().unwrap().iter().map(|r| (r["kind"].as_str().unwrap().into(), r["item"].as_str().unwrap().into()));
|
||||
(g["name"].as_str().unwrap().to_string(), items.collect())
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn refs(v: &[(&str, &str)]) -> Vec<(String, String)> {
|
||||
v.iter().map(|(k, i)| (k.to_string(), i.to_string())).collect()
|
||||
}
|
||||
|
||||
/// Graphics added one after another get ids of their own (the third used to repeat the second's).
|
||||
#[test]
|
||||
fn every_graphic_gets_an_id_of_its_own() {
|
||||
let mut s = session();
|
||||
run(&mut s, "library.create", json!({}));
|
||||
let mut ids = vec![];
|
||||
for x in [0, 30, 60, 90] {
|
||||
let r = run(&mut s, "shape.rectangle", json!({"x": x, "y": 0, "width": 10 + x, "height": 10}))["id"].clone();
|
||||
ids.push(run(&mut s, "library.add", json!({"kind": "graphic", "ids": [r]}))["id"].as_str().unwrap().to_string());
|
||||
}
|
||||
assert_eq!(ids, ["graphic", "graphic-2", "graphic-3", "graphic-4"]);
|
||||
}
|
||||
|
||||
/// Items go into user-named groups and back out (#926); deleting a group keeps its items.
|
||||
#[test]
|
||||
fn items_are_grouped_moved_and_ungrouped() {
|
||||
let (mut s, g, c, st) = brand();
|
||||
assert!(groups(&mut s).is_empty(), "no groups yet");
|
||||
// A group made with items; a graphic is named by its name or its id.
|
||||
let r = run(&mut s, "library.createGroup", json!({"name": "Logos", "items": [{"kind": "graphic", "item": "Logo"}]}));
|
||||
assert_eq!(r["group"], "Logos");
|
||||
assert_eq!(groups(&mut s), [("Logos".into(), refs(&[("graphic", &g)]))]);
|
||||
// A name taken (ignoring case) gets a number.
|
||||
assert_eq!(run(&mut s, "library.createGroup", json!({"name": "logos"}))["group"], "logos 2");
|
||||
run(&mut s, "library.renameGroup", json!({"group": "logos 2", "name": "Palette"}));
|
||||
assert!(s.execute("library.renameGroup", &json!({"group": "Palette", "name": "LOGOS"})).is_err(), "another group's name");
|
||||
assert!(s.execute("library.renameGroup", &json!({"group": "Nope", "name": "X"})).is_err());
|
||||
// Moving an item takes it out of the group it was in; a colour is named as fill or stroke.
|
||||
run(&mut s, "library.moveItem", json!({"kind": "strokeColor", "item": c, "group": "palette"}));
|
||||
run(&mut s, "library.moveItem", json!({"kind": "charStyle", "item": st, "group": "Palette"}));
|
||||
let moved = run(&mut s, "library.moveItem", json!({"kind": "graphic", "item": "Logo", "group": "Palette"}));
|
||||
assert_eq!((moved["item"].as_str(), moved["group"].as_str()), (Some(g.as_str()), Some("Palette")));
|
||||
assert_eq!(groups(&mut s), [("Logos".into(), vec![]), ("Palette".into(), refs(&[("color", &c), ("charStyle", &st), ("graphic", &g)]))]);
|
||||
// Without a group, out of its group.
|
||||
assert!(run(&mut s, "library.moveItem", json!({"kind": "fillColor", "item": c}))["group"].is_null());
|
||||
assert!(s.execute("library.moveItem", &json!({"kind": "fillColor", "item": c, "group": "Nope"})).is_err());
|
||||
assert!(s.execute("library.moveItem", &json!({"kind": "fillColor", "item": "#nope", "group": "Palette"})).is_err());
|
||||
assert!(s.execute("library.createGroup", &json!({"items": [{"kind": "graphic", "item": "nope"}]})).is_err(), "no such item");
|
||||
assert_eq!(groups(&mut s).len(), 2, "a failed command makes no group");
|
||||
// A deleted item leaves its group.
|
||||
run(&mut s, "library.removeItem", json!({"kind": "charStyle", "item": st}));
|
||||
assert_eq!(groups(&mut s)[1].1, refs(&[("graphic", &g)]));
|
||||
// A deleted group's items stay, ungrouped.
|
||||
assert_eq!(run(&mut s, "library.deleteGroup", json!({"group": "Palette"}))["ungrouped"], 1);
|
||||
assert_eq!(run(&mut s, "library.get", json!({}))["graphics"].as_array().unwrap().len(), 1);
|
||||
assert_eq!(groups(&mut s), [("Logos".into(), vec![])]);
|
||||
assert_eq!(run(&mut s, "library.list", json!({}))["libraries"][0]["groups"], 1);
|
||||
}
|
||||
|
||||
/// A library goes out to a file with its art and groups and comes back as a new library, numbered
|
||||
/// when its name is taken; the graphic it brings places in a document (#926).
|
||||
#[test]
|
||||
fn a_library_is_exported_and_imported() {
|
||||
let (mut s, g, c, _) = brand();
|
||||
run(&mut s, "library.createGroup", json!({"name": "Logos", "items": [{"kind": "graphic", "item": g}, {"kind": "fillColor", "item": c}]}));
|
||||
let out = run(&mut s, "library.export", json!({}));
|
||||
assert_eq!(out["name"], "Brand");
|
||||
let data = out["data"].as_str().unwrap().to_string();
|
||||
let r = run(&mut s, "library.import", json!({"data": data}));
|
||||
let counts = ["graphics", "colors", "charStyles", "paraStyles", "groups"].map(|k| r[k].as_u64().unwrap());
|
||||
assert_eq!((r["name"].as_str(), counts), (Some("Brand (2)"), [1, 1, 1, 0, 1]));
|
||||
let id = r["id"].as_str().unwrap().to_string();
|
||||
assert_eq!(run(&mut s, "library.list", json!({}))["current"], id, "the imported library is the current one");
|
||||
let a = run(&mut s, "library.get", json!({"library": "Brand"}));
|
||||
let b = run(&mut s, "library.get", json!({"library": id}));
|
||||
for k in ["graphics", "colors", "charStyles", "paraStyles", "groups"] {
|
||||
assert_eq!(a[k], b[k], "{k}");
|
||||
}
|
||||
// Again: the next number; given a name, that name.
|
||||
let b64 = vectorcraft_format::base64_encode(data.as_bytes());
|
||||
assert_eq!(run(&mut s, "library.import", json!({"dataBase64": b64}))["name"], "Brand (3)");
|
||||
assert_eq!(run(&mut s, "library.import", json!({"data": data, "name": "Client"}))["name"], "Client");
|
||||
// Its graphic places in another document.
|
||||
run(&mut s, "file.new", json!({"width": 100, "height": 100}));
|
||||
let placed = run(&mut s, "library.use", json!({"library": id, "kind": "graphic", "item": "Logo", "center": [50, 50]}));
|
||||
assert_eq!(placed["ids"].as_array().unwrap().len(), 1);
|
||||
// Not a library file.
|
||||
let swatches = json!({"format": "vcswatches", "name": "S"}).to_string();
|
||||
for bad in [json!({"data": "{not json"}), json!({"data": "[1, 2]"}), json!({"data": swatches}), json!({"dataBase64": "%%"}), json!({})] {
|
||||
assert!(s.execute("library.import", &bad).is_err(), "{bad}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A file written before groups loads; one with repeated names, groups naming items that aren't
|
||||
/// there or kinds this version doesn't know, and a thumbnail claiming a huge image is made safe.
|
||||
#[test]
|
||||
fn imported_libraries_are_made_consistent() {
|
||||
let mut s = session();
|
||||
let old = json!({"name": "Old", "colors": [{"name": "Red", "color": {"model": "rgb", "r": 1, "g": 0, "b": 0}}]});
|
||||
let r = run(&mut s, "library.import", json!({"data": old.to_string()}));
|
||||
assert_eq!((r["colors"].as_u64(), r["groups"].as_u64()), (Some(1), Some(0)));
|
||||
// A PNG header claiming 100000 × 100000 pixels.
|
||||
let mut png = b"\x89PNG\r\n\x1a\n\0\0\0\x0dIHDR".to_vec();
|
||||
png.extend(100_000u32.to_be_bytes());
|
||||
png.extend(100_000u32.to_be_bytes());
|
||||
png.extend([8, 6, 0, 0, 0]);
|
||||
let messy = json!({
|
||||
"name": " ",
|
||||
"colors": [{"name": "Red", "color": {"model": "rgb", "r": 1, "g": 0, "b": 0}}, {"name": "Red", "color": {"model": "rgb", "r": 0, "g": 1, "b": 0}}, {"name": "", "color": {"model": "rgb", "r": 0, "g": 0, "b": 1}}],
|
||||
"graphics": [
|
||||
{"id": "g", "name": "A", "width": 1, "height": 1, "data": "", "thumbnail": vectorcraft_format::base64_encode(&png)},
|
||||
{"id": "g", "name": "B", "width": -5, "height": 1, "data": ""}
|
||||
],
|
||||
"groups": [
|
||||
{"name": "One", "items": [{"kind": "color", "item": "Red 2"}, {"kind": "graphic", "item": "nope"}, {"kind": "brush", "item": "Red"}]},
|
||||
{"name": "one", "items": [{"kind": "color", "item": "Red 2"}, {"kind": "graphic", "item": "g-2"}]}
|
||||
]
|
||||
});
|
||||
let r = run(&mut s, "library.import", json!({"data": messy.to_string()}));
|
||||
assert_eq!(r["name"], "Library", "a blank name");
|
||||
let got = run(&mut s, "library.get", json!({}));
|
||||
let names: Vec<&str> = got["colors"].as_array().unwrap().iter().map(|c| c["name"].as_str().unwrap()).collect();
|
||||
assert_eq!(names, ["Red", "Red 2", "Color"]);
|
||||
let graphics = got["graphics"].as_array().unwrap();
|
||||
assert_eq!((graphics[0]["id"].as_str(), graphics[1]["id"].as_str()), (Some("g"), Some("g-2")));
|
||||
assert_eq!((graphics[0]["thumbnail"].as_str(), graphics[1]["width"].as_f64()), (Some(""), Some(0.0)));
|
||||
assert_eq!(
|
||||
got["groups"],
|
||||
json!([{"name": "One", "items": [{"kind": "color", "item": "Red 2"}]}, {"name": "one 2", "items": [{"kind": "graphic", "item": "g-2"}]}])
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[test]
|
||||
fn a_library_is_exported_to_a_file_and_imported_from_it() {
|
||||
let dir = vectorcraft_testkit::temp_dir("library-export");
|
||||
let path = dir.join("Brand.vclibrary").to_string_lossy().to_string();
|
||||
let (mut s, g, ..) = brand();
|
||||
run(&mut s, "library.createGroup", json!({"name": "Logos", "items": [{"kind": "graphic", "item": g}]}));
|
||||
assert_eq!(run(&mut s, "library.export", json!({"library": "Brand", "path": path}))["path"], path.as_str());
|
||||
let mut t = session();
|
||||
let r = run(&mut t, "library.import", json!({"path": path}));
|
||||
assert_eq!((r["name"].as_str(), r["graphics"].as_u64(), r["groups"].as_u64()), (Some("Brand"), Some(1), Some(1)));
|
||||
assert!(t.execute("library.import", &json!({"path": dir.join("missing.vclibrary").to_string_lossy()})).is_err());
|
||||
// Groups are kept in the library folder's files too.
|
||||
let folder = dir.join("folder").to_string_lossy().to_string();
|
||||
let mut u = session();
|
||||
u.libraries.set_dir(Some(folder.clone()));
|
||||
run(&mut u, "library.import", json!({"path": path}));
|
||||
let mut v = session();
|
||||
v.libraries.set_dir(Some(folder));
|
||||
assert_eq!(groups(&mut v), [("Logos".into(), refs(&[("graphic", &g)]))]);
|
||||
}
|
||||
@@ -804,6 +804,53 @@ fn styles(what: &str, data: &str) -> Result<(), TestCaseError> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Libraries panel library file `data` imported, then each kind's first item used on the rich
|
||||
/// document and moved into its first group, the group deleted and the library exported (#926).
|
||||
fn libraries(what: &str, data: &str) -> Result<(), TestCaseError> {
|
||||
survive_library(what, "library.import", data, |s, r| {
|
||||
let lib = r["id"].clone();
|
||||
let Ok(got) = no_panic(s.execute("library.get", &json!({"library": lib}))) else { return };
|
||||
let _ = no_panic(s.execute("select.all", &json!({})));
|
||||
for (list, kind, key) in
|
||||
[("graphics", "graphic", "id"), ("colors", "fillColor", "name"), ("charStyles", "charStyle", "name"), ("paraStyles", "paraStyle", "name")]
|
||||
{
|
||||
if let Some(item) = got[list].get(0) {
|
||||
let _ = no_panic(s.execute("library.use", &json!({"library": lib, "kind": kind, "item": item[key]})));
|
||||
let to = json!({"library": lib, "kind": kind, "item": item[key], "group": got["groups"][0]["name"]});
|
||||
let _ = no_panic(s.execute("library.moveItem", &to));
|
||||
}
|
||||
}
|
||||
if let Some(g) = got["groups"].get(0) {
|
||||
let _ = no_panic(s.execute("library.deleteGroup", &json!({"library": lib, "group": g["name"]})));
|
||||
}
|
||||
let _ = no_panic(s.execute("library.export", &json!({"library": lib})));
|
||||
})
|
||||
}
|
||||
|
||||
/// A Libraries panel library of the rich document's first object (a graphic), a colour, its
|
||||
/// type's character and paragraph styles and a group, exported, made once.
|
||||
fn saved_library() -> String {
|
||||
static TEXT: std::sync::OnceLock<String> = std::sync::OnceLock::new();
|
||||
TEXT.get_or_init(|| {
|
||||
let mut s = rich_session();
|
||||
s.execute("library.create", &json!({"name": "Fuzz"})).unwrap();
|
||||
let first = s.doc().unwrap().doc.layers[0].children().unwrap()[0].id.0;
|
||||
let g = s.execute("library.add", &json!({"kind": "graphic", "ids": [first]})).unwrap()["id"].clone();
|
||||
s.execute("library.add", &json!({"kind": "fillColor", "color": "#336699"})).unwrap();
|
||||
s.execute("select.all", &json!({})).unwrap();
|
||||
for kind in ["charStyle", "paraStyle"] {
|
||||
s.execute("library.add", &json!({"kind": kind})).unwrap();
|
||||
}
|
||||
s.execute(
|
||||
"library.createGroup",
|
||||
&json!({"name": "Logos", "items": [{"kind": "graphic", "item": g}, {"kind": "fillColor", "item": "#336699"}]}),
|
||||
)
|
||||
.unwrap();
|
||||
s.execute("library.export", &json!({})).unwrap()["data"].as_str().unwrap().to_string()
|
||||
})
|
||||
.clone()
|
||||
}
|
||||
|
||||
fn flattener_presets(what: &str, data: &str) -> Result<(), TestCaseError> {
|
||||
survive_library(what, "flattener.presets.import", data, |s, r| {
|
||||
let _ = no_panic(s.execute("select.all", &json!({})));
|
||||
@@ -865,11 +912,12 @@ proptest! {
|
||||
#![proptest_config(config())]
|
||||
|
||||
#[test]
|
||||
fn library_garbage_never_panics(s in ".{0,300}", head in prop::sample::select(vec!["", "GIMP Palette\n","{\"format\": \"vcswatches\", ", "{\"format\": \"vcstyles\", ", "{\"format\": \"vcflattener\", "])) {
|
||||
fn library_garbage_never_panics(s in ".{0,300}", head in prop::sample::select(vec!["", "GIMP Palette\n","{\"format\": \"vcswatches\", ", "{\"format\": \"vcstyles\", ", "{\"format\": \"vcflattener\", ", "{\"name\": \"L\", "])) {
|
||||
let data = format!("{head}{s}");
|
||||
swatches("swatch library garbage", &data)?;
|
||||
styles("style library garbage", &data)?;
|
||||
flattener_presets("flattener preset garbage", &data)?;
|
||||
libraries("library garbage", &data)?;
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -890,6 +938,14 @@ proptest! {
|
||||
flattener_presets("mutated flattener presets", &mutate_text(&text, cut, &edits))?;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mutated_libraries_never_panic(keep in 0.0f64..1.2, edits in prop::collection::vec(arb_edit(), 0..10)) {
|
||||
// Cut anywhere in the file (whole most of the time), the edits anywhere in it.
|
||||
let text = saved_library();
|
||||
let cut = (text.chars().count() as f64 * keep) as usize;
|
||||
libraries("mutated library", &mutate_text(&text, cut, &edits))?;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn swatch_exchange_garbage_never_panics(blocks in prop::collection::vec(arb_ase_block(), 0..12), tail in prop::collection::vec(any::<u8>(), 0..16)) {
|
||||
let mut bytes = b"ASEF\0\x01\0\0".to_vec();
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
//! The generic parameter dialog (`ui.paramDialog`): edits a command's parameters (`__command`,
|
||||
//! headed `__label`) and runs it on OK.
|
||||
//! headed `__label`) and runs it on OK, with the fixed ones in `__params` (not shown) added.
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
@@ -60,7 +60,10 @@ const VERTICAL_ALIGN: form::Choices = &[("Top", "top"), ("Center", "center"), ("
|
||||
/// Closes before running, so a dialog the command opens stays open.
|
||||
fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
|
||||
let cmd = d.str("__command");
|
||||
let params = form::params(d);
|
||||
let mut params = form::params(d);
|
||||
if let (Some(o), Some(Value::Object(fixed))) = (params.as_object_mut(), d.fields.get("__params")) {
|
||||
o.extend(fixed.clone());
|
||||
}
|
||||
app.ui.dialog = None;
|
||||
// A tool's click-to-size shape (Flare) goes on the active perspective plane while the grid shows.
|
||||
let at = |x: &str, y: &str| Some(vectorcraft_geom::Point::new(params.get(x)?.as_f64()?, params.get(y)?.as_f64()?));
|
||||
|
||||
@@ -2760,6 +2760,23 @@
|
||||
Add to Library Añadir a la biblioteca
|
||||
Delete Library Eliminar biblioteca
|
||||
Delete Library Item Eliminar elemento de la biblioteca
|
||||
No results Sin resultados
|
||||
No items in this library match “{query}”. Ningún elemento de esta biblioteca coincide con “{query}”.
|
||||
Right-click an item to move it into this group. Haz clic con el botón derecho en un elemento para moverlo a este grupo.
|
||||
New Group Nuevo grupo
|
||||
New Group… Nuevo grupo…
|
||||
Move to Group Mover al grupo
|
||||
No Group Sin grupo
|
||||
Rename Group… Cambiar nombre del grupo…
|
||||
Delete Group Eliminar grupo
|
||||
Import Library… Importar biblioteca…
|
||||
Export Library… Exportar biblioteca…
|
||||
Import Library Importar biblioteca
|
||||
Export Library Exportar biblioteca
|
||||
New Library Group Nuevo grupo de biblioteca
|
||||
Rename Library Group Cambiar nombre del grupo de biblioteca
|
||||
Delete Library Group Eliminar grupo de biblioteca
|
||||
Move Library Item to Group Mover elemento de la biblioteca al grupo
|
||||
Library Biblioteca
|
||||
Rename Library Cambiar nombre de la biblioteca
|
||||
Show Library Mostrar biblioteca
|
||||
@@ -3881,3 +3898,4 @@ axis Both Ambos
|
||||
Even Fields Campos pares
|
||||
Duplication Duplicación
|
||||
Interpolation Interpolación
|
||||
@msg Imported library {name} Biblioteca {name} importada
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -2757,6 +2757,23 @@
|
||||
Add to Library Ajouter à la bibliothèque
|
||||
Delete Library Supprimer la bibliothèque
|
||||
Delete Library Item Supprimer l’élément de la bibliothèque
|
||||
No results Aucun résultat
|
||||
No items in this library match “{query}”. Aucun élément de cette bibliothèque ne correspond à « {query} ».
|
||||
Right-click an item to move it into this group. Cliquez avec le bouton droit sur un élément pour le placer dans ce groupe.
|
||||
New Group Nouveau groupe
|
||||
New Group… Nouveau groupe…
|
||||
Move to Group Déplacer vers le groupe
|
||||
No Group Aucun groupe
|
||||
Rename Group… Renommer le groupe…
|
||||
Delete Group Supprimer le groupe
|
||||
Import Library… Importer une bibliothèque…
|
||||
Export Library… Exporter la bibliothèque…
|
||||
Import Library Importer une bibliothèque
|
||||
Export Library Exporter la bibliothèque
|
||||
New Library Group Nouveau groupe de bibliothèque
|
||||
Rename Library Group Renommer le groupe de bibliothèque
|
||||
Delete Library Group Supprimer le groupe de bibliothèque
|
||||
Move Library Item to Group Déplacer l’élément de la bibliothèque vers un groupe
|
||||
Library Bibliothèque
|
||||
Rename Library Renommer la bibliothèque
|
||||
Show Library Afficher la bibliothèque
|
||||
@@ -3892,3 +3909,4 @@ axis Both Les deux
|
||||
Even Fields Trames paires
|
||||
Duplication Duplication
|
||||
Interpolation Interpolation
|
||||
@msg Imported library {name} Bibliothèque {name} importée
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -2760,6 +2760,23 @@
|
||||
Add to Library Aggiungi alla libreria
|
||||
Delete Library Elimina libreria
|
||||
Delete Library Item Elimina elemento della libreria
|
||||
No results Nessun risultato
|
||||
No items in this library match “{query}”. Nessun elemento di questa libreria corrisponde a “{query}”.
|
||||
Right-click an item to move it into this group. Fai clic con il pulsante destro su un elemento per spostarlo in questo gruppo.
|
||||
New Group Nuovo gruppo
|
||||
New Group… Nuovo gruppo…
|
||||
Move to Group Sposta nel gruppo
|
||||
No Group Nessun gruppo
|
||||
Rename Group… Rinomina gruppo…
|
||||
Delete Group Elimina gruppo
|
||||
Import Library… Importa libreria…
|
||||
Export Library… Esporta libreria…
|
||||
Import Library Importa libreria
|
||||
Export Library Esporta libreria
|
||||
New Library Group Nuovo gruppo di libreria
|
||||
Rename Library Group Rinomina gruppo di libreria
|
||||
Delete Library Group Elimina gruppo di libreria
|
||||
Move Library Item to Group Sposta elemento della libreria nel gruppo
|
||||
Library Libreria
|
||||
Rename Library Rinomina libreria
|
||||
Show Library Mostra libreria
|
||||
@@ -3881,3 +3898,4 @@ axis Both Entrambi
|
||||
Even Fields Campi pari
|
||||
Duplication Duplicazione
|
||||
Interpolation Interpolazione
|
||||
@msg Imported library {name} Libreria {name} importata
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -2496,6 +2496,23 @@
|
||||
Add to Library ライブラリに追加
|
||||
Delete Library ライブラリを削除
|
||||
Delete Library Item ライブラリ項目を削除
|
||||
No results 結果はありません
|
||||
No items in this library match “{query}”. このライブラリには「{query}」に一致する項目がありません。
|
||||
Right-click an item to move it into this group. 項目を右クリックすると、このグループに移動できます。
|
||||
New Group 新規グループ
|
||||
New Group… 新規グループ…
|
||||
Move to Group グループに移動
|
||||
No Group グループなし
|
||||
Rename Group… グループ名を変更…
|
||||
Delete Group グループを削除
|
||||
Import Library… ライブラリを読み込み…
|
||||
Export Library… ライブラリを書き出し…
|
||||
Import Library ライブラリを読み込み
|
||||
Export Library ライブラリを書き出し
|
||||
New Library Group 新規ライブラリグループ
|
||||
Rename Library Group ライブラリグループ名を変更
|
||||
Delete Library Group ライブラリグループを削除
|
||||
Move Library Item to Group ライブラリ項目をグループに移動
|
||||
Library ライブラリ
|
||||
Rename Library ライブラリ名を変更
|
||||
Show Library ライブラリを表示
|
||||
@@ -3873,3 +3890,4 @@ theme Appearance 外観
|
||||
Even Fields 偶数フィールド
|
||||
Duplication 複製
|
||||
Interpolation 補間
|
||||
@msg Imported library {name} ライブラリ {name} を読み込みました
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -2756,6 +2756,23 @@
|
||||
Add to Library Добавить в библиотеку
|
||||
Delete Library Удалить библиотеку
|
||||
Delete Library Item Удалить элемент библиотеки
|
||||
No results Ничего не найдено
|
||||
No items in this library match “{query}”. В этой библиотеке нет элементов, соответствующих «{query}».
|
||||
Right-click an item to move it into this group. Щёлкните элемент правой кнопкой мыши, чтобы переместить его в эту группу.
|
||||
New Group Новая группа
|
||||
New Group… Новая группа…
|
||||
Move to Group Переместить в группу
|
||||
No Group Без группы
|
||||
Rename Group… Переименовать группу…
|
||||
Delete Group Удалить группу
|
||||
Import Library… Импортировать библиотеку…
|
||||
Export Library… Экспортировать библиотеку…
|
||||
Import Library Импортировать библиотеку
|
||||
Export Library Экспортировать библиотеку
|
||||
New Library Group Новая группа библиотеки
|
||||
Rename Library Group Переименовать группу библиотеки
|
||||
Delete Library Group Удалить группу библиотеки
|
||||
Move Library Item to Group Переместить элемент библиотеки в группу
|
||||
Library Библиотека
|
||||
Rename Library Переименовать библиотеку
|
||||
Show Library Показать библиотеку
|
||||
@@ -3879,3 +3896,4 @@ axis Both Оба
|
||||
Even Fields Чётные поля
|
||||
Duplication Дублированием
|
||||
Interpolation Интерполяцией
|
||||
@msg Imported library {name} Импортирована библиотека {name}
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -2752,6 +2752,23 @@
|
||||
Add to Library Додати до бібліотеки
|
||||
Delete Library Видалити бібліотеку
|
||||
Delete Library Item Видалити елемент бібліотеки
|
||||
No results Нічого не знайдено
|
||||
No items in this library match “{query}”. У цій бібліотеці немає елементів, що відповідають «{query}».
|
||||
Right-click an item to move it into this group. Клацніть елемент правою кнопкою миші, щоб перемістити його в цю групу.
|
||||
New Group Нова група
|
||||
New Group… Нова група…
|
||||
Move to Group Перемістити в групу
|
||||
No Group Без групи
|
||||
Rename Group… Перейменувати групу…
|
||||
Delete Group Видалити групу
|
||||
Import Library… Імпортувати бібліотеку…
|
||||
Export Library… Експортувати бібліотеку…
|
||||
Import Library Імпортувати бібліотеку
|
||||
Export Library Експортувати бібліотеку
|
||||
New Library Group Нова група бібліотеки
|
||||
Rename Library Group Перейменувати групу бібліотеки
|
||||
Delete Library Group Видалити групу бібліотеки
|
||||
Move Library Item to Group Перемістити елемент бібліотеки в групу
|
||||
Library Бібліотека
|
||||
Rename Library Перейменувати бібліотеку
|
||||
Show Library Показати бібліотеку
|
||||
@@ -3864,3 +3881,4 @@ axis Both Обидві
|
||||
Even Fields Парні поля
|
||||
Duplication Дублюванням
|
||||
Interpolation Інтерполяцією
|
||||
@msg Imported library {name} Імпортовано бібліотеку {name}
|
||||
|
Can't render this file because it contains an unexpected character in line 659 and column 40.
|
@@ -2652,6 +2652,23 @@
|
||||
Add to Library 添加到库
|
||||
Delete Library 删除库
|
||||
Delete Library Item 删除库项目
|
||||
No results 无结果
|
||||
No items in this library match “{query}”. 此库中没有与“{query}”匹配的项目。
|
||||
Right-click an item to move it into this group. 右键单击项目可将其移到此组。
|
||||
New Group 新建组
|
||||
New Group… 新建组…
|
||||
Move to Group 移到组
|
||||
No Group 无组
|
||||
Rename Group… 重命名组…
|
||||
Delete Group 删除组
|
||||
Import Library… 导入库…
|
||||
Export Library… 导出库…
|
||||
Import Library 导入库
|
||||
Export Library 导出库
|
||||
New Library Group 新建库组
|
||||
Rename Library Group 重命名库组
|
||||
Delete Library Group 删除库组
|
||||
Move Library Item to Group 将库项目移到组
|
||||
Library 库
|
||||
Rename Library 重命名库
|
||||
Show Library 显示库
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -2738,6 +2738,23 @@
|
||||
Add to Library 加入資料庫
|
||||
Delete Library 刪除資料庫
|
||||
Delete Library Item 刪除資料庫項目
|
||||
No results 沒有結果
|
||||
No items in this library match “{query}”. 此資料庫中沒有符合「{query}」的項目。
|
||||
Right-click an item to move it into this group. 在項目上按一下右鍵,即可將其移至此群組。
|
||||
New Group 新增群組
|
||||
New Group… 新增群組…
|
||||
Move to Group 移至群組
|
||||
No Group 無群組
|
||||
Rename Group… 重新命名群組…
|
||||
Delete Group 刪除群組
|
||||
Import Library… 匯入資料庫…
|
||||
Export Library… 匯出資料庫…
|
||||
Import Library 匯入資料庫
|
||||
Export Library 匯出資料庫
|
||||
New Library Group 新增資料庫群組
|
||||
Rename Library Group 重新命名資料庫群組
|
||||
Delete Library Group 刪除資料庫群組
|
||||
Move Library Item to Group 將資料庫項目移至群組
|
||||
Library 資料庫
|
||||
Rename Library 重新命名資料庫
|
||||
Show Library 顯示資料庫
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -15,8 +15,8 @@ use crate::{FilePick, Services, VectorcraftApp, dialogs};
|
||||
const TEMPLATE_EXTS: &[&str] = &["vctemplate", "ait", "vectorcraft", "drawcraft"];
|
||||
|
||||
/// Open bytes of any readable format as a new document (templates open untitled); swatch and
|
||||
/// graphic style library files open in the library panel and flattener, PDF, print and perspective
|
||||
/// grid presets files are imported. → `document.open`'s result for a document opened now (its
|
||||
/// graphic style library files open in the library panel, Libraries panel files are added to it,
|
||||
/// and flattener, PDF, print and perspective grid presets files are imported. → `document.open`'s result for a document opened now (its
|
||||
/// `warnings` say what didn't come in as it was), else null.
|
||||
pub fn open_bytes(app: &mut VectorcraftApp, name: &str, bytes: &[u8], path: Option<String>) -> Result<Value, String> {
|
||||
let ext = fileio::extension(name);
|
||||
@@ -39,6 +39,9 @@ pub fn open_bytes(app: &mut VectorcraftApp, name: &str, bytes: &[u8], path: Opti
|
||||
app.status(format!("Imported {what}: {}", names.join(", ")));
|
||||
return Ok(Value::Null);
|
||||
}
|
||||
if vectorcraft_engine::cmd::library::LIBRARY_EXTS.contains(&ext.as_str()) {
|
||||
return crate::panels::libraries::import(app, json!({"data": String::from_utf8_lossy(bytes)})).map(|_| Value::Null);
|
||||
}
|
||||
let swatches = vectorcraft_engine::cmd::swatchlib::LIBRARY_EXTS.contains(&ext.as_str());
|
||||
if swatches || ext == vectorcraft_doc::style_libs::STYLES_EXT {
|
||||
let p = match path {
|
||||
|
||||
@@ -4,16 +4,20 @@
|
||||
//! 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.
|
||||
//!
|
||||
//! The search field filters the items by name; items can be moved into user-named groups, shown as
|
||||
//! collapsible sections after the ungrouped items; and the panel menu exports the library to a
|
||||
//! `.vclibrary` file and imports one as a new library (#926).
|
||||
|
||||
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 vectorcraft_engine::cmd::library::{ItemKind, LIBRARY_EXT, LIBRARY_EXTS, Library, LibraryGraphic};
|
||||
|
||||
use crate::theme::Tokens;
|
||||
use crate::widgets::{self, PanelDrag, menu_item};
|
||||
use crate::widgets::{self, PanelDrag, menu_item, menu_item_name};
|
||||
use crate::{VectorcraftApp, icons};
|
||||
|
||||
/// The size of a graphic's tile.
|
||||
@@ -21,20 +25,28 @@ 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).
|
||||
/// The current library: its id and a copy of it (the panel menu 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) {
|
||||
/// Report a failure in the status bar (a file dialog closed or still open says nothing).
|
||||
fn report<T>(app: &mut VectorcraftApp, r: Result<T, String>) {
|
||||
if let Err(e) = r
|
||||
&& e != "cancelled"
|
||||
{
|
||||
app.status(e);
|
||||
}
|
||||
}
|
||||
|
||||
/// Run `cmd`, reporting a failure in the status bar.
|
||||
fn run(app: &mut VectorcraftApp, cmd: &str, p: Value) {
|
||||
let r = app.run(cmd, p);
|
||||
report(app, r);
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
@@ -44,9 +56,46 @@ fn ask_name(app: &mut VectorcraftApp, rename: Option<&str>) {
|
||||
app.ui.dialog = Some(crate::state::Dialog::new("command", json!({ "__command": cmd, "__label": label, "name": name })));
|
||||
}
|
||||
|
||||
/// The name dialog for a new group of `library` (`items`: `[{kind, item}]` moved into it), or for
|
||||
/// renaming group `rename`.
|
||||
fn ask_group_name(app: &mut VectorcraftApp, library: &str, rename: Option<&str>, items: Value) {
|
||||
let (cmd, label, name, fixed) = match rename {
|
||||
Some(g) => ("library.renameGroup", "Rename Library Group", g, json!({ "library": library, "group": g })),
|
||||
None => ("library.createGroup", "New Library Group", "Group", json!({ "library": library, "items": items })),
|
||||
};
|
||||
let d = json!({ "__command": cmd, "__label": label, "name": name, "__params": fixed });
|
||||
app.ui.dialog = Some(crate::state::Dialog::new("command", d));
|
||||
}
|
||||
|
||||
/// What a click in the panel asks for, done once the list is drawn.
|
||||
enum Act {
|
||||
Run(&'static str, Value),
|
||||
/// The New Group dialog; the items (`[{kind, item}]`) go into the new group.
|
||||
NewGroup(Value),
|
||||
RenameGroup(String),
|
||||
}
|
||||
|
||||
/// What the item list draws from.
|
||||
struct Items<'a> {
|
||||
library: &'a str,
|
||||
lib: &'a Library,
|
||||
/// The group of each grouped item ([`Library::group_index`]).
|
||||
groups: HashMap<(ItemKind, &'a str), usize>,
|
||||
fill_active: bool,
|
||||
}
|
||||
|
||||
impl Items<'_> {
|
||||
fn group_of(&self, kind: ItemKind, key: &str) -> Option<usize> {
|
||||
self.groups.get(&(kind, key)).copied()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let Some((lib_id, lib)) = current(app) else {
|
||||
let fill_active = app.session.fill_active;
|
||||
let has_doc = app.session.active().is_some();
|
||||
let libs = &app.session.libraries;
|
||||
let Some((lib_id, lib)) = libs.current().and_then(|id| Some((id.to_string(), libs.get(id)?))) else {
|
||||
ui.add_space(16.0);
|
||||
ui.vertical_centered(|ui| {
|
||||
icons::icon(ui, "library", 40.0, t.text_dim);
|
||||
@@ -60,18 +109,27 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
});
|
||||
return;
|
||||
};
|
||||
let mut act: Option<Act> = None;
|
||||
// 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 }));
|
||||
let names: Vec<&str> = libs.all().iter().map(|(_, l)| l.name.as_str()).collect();
|
||||
if let Some((id, _)) = widgets::dropdown_names(ui, "lib-picker", &lib.name, &names, ui.available_width()).and_then(|i| libs.all().get(i)) {
|
||||
act = Some(Act::Run("library.setCurrent", json!({ "library": id })));
|
||||
}
|
||||
ui.add_space(4.0);
|
||||
let empty = lib.items().next().is_none() && lib.groups.is_empty();
|
||||
let query = if empty { String::new() } else { widgets::search_field(ui, egui::Id::new("library-search"), tl!("Search")) };
|
||||
let query = query.trim().to_lowercase();
|
||||
let searching = !query.is_empty();
|
||||
let shown = |name: &str| !searching || name.to_lowercase().contains(&query);
|
||||
let items = Items { library: &lib_id, lib, groups: lib.group_index(), fill_active };
|
||||
// The items shown in each section: the ungrouped ones first, then each group's.
|
||||
let mut counts = vec![0usize; lib.groups.len() + 1];
|
||||
for (kind, key, _) in lib.items().filter(|(_, _, name)| shown(name)) {
|
||||
if let Some(n) = counts.get_mut(items.group_of(kind, key).map_or(0, |i| i + 1)) {
|
||||
*n += 1;
|
||||
}
|
||||
}
|
||||
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());
|
||||
@@ -84,101 +142,53 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
);
|
||||
return ui.min_rect();
|
||||
}
|
||||
if searching && counts.iter().all(|n| *n == 0) {
|
||||
let body = crate::i18n::fmt(tl!("No items in this library match “{query}”."), &[("query", &query)]);
|
||||
super::empty_state(ui, "search", tl!("No results"), &body);
|
||||
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() {
|
||||
items.show(ui, &mut act, |kind, key, name| items.group_of(kind, key).is_none() && shown(name));
|
||||
for (i, g) in lib.groups.iter().enumerate() {
|
||||
let n = counts.get(i + 1).copied().unwrap_or_default();
|
||||
if searching && n == 0 {
|
||||
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();
|
||||
}
|
||||
});
|
||||
// Open unless closed; open while searching, to show what matched.
|
||||
let key = format!("library-group-closed:{lib_id}:{}", g.name);
|
||||
let closed: bool = super::pstate(ui.ctx(), &key);
|
||||
let open = searching || !closed;
|
||||
let header = widgets::section_toggle_name(ui, &g.name, open);
|
||||
ui.painter().text(
|
||||
header.rect.right_center() - vec2(6.0, 0.0),
|
||||
egui::Align2::RIGHT_CENTER,
|
||||
n.to_string(),
|
||||
egui::FontId::proportional(11.5),
|
||||
t.text_dim,
|
||||
);
|
||||
if header.clicked() && !searching {
|
||||
super::set_pstate(ui.ctx(), &key, !closed);
|
||||
}
|
||||
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();
|
||||
}
|
||||
});
|
||||
header.context_menu(|ui| {
|
||||
if menu_item(ui, "Rename Group…", true, false) {
|
||||
act = Some(Act::RenameGroup(g.name.clone()));
|
||||
ui.close();
|
||||
}
|
||||
if menu_item(ui, "Delete Group", true, false) {
|
||||
act = Some(Act::Run("library.deleteGroup", json!({ "group": g.name })));
|
||||
ui.close();
|
||||
}
|
||||
});
|
||||
if !open {
|
||||
continue;
|
||||
}
|
||||
ui.add_space(2.0);
|
||||
if n == 0 {
|
||||
ui.label(egui::RichText::new(tl!("Right-click an item to move it into this group.")).size(11.5).color(t.text_dim));
|
||||
ui.add_space(6.0);
|
||||
} else {
|
||||
items.show(ui, &mut act, |kind, key, name| items.group_of(kind, key) == Some(i) && shown(name));
|
||||
}
|
||||
}
|
||||
});
|
||||
ui.min_rect()
|
||||
@@ -186,7 +196,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
// 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 })));
|
||||
act = Some(Act::Run("library.add", json!({ "kind": "graphic", "ids": ids })));
|
||||
}
|
||||
if let Some(drag) = zone.dnd_hover_payload::<PanelDrag>()
|
||||
&& let PanelDrag::Paint { paint: Paint::Solid { color, .. }, .. } = &*drag
|
||||
@@ -194,21 +204,13 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
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 })));
|
||||
act = Some(Act::Run("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| {
|
||||
if widgets::icon_button(ui, "group", tl!("New Group"), false, 24.0).clicked() {
|
||||
act = Some(Act::NewGroup(json!([])));
|
||||
}
|
||||
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 [
|
||||
@@ -219,12 +221,154 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
("Paragraph Style", "paraStyle"),
|
||||
] {
|
||||
if menu_item(ui, label, true, false) {
|
||||
run(app, "library.add", json!({ "library": lib_id, "kind": kind }));
|
||||
act = Some(Act::Run("library.add", json!({ "kind": kind })));
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
match act {
|
||||
Some(Act::Run(cmd, mut p)) => {
|
||||
if cmd != "library.setCurrent" {
|
||||
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);
|
||||
}
|
||||
Some(Act::NewGroup(items)) => ask_group_name(app, &lib_id, None, items),
|
||||
Some(Act::RenameGroup(g)) => ask_group_name(app, &lib_id, Some(&g), Value::Null),
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
|
||||
impl Items<'_> {
|
||||
/// The colours, character and paragraph styles and graphics `keep` keeps (given the kind, key
|
||||
/// and name of each), each kind under its heading.
|
||||
fn show(&self, ui: &mut Ui, act: &mut Option<Act>, keep: impl Fn(ItemKind, &str, &str) -> bool) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let lib = self.lib;
|
||||
let colors: Vec<_> = lib.colors.iter().filter(|c| keep(ItemKind::Color, &c.name, &c.name)).collect();
|
||||
if !colors.is_empty() {
|
||||
heading(ui, tl!("Colors"));
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
ui.spacing_mut().item_spacing = vec2(4.0, 4.0);
|
||||
for c in 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() {
|
||||
let to = if self.fill_active { "fill" } else { "stroke" };
|
||||
*act = Some(Act::Run("library.use", json!({ "kind": "fillColor", "item": c.name, "to": to })));
|
||||
}
|
||||
resp.context_menu(|ui| self.item_menu(ui, act, ItemKind::Color, &c.name));
|
||||
}
|
||||
});
|
||||
ui.add_space(6.0);
|
||||
}
|
||||
for (kind, list, title) in
|
||||
[(ItemKind::CharStyle, &lib.char_styles, tl!("Character Styles")), (ItemKind::ParaStyle, &lib.para_styles, tl!("Paragraph Styles"))]
|
||||
{
|
||||
let list: Vec<_> = list.iter().filter(|s| keep(kind, &s.name, &s.name)).collect();
|
||||
if list.is_empty() {
|
||||
continue;
|
||||
}
|
||||
heading(ui, title);
|
||||
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);
|
||||
}
|
||||
let icon = if kind == ItemKind::ParaStyle { "pilcrow" } else { "type" };
|
||||
icons::paint(ui, icon, 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(Act::Run("library.use", json!({ "kind": kind_param(kind), "item": st.name })));
|
||||
}
|
||||
resp.context_menu(|ui| self.item_menu(ui, act, kind, &st.name));
|
||||
}
|
||||
ui.add_space(6.0);
|
||||
}
|
||||
let graphics: Vec<_> = lib.graphics.iter().filter(|g| keep(ItemKind::Graphic, &g.id, &g.name)).collect();
|
||||
if !graphics.is_empty() {
|
||||
heading(ui, tl!("Graphics"));
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
ui.spacing_mut().item_spacing = vec2(4.0, 4.0);
|
||||
for g in graphics {
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(TILE, TILE), Sense::click_and_drag());
|
||||
tile(ui, r, self.library, 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: self.library.to_string(), item: g.id.clone() });
|
||||
let resp = resp.on_hover_text(&g.name);
|
||||
let place = || Act::Run("library.use", json!({ "kind": "graphic", "item": g.id }));
|
||||
if resp.double_clicked() {
|
||||
*act = Some(place());
|
||||
}
|
||||
resp.context_menu(|ui| {
|
||||
if menu_item(ui, "Place", true, false) {
|
||||
*act = Some(place());
|
||||
ui.close();
|
||||
}
|
||||
self.item_menu(ui, act, ItemKind::Graphic, &g.id);
|
||||
});
|
||||
}
|
||||
});
|
||||
ui.add_space(6.0);
|
||||
}
|
||||
}
|
||||
|
||||
/// An item's context menu entries: Move to Group (No Group, the library's groups, New
|
||||
/// Group…) and Delete.
|
||||
fn item_menu(&self, ui: &mut Ui, act: &mut Option<Act>, kind: ItemKind, key: &str) {
|
||||
let at = self.group_of(kind, key);
|
||||
let item = json!({ "kind": kind_param(kind), "item": key });
|
||||
ui.menu_button(format!(" {}", tl!("Move to Group")), |ui| {
|
||||
if menu_item(ui, "No Group", true, at.is_none()) {
|
||||
if at.is_some() {
|
||||
*act = Some(Act::Run("library.moveItem", item.clone()));
|
||||
}
|
||||
ui.close();
|
||||
}
|
||||
for (i, g) in self.lib.groups.iter().enumerate() {
|
||||
if menu_item_name(ui, &g.name, true, at == Some(i)) {
|
||||
if at != Some(i) {
|
||||
let mut p = item.clone();
|
||||
p["group"] = json!(g.name);
|
||||
*act = Some(Act::Run("library.moveItem", p));
|
||||
}
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
ui.separator();
|
||||
if menu_item(ui, "New Group…", true, false) {
|
||||
*act = Some(Act::NewGroup(json!([item])));
|
||||
ui.close();
|
||||
}
|
||||
});
|
||||
if menu_item(ui, "Delete", true, false) {
|
||||
*act = Some(Act::Run("library.removeItem", item));
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `kind` as the `library.*` commands' `kind` param (a colour as a fill colour).
|
||||
fn kind_param(kind: ItemKind) -> &'static str {
|
||||
match kind {
|
||||
ItemKind::Graphic => "graphic",
|
||||
ItemKind::CharStyle => "charStyle",
|
||||
ItemKind::ParaStyle => "paraStyle",
|
||||
ItemKind::Color | ItemKind::Unknown => "fillColor",
|
||||
}
|
||||
}
|
||||
|
||||
/// A section heading in the list.
|
||||
@@ -235,7 +379,7 @@ fn heading(ui: &mut Ui, text: &str) {
|
||||
}
|
||||
|
||||
/// Graphic `g`'s thumbnail on white in `r`.
|
||||
fn tile(ui: &Ui, r: Rect, library: &str, g: &vectorcraft_engine::cmd::library::LibraryGraphic) {
|
||||
fn tile(ui: &Ui, r: Rect, library: &str, g: &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();
|
||||
@@ -246,7 +390,7 @@ fn tile(ui: &Ui, r: Rect, library: &str, g: &vectorcraft_engine::cmd::library::L
|
||||
}
|
||||
|
||||
/// 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> {
|
||||
fn thumbnail(ctx: &egui::Context, library: &str, g: &LibraryGraphic) -> Option<egui::TextureHandle> {
|
||||
thread_local! {
|
||||
static CACHE: crate::graphics::TexCache<HashMap<(String, String, usize), Option<egui::TextureHandle>>> = crate::graphics::TexCache::default();
|
||||
}
|
||||
@@ -278,7 +422,37 @@ pub(crate) fn chip(app: &VectorcraftApp, ui: &Ui, r: Rect, library: &str, item:
|
||||
}
|
||||
}
|
||||
|
||||
/// The panel's ≡ menu: Create New Library, Rename Library…, Delete Library.
|
||||
/// Import a library file (`library.import`'s params), saying so in the status bar.
|
||||
pub(crate) fn import(app: &mut VectorcraftApp, p: Value) -> Result<Value, String> {
|
||||
let r = app.run("library.import", p)?;
|
||||
let name = r["name"].as_str().unwrap_or_default().to_string();
|
||||
app.status(format!("Imported library {name}"));
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
/// Import Library…: a picked `.vclibrary` file becomes a new library (on the web the picked file
|
||||
/// arrives later through [`crate::io::open_bytes`]).
|
||||
fn import_picked(app: &mut VectorcraftApp) {
|
||||
if let Some(f) = app.services.open_async.as_mut() {
|
||||
f();
|
||||
return;
|
||||
}
|
||||
let pick = crate::FilePick { filters: vec![("Libraries", LIBRARY_EXTS)], ..Default::default() };
|
||||
if let Some(path) = crate::picks::open(app, &pick) {
|
||||
let r = import(app, json!({ "path": path }));
|
||||
report(app, r);
|
||||
}
|
||||
}
|
||||
|
||||
/// Export Library…: library `id` written to a picked file named after it (the web downloads it).
|
||||
fn export_picked(app: &mut VectorcraftApp, id: &str, name: &str) {
|
||||
let file = format!("{}.{LIBRARY_EXT}", vectorcraft_engine::cmd::library::file_stem_for(name));
|
||||
let r = crate::io::save_command_output_named(app, "library.export", &file, json!({ "library": id }));
|
||||
report(app, r);
|
||||
}
|
||||
|
||||
/// The panel's ≡ menu: Create New Library, Rename Library…, Delete Library, New Group…, Import
|
||||
/// Library…, Export Library….
|
||||
pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
let cur = current(app);
|
||||
if menu_item(ui, "Create New Library…", true, false) {
|
||||
@@ -304,6 +478,24 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
);
|
||||
ui.close();
|
||||
}
|
||||
ui.separator();
|
||||
if menu_item(ui, "New Group…", cur.is_some(), false)
|
||||
&& let Some((id, _)) = &cur
|
||||
{
|
||||
ask_group_name(app, id, None, json!([]));
|
||||
ui.close();
|
||||
}
|
||||
ui.separator();
|
||||
if menu_item(ui, "Import Library…", true, false) {
|
||||
crate::picks::button(app, import_picked);
|
||||
ui.close();
|
||||
}
|
||||
if menu_item(ui, "Export Library…", cur.is_some(), false)
|
||||
&& let Some((id, lib)) = cur
|
||||
{
|
||||
crate::picks::button(app, move |app| export_picked(app, &id, &lib.name));
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -315,6 +507,7 @@ mod tests {
|
||||
|
||||
fn frame(app: &mut VectorcraftApp) {
|
||||
let ctx = egui::Context::default();
|
||||
crate::theme::install_fonts(&ctx);
|
||||
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| {
|
||||
show(app, ui);
|
||||
menu(app, ui);
|
||||
@@ -345,4 +538,94 @@ mod tests {
|
||||
let ctx = egui::Context::default();
|
||||
assert!(thumbnail(&ctx, "Brand", &lib.graphics[0]).is_some());
|
||||
}
|
||||
|
||||
/// The panel's text: every label it shows in one frame (`query` typed in the search field).
|
||||
fn texts(app: &mut VectorcraftApp, query: &str) -> Vec<String> {
|
||||
let ctx = egui::Context::default();
|
||||
crate::theme::install_fonts(&ctx);
|
||||
ctx.data_mut(|d| d.insert_temp(egui::Id::new("library-search"), query.to_string()));
|
||||
let mut shapes = vec![];
|
||||
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| show(app, ui));
|
||||
out.textures_delta.clear();
|
||||
shapes.append(&mut out.shapes);
|
||||
let mut texts = vec![];
|
||||
for s in shapes {
|
||||
if let egui::epaint::Shape::Text(t) = s.shape {
|
||||
texts.push(t.galley.text().to_string());
|
||||
}
|
||||
}
|
||||
texts
|
||||
}
|
||||
|
||||
/// The search field filters items by name, ignoring case; groups show after the ungrouped
|
||||
/// items, and a search nothing matches says so (#926).
|
||||
#[test]
|
||||
fn the_panel_searches_and_shows_groups() {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.run("file.new", json!({"width": 200, "height": 200})).unwrap();
|
||||
app.run("library.create", json!({"name": "Brand"})).unwrap();
|
||||
for (name, color) in [("Ocean Blue", "#0044aa"), ("Sun Yellow", "#ffcc00")] {
|
||||
app.run("library.add", json!({"kind": "fillColor", "color": color, "name": name})).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", "name": "Headline"})).unwrap();
|
||||
app.run("library.createGroup", json!({"name": "Warm", "items": [{"kind": "fillColor", "item": "Sun Yellow"}]})).unwrap();
|
||||
let all = texts(&mut app, "");
|
||||
let at = |s: &str| all.iter().position(|t| t == s);
|
||||
assert!(at("Headline").is_some() && at("Warm").is_some(), "{all:?}");
|
||||
assert!(at("Headline") < at("Warm"), "groups come after the ungrouped items: {all:?}");
|
||||
// "BLUE" finds the blue swatch only: the style and the group without a match are hidden.
|
||||
let found = texts(&mut app, "BLUE");
|
||||
assert!(!found.iter().any(|t| t == "Headline" || t == "Warm"), "{found:?}");
|
||||
let found = texts(&mut app, "yellow");
|
||||
assert!(found.iter().any(|t| t == "Warm") && !found.iter().any(|t| t == "Headline"), "{found:?}");
|
||||
let none = texts(&mut app, "zzz");
|
||||
assert!(none.iter().any(|t| t == "No results"), "{none:?}");
|
||||
}
|
||||
|
||||
/// New Group… and Rename Group… ask for a name and act on the library and group they came
|
||||
/// from; the dialog shows only the name.
|
||||
#[test]
|
||||
fn group_dialogs_run_with_their_library_and_group() {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.run("file.new", json!({"width": 200, "height": 200})).unwrap();
|
||||
let lib = app.run("library.create", json!({"name": "Brand"})).unwrap()["id"].as_str().unwrap().to_string();
|
||||
app.run("library.add", json!({"kind": "fillColor", "color": "#0044aa", "name": "Blue"})).unwrap();
|
||||
ask_group_name(&mut app, &lib, None, json!([{"kind": "fillColor", "item": "Blue"}]));
|
||||
app.ui.dialog.as_mut().unwrap().fields.insert("name".into(), json!("Cool"));
|
||||
crate::dialogs::confirm(&mut app).unwrap();
|
||||
ask_group_name(&mut app, &lib, Some("Cool"), Value::Null);
|
||||
app.ui.dialog.as_mut().unwrap().fields.insert("name".into(), json!("Cold"));
|
||||
crate::dialogs::confirm(&mut app).unwrap();
|
||||
let got = app.run("library.get", json!({})).unwrap();
|
||||
assert_eq!(got["groups"], json!([{"name": "Cold", "items": [{"kind": "color", "item": "Blue"}]}]));
|
||||
}
|
||||
|
||||
/// Export Library… writes the library's file and Import Library… adds it back as a new library.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[test]
|
||||
fn libraries_are_exported_and_imported_from_the_panel_menu() {
|
||||
let dir = vectorcraft_testkit::temp_dir("libraries-panel");
|
||||
let path = dir.join("Brand.vclibrary").to_string_lossy().to_string();
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.run("file.new", json!({"width": 200, "height": 200})).unwrap();
|
||||
app.run("library.create", json!({"name": "Brand"})).unwrap();
|
||||
app.run("library.add", json!({"kind": "fillColor", "color": "#0044aa", "name": "Blue"})).unwrap();
|
||||
let picked = path.clone();
|
||||
app.services.pick_save = Some(Box::new(move |pick: &crate::FilePick| {
|
||||
assert_eq!(pick.name, "Brand.vclibrary", "the library's name suggested");
|
||||
Some(picked.clone())
|
||||
}));
|
||||
app.services.write = Some(Box::new(|p: &str, bytes: &[u8]| std::fs::write(p, bytes).map_err(|e| e.to_string())));
|
||||
export_picked(&mut app, "Brand", "Brand");
|
||||
assert!(std::path::Path::new(&path).exists());
|
||||
let picked = path.clone();
|
||||
app.services.pick_open = Some(Box::new(move |_: &crate::FilePick| Some(picked.clone())));
|
||||
import_picked(&mut app);
|
||||
let list = app.run("library.list", json!({})).unwrap();
|
||||
let names: Vec<&str> = list["libraries"].as_array().unwrap().iter().map(|l| l["name"].as_str().unwrap()).collect();
|
||||
assert_eq!(names, ["Brand", "Brand (2)"]);
|
||||
assert_eq!(app.ui.status, "Imported library Brand (2)");
|
||||
}
|
||||
}
|
||||
@@ -124,6 +124,12 @@ pub fn section_header(ui: &mut Ui, text: &str) {
|
||||
/// A collapsible section's header across the panel: a chevron (down while `open`) and `text`;
|
||||
/// a click anywhere on it toggles the section. Whether it was clicked.
|
||||
pub fn section_toggle(ui: &mut Ui, text: &str, open: bool) -> bool {
|
||||
section_toggle_name(ui, tl!(text), open).clicked()
|
||||
}
|
||||
|
||||
/// [`section_toggle`] for a name that is user data (a library group), shown as it is → its
|
||||
/// response (clicked: toggle; it takes a context menu).
|
||||
pub fn section_toggle_name(ui: &mut Ui, text: &str, open: bool) -> Response {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 24.0), Sense::click());
|
||||
if resp.hovered() {
|
||||
@@ -131,9 +137,9 @@ pub fn section_toggle(ui: &mut Ui, text: &str, open: bool) -> bool {
|
||||
}
|
||||
let chevron = Rect::from_center_size(pos2(rect.left() + 8.0, rect.center().y), vec2(12.0, 12.0));
|
||||
icons::paint(ui, if open { "chevron-down" } else { "chevron-right" }, chevron, if resp.hovered() { t.text_strong } else { t.icon });
|
||||
ui.painter().text(pos2(rect.left() + 18.0, rect.center().y), egui::Align2::LEFT_CENTER, tl!(text), theme::semibold(12.5), t.text);
|
||||
resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::CollapsingHeader, true, open, tl!(text)));
|
||||
resp.on_hover_cursor(egui::CursorIcon::PointingHand).clicked()
|
||||
ui.painter().text(pos2(rect.left() + 18.0, rect.center().y), egui::Align2::LEFT_CENTER, text, theme::semibold(12.5), t.text);
|
||||
resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::CollapsingHeader, true, open, text));
|
||||
resp.on_hover_cursor(egui::CursorIcon::PointingHand)
|
||||
}
|
||||
|
||||
/// Full-width 1 px divider with vertical margins.
|
||||
|
||||
+17
@@ -953,9 +953,26 @@ gives another colour merges into the document's), a colour paints the selection'
|
||||
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.
|
||||
|
||||
Items can be put in user-named groups, which the panel shows as collapsible sections after the ungrouped items (its
|
||||
search field filters every item by name; `library.get` lists them all to filter). `library.createGroup {library?,
|
||||
name?, items?: [{kind, item}]}` makes one (a name another group has, ignoring case, gets a number) with those items
|
||||
moved into it; `library.renameGroup {library?, group, name}` renames one; `library.deleteGroup {library?, group}`
|
||||
deletes one, its items staying in the library ungrouped; and `library.moveItem {library?, kind, item, group?}` moves
|
||||
an item into a group (without `group`, out of its group). An item is in one group at most; `library.get` lists the
|
||||
groups as `groups: [{name, items: [{kind: "color"|"charStyle"|"paraStyle"|"graphic", item}]}]` (a graphic by its id).
|
||||
|
||||
`library.export {library?, path?}` writes a library (its colours, styles, graphics with their art and thumbnails, and
|
||||
its groups) as a `.vclibrary` file, the JSON the library folder keeps (without `path`: `{data}`, the file's text).
|
||||
`library.import {path? | data? | dataBase64?, name?}` adds the library of such a file as a new library and makes it
|
||||
the current one; a name another library has gets a number (`Brand (2)`). Imported files are checked: at most 10,000
|
||||
items of each kind and 1,000 groups, names clipped and made unique, thumbnails kept only when they are small PNGs, and
|
||||
groups naming items that aren't there dropped. Files written before groups load as they are.
|
||||
|
||||
```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]}}}
|
||||
{"name":"run_command","arguments":{"command":"library.createGroup","params":{"name":"Logos","items":[{"kind":"graphic","item":"Logo"}]}}}
|
||||
{"name":"run_command","arguments":{"command":"library.export","params":{"path":"/tmp/Brand.vclibrary"}}}
|
||||
```
|
||||
|
||||
## Strokes on type
|
||||
|
||||
Reference in new issue
Block a user