mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-29 20:27:05 -06:00
M4.88: Object → Slice makes, edits and lays out user, object and auto slices
Document.slices holds user slices, Node.slice makes an object an object slice that follows its bounds, and Document.slices_clip_to_artboard picks the region auto slices fill (the artboards, or the art and the slices). Object → Slice: Make, Release, Create from Guides, Create from Selection, Duplicate, Combine, Divide… (dialog), Delete All, Slice Options… (dialog) and Clip to Artboard; View → Hide/Lock Slices; Select → Object → Slices; slice.list reads the numbered layout. The canvas draws the slices in the Slices preferences' line colour with their numbers. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
d767fa22e0
commit
271b0c6c25
@@ -28,6 +28,10 @@ pub struct MenuState {
|
||||
pub new_masks_inverted: bool,
|
||||
/// Magic Wand panel settings.
|
||||
pub wand: super::wand::WandSettings,
|
||||
/// View → Hide Slices.
|
||||
pub slices_hidden: bool,
|
||||
/// View → Lock Slices.
|
||||
pub slices_locked: bool,
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
|
||||
@@ -47,6 +47,7 @@ pub mod prefscmds;
|
||||
pub mod rasterfx;
|
||||
mod recolor;
|
||||
mod select;
|
||||
pub(crate) mod slices;
|
||||
mod stroke;
|
||||
mod style;
|
||||
pub mod stylelib;
|
||||
@@ -225,6 +226,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
|
||||
v.extend(pdfcmds::specs());
|
||||
v.extend(fileio::dxf::specs());
|
||||
v.extend(package::specs());
|
||||
v.extend(slices::specs());
|
||||
v
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,567 @@
|
||||
//! Object → Slice, View → Hide/Lock Slices and Select → Object → Slices.
|
||||
//!
|
||||
//! The model is in [`vectorcraft_doc::slices`]: user slices live in [`Document::slices`], object
|
||||
//! slices are objects with slice options, auto slices are laid out around them. The Object → Slice
|
||||
//! commands act on the selected slices: those the Slice Selection tool (or `object.slice.select`)
|
||||
//! selected and the selected objects that are object slices. Commands that take slice ids take
|
||||
//! user slice ids and the ids of objects with an object slice.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_color::Paint;
|
||||
use vectorcraft_doc::slices::valid_rect;
|
||||
use vectorcraft_doc::{Appearance, CellAlign, CellVAlign, Document, Node, NodeKind, Slice, SliceKind, SliceOptions};
|
||||
use vectorcraft_geom::{Rect, shapes};
|
||||
|
||||
use super::*;
|
||||
|
||||
/// The most slices one Divide Slices makes.
|
||||
const MAX_PIECES: usize = 1000;
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(
|
||||
"object.slice.make",
|
||||
"Make",
|
||||
["Object", "Slice"],
|
||||
None,
|
||||
"{ids?} make each selected object (or ids) an object slice: its slice follows the object's bounds → {ids: the new object slices}",
|
||||
has_selection,
|
||||
make
|
||||
),
|
||||
cmd!(
|
||||
"object.slice.release",
|
||||
"Release",
|
||||
["Object", "Slice"],
|
||||
None,
|
||||
"{} release the selected slices: an object slice leaves its object as it was, a user slice becomes an unpainted rectangle; the objects end up selected → {ids}",
|
||||
slices_targeted,
|
||||
release
|
||||
),
|
||||
cmd!(
|
||||
"object.slice.fromGuides",
|
||||
"Create from Guides",
|
||||
["Object", "Slice"],
|
||||
None,
|
||||
"{} replace the user slices with the grid the ruler guides cut the artboards into (two vertical and two horizontal guides give 9) → {ids}",
|
||||
has_doc,
|
||||
from_guides
|
||||
),
|
||||
cmd!(
|
||||
"object.slice.fromSelection",
|
||||
"Create from Selection",
|
||||
["Object", "Slice"],
|
||||
None,
|
||||
"{ids?} a user slice over the visual bounds of the selected objects (or ids), selected → {id}",
|
||||
has_selection,
|
||||
from_selection
|
||||
),
|
||||
cmd!(
|
||||
"object.slice.duplicate",
|
||||
"Duplicate Slice",
|
||||
["Object", "Slice"],
|
||||
None,
|
||||
"{dx?: pt (default 10), dy?: pt (default 10)} copy the selected slices as user slices with their options, offset by dx, dy; the copies are selected → {ids}",
|
||||
slices_targeted,
|
||||
duplicate
|
||||
),
|
||||
cmd!(
|
||||
"object.slice.combine",
|
||||
"Combine Slices",
|
||||
["Object", "Slice"],
|
||||
None,
|
||||
"{} replace the two or more selected slices with one user slice over their bounds (the first one's options; object slices are released) → {id}",
|
||||
slices_targeted,
|
||||
combine
|
||||
),
|
||||
cmd!(
|
||||
"object.slice.divide",
|
||||
"Divide Slices…",
|
||||
["Object", "Slice"],
|
||||
None,
|
||||
"{rows?: slices down, rowHeight?: pt per slice, columns?: slices across, columnWidth?: pt per slice} cut each selected slice into a grid of user slices (evenly by count, or by size with a smaller last one; an object slice is released); the pieces are selected → {ids}",
|
||||
slices_targeted,
|
||||
divide
|
||||
),
|
||||
cmd!(
|
||||
"object.slice.deleteAll",
|
||||
"Delete All",
|
||||
["Object", "Slice"],
|
||||
None,
|
||||
"{} delete every user slice and release every object slice → {count}",
|
||||
doc_has_slices,
|
||||
delete_all
|
||||
),
|
||||
cmd!(
|
||||
"object.slice.options",
|
||||
"Slice Options…",
|
||||
["Object", "Slice"],
|
||||
None,
|
||||
"{kind?: \"image\"|\"noImage\"|\"htmlText\" (object slices of type only), name?, url?, target?, message?, alt?: (image), text?: cell text, HTML allowed (noImage), background?: \"\"|\"matte\"|\"#rrggbb\", hAlign?: \"default\"|\"left\"|\"center\"|\"right\", vAlign?: \"default\"|\"top\"|\"middle\"|\"baseline\"|\"bottom\"} set the selected slices' options as one undo step; no options: read them → the first slice's {kind, name, url, target, message, alt, text, background, hAlign, vAlign, htmlText: whether HTML Text applies}",
|
||||
slices_targeted,
|
||||
options
|
||||
),
|
||||
cmd!(
|
||||
"object.slice.clipToArtboard",
|
||||
"Clip to Artboard",
|
||||
["Object", "Slice"],
|
||||
None,
|
||||
"{on?: bool} toggle (or set) Clip to Artboard: on, slices are clipped to the artboards and auto slices fill them; off, auto slices cover the art and the slices → {on}",
|
||||
has_doc,
|
||||
clip_to_artboard
|
||||
),
|
||||
cmd!(query "view.slices.hide", "Hide Slices", ["View"], None, "{hidden?: bool} toggle (or set) whether the canvas hides the slices (session view state) → {hidden}", has_doc, hide),
|
||||
cmd!(
|
||||
query "view.slices.lock",
|
||||
"Lock Slices",
|
||||
["View"],
|
||||
None,
|
||||
"{locked?: bool} toggle (or set) the slice lock: locked slices can't be selected or edited with the Slice Selection tool → {locked}",
|
||||
has_doc,
|
||||
lock
|
||||
),
|
||||
cmd!(
|
||||
query "slice.list",
|
||||
"List Slices",
|
||||
[],
|
||||
None,
|
||||
"{} the slices as laid out, numbered left to right and top to bottom → {slices: [{number, id (null for auto slices), source: \"user\"|\"object\"|\"auto\", name, x, y, width, height, options, selected}], clipToArtboard, hidden, locked}",
|
||||
has_doc,
|
||||
list
|
||||
),
|
||||
cmd!("select.object.slices", "Slices", ["Select", "Object"], None, "{} select every user and object slice → {count}", has_doc, select_all),
|
||||
]
|
||||
}
|
||||
|
||||
impl Session {
|
||||
/// View → Hide Slices.
|
||||
pub fn slices_hidden(&self) -> bool {
|
||||
self.menu.slices_hidden
|
||||
}
|
||||
/// View → Lock Slices.
|
||||
pub fn slices_locked(&self) -> bool {
|
||||
self.menu.slices_locked
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- targets and params ----------
|
||||
|
||||
/// The selected slices: those the Slice Selection tool selected and the selected objects that are
|
||||
/// object slices.
|
||||
fn selected(s: &Session) -> Result<Vec<NodeId>> {
|
||||
let st = s.doc()?;
|
||||
Ok(selected_slices(&st.doc, &st.selection))
|
||||
}
|
||||
|
||||
fn selected_slices(d: &Document, sel: &vectorcraft_doc::Selection) -> Vec<NodeId> {
|
||||
let objects = sel.objects.iter().filter(|id| d.node(**id).is_some_and(|n| n.slice.is_some()));
|
||||
dedup(sel.slices.iter().chain(objects).copied().collect())
|
||||
}
|
||||
|
||||
fn dedup(mut ids: Vec<NodeId>) -> Vec<NodeId> {
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
ids.retain(|id| seen.insert(*id));
|
||||
ids
|
||||
}
|
||||
|
||||
fn slices_targeted(s: &Session) -> std::result::Result<(), String> {
|
||||
let st = s.active().ok_or("no document open")?;
|
||||
if selected_slices(&st.doc, &st.selection).is_empty() { Err("no slice selected".into()) } else { Ok(()) }
|
||||
}
|
||||
|
||||
fn doc_has_slices(s: &Session) -> std::result::Result<(), String> {
|
||||
let st = s.active().ok_or("no document open")?;
|
||||
if st.doc.slices.is_empty() && object_slice_ids(&st.doc).is_empty() { Err("the document has no slices".into()) } else { Ok(()) }
|
||||
}
|
||||
|
||||
/// Every object with slice options, hidden ones included.
|
||||
fn object_slice_ids(d: &Document) -> Vec<NodeId> {
|
||||
let mut ids = vec![];
|
||||
d.walk(|n| {
|
||||
if n.slice.is_some() {
|
||||
ids.push(n.id);
|
||||
}
|
||||
});
|
||||
ids
|
||||
}
|
||||
|
||||
/// A rectangle a slice can have: a size, and coordinates on the canvas.
|
||||
pub(crate) fn slice_rect_ok(r: Rect) -> bool {
|
||||
valid_rect(r) && [r.x0, r.y0, r.x1, r.y1].iter().all(|v| v.abs() <= crate::MAX_COORD)
|
||||
}
|
||||
|
||||
/// A new user slice over `rect` with `options`.
|
||||
pub(crate) fn add_slice(d: &mut Document, rect: Rect, options: SliceOptions) -> NodeId {
|
||||
let id = d.alloc_id();
|
||||
d.slices.push(Slice { id, rect, options });
|
||||
id
|
||||
}
|
||||
|
||||
/// Remove slice `id`: a user slice goes, an object slice's object loses its slice options.
|
||||
pub(crate) fn remove_slice(d: &mut Document, id: NodeId) {
|
||||
let before = d.slices.len();
|
||||
d.slices.retain(|s| s.id != id);
|
||||
if d.slices.len() == before
|
||||
&& let Some(n) = d.node_mut(id)
|
||||
{
|
||||
n.slice = None;
|
||||
}
|
||||
}
|
||||
|
||||
fn ids_json(ids: &[NodeId]) -> Value {
|
||||
json!(ids.iter().map(|i| i.0).collect::<Vec<_>>())
|
||||
}
|
||||
|
||||
/// Slice options as the commands give them back (every field).
|
||||
pub(crate) fn options_json(o: &SliceOptions) -> Value {
|
||||
json!({
|
||||
"kind": o.kind.key(), "name": o.name, "url": o.url, "target": o.target, "message": o.message, "alt": o.alt,
|
||||
"text": o.text, "background": o.background, "hAlign": o.h_align.key(), "vAlign": o.v_align.key(),
|
||||
})
|
||||
}
|
||||
|
||||
// ---------- Object → Slice ----------
|
||||
|
||||
fn make(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ids = match ids_param(p, "ids") {
|
||||
Some(ids) => ids,
|
||||
None => super::edit::selected_roots(s)?,
|
||||
};
|
||||
let d = &s.doc()?.doc;
|
||||
let made: Vec<NodeId> = ids.into_iter().filter(|id| d.node(*id).is_some_and(|n| !n.is_layer() && n.slice.is_none())).collect();
|
||||
if made.is_empty() {
|
||||
return Ok(json!({ "ids": [] }));
|
||||
}
|
||||
s.edit("Make Slice", |d, _| {
|
||||
for id in &made {
|
||||
if let Some(n) = d.node_mut(*id) {
|
||||
n.slice = Some(Box::default());
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(json!({ "ids": ids_json(&made) }))
|
||||
}
|
||||
|
||||
fn release(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let targets = selected(s)?;
|
||||
let parent = s.doc()?.insertion_parent();
|
||||
let ids = s.edit("Release Slice", |d, sel| {
|
||||
let mut out = vec![];
|
||||
for id in &targets {
|
||||
match d.slice(*id).map(|s| s.rect) {
|
||||
// A user slice becomes an unpainted rectangle.
|
||||
Some(r) => {
|
||||
d.slices.retain(|s| s.id != *id);
|
||||
let parent = parent.ok_or_else(|| EngineError::Other("Release: no layer to put the rectangle in".into()))?;
|
||||
let rid = d.alloc_id();
|
||||
d.insert(Some(parent), usize::MAX, Node::path(rid, shapes::rectangle(r), Appearance::basic(Paint::None, Paint::None, 1.0)))?;
|
||||
out.push(rid);
|
||||
}
|
||||
None => {
|
||||
remove_slice(d, *id);
|
||||
out.push(*id);
|
||||
}
|
||||
}
|
||||
}
|
||||
sel.set(out.iter().copied());
|
||||
Ok(out)
|
||||
})?;
|
||||
Ok(json!({ "ids": ids_json(&ids) }))
|
||||
}
|
||||
|
||||
fn from_guides(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let d = &s.doc()?.doc;
|
||||
let region = d.slice_clip().ok_or_else(|| EngineError::Other("Create from Guides: the document has no artboard".into()))?;
|
||||
let cuts = |vertical: bool, lo: f64, hi: f64| -> Vec<f64> {
|
||||
let mut v: Vec<f64> = d.guides.iter().filter(|g| g.vertical == vertical && g.pos > lo && g.pos < hi).map(|g| g.pos).collect();
|
||||
v.extend([lo, hi]);
|
||||
v.sort_by(f64::total_cmp);
|
||||
v.dedup();
|
||||
v
|
||||
};
|
||||
let (xs, ys) = (cuts(true, region.x0, region.x1), cuts(false, region.y0, region.y1));
|
||||
let rects: Vec<Rect> =
|
||||
ys.windows(2).flat_map(|y| xs.windows(2).map(move |x| Rect::new(x[0], y[0], x[1], y[1]))).filter(|r| valid_rect(*r)).collect();
|
||||
if rects.len() > MAX_PIECES {
|
||||
return Err(EngineError::Other(format!("Create from Guides: the guides make more than {MAX_PIECES} slices")));
|
||||
}
|
||||
let ids = s.edit("Create Slices from Guides", |d, _| {
|
||||
d.slices.clear();
|
||||
Ok(rects.iter().map(|r| add_slice(d, *r, SliceOptions::default())).collect::<Vec<_>>())
|
||||
})?;
|
||||
Ok(json!({ "ids": ids_json(&ids) }))
|
||||
}
|
||||
|
||||
fn from_selection(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ids = targets(s, p)?;
|
||||
let r = s.doc()?.doc.bounds_of(&ids, true).filter(|r| slice_rect_ok(*r));
|
||||
let r = r.ok_or_else(|| EngineError::Other("Create from Selection: the selection has no area".into()))?;
|
||||
let id = s.edit("Create Slice from Selection", |d, sel| {
|
||||
let id = add_slice(d, r, SliceOptions::default());
|
||||
sel.set_slices([id]);
|
||||
Ok(id)
|
||||
})?;
|
||||
Ok(json!({ "id": id.0 }))
|
||||
}
|
||||
|
||||
fn duplicate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let targets = selected(s)?;
|
||||
let (dx, dy) = (f64_or(p, "dx", 10.0), f64_or(p, "dy", 10.0));
|
||||
let ids = s.edit("Duplicate Slice", |d, sel| {
|
||||
let mut out = vec![];
|
||||
for id in &targets {
|
||||
let (Some(r), Some(o)) = (d.slice_bounds(*id), d.slice_options(*id).cloned()) else { continue };
|
||||
let r = r + vectorcraft_geom::Vec2::new(dx, dy);
|
||||
if !slice_rect_ok(r) {
|
||||
return Err(bad("object.slice.duplicate", "the copy would leave the canvas"));
|
||||
}
|
||||
out.push(add_slice(d, r, o));
|
||||
}
|
||||
sel.set_slices(out.iter().copied());
|
||||
Ok(out)
|
||||
})?;
|
||||
Ok(json!({ "ids": ids_json(&ids) }))
|
||||
}
|
||||
|
||||
fn combine(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let targets = selected(s)?;
|
||||
if targets.len() < 2 {
|
||||
return Err(bad("object.slice.combine", "select two or more slices to combine"));
|
||||
}
|
||||
let id = s.edit("Combine Slices", |d, sel| {
|
||||
let r = targets.iter().filter_map(|id| d.slice_bounds(*id)).reduce(|a, b| a.union(b));
|
||||
let r = r.ok_or_else(|| EngineError::Other("Combine Slices: the slices have no bounds".into()))?;
|
||||
let o = targets.first().and_then(|id| d.slice_options(*id)).cloned().unwrap_or_default();
|
||||
for id in &targets {
|
||||
remove_slice(d, *id);
|
||||
}
|
||||
let id = add_slice(d, r, o);
|
||||
sel.set_slices([id]);
|
||||
Ok(id)
|
||||
})?;
|
||||
Ok(json!({ "id": id.0 }))
|
||||
}
|
||||
|
||||
/// The cuts that split `lo..hi` into `count` equal parts or parts `size` long (the last one
|
||||
/// shorter), whichever `count`/`size` gives; `None` for neither.
|
||||
fn splits(lo: f64, hi: f64, count: Option<u64>, size: Option<f64>) -> std::result::Result<Vec<f64>, String> {
|
||||
let len = hi - lo;
|
||||
let n = match (count, size) {
|
||||
(Some(n), _) => n,
|
||||
(None, Some(sz)) if sz.is_finite() && sz > 0.0 => (len / sz - 1e-9).ceil().max(1.0).min(MAX_PIECES as f64 + 1.0) as u64,
|
||||
(None, Some(_)) => return Err("a slice size must be a positive number of points".into()),
|
||||
(None, None) => 1,
|
||||
};
|
||||
if n == 0 || n as usize > MAX_PIECES {
|
||||
return Err(format!("divide into 1 to {MAX_PIECES} slices"));
|
||||
}
|
||||
let step = match (count, size) {
|
||||
(None, Some(sz)) => sz,
|
||||
_ => len / n as f64,
|
||||
};
|
||||
Ok((0..=n).map(|i| if i == n { hi } else { lo + step * i as f64 }).collect())
|
||||
}
|
||||
|
||||
fn divide(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "object.slice.divide";
|
||||
let targets = selected(s)?;
|
||||
let count = |k: &str| p.get(k).map(|v| v.as_u64().ok_or_else(|| bad(C, format!("`{k}` must be a whole number")))).transpose();
|
||||
let size = |k: &str| p.get(k).map(|v| v.as_f64().ok_or_else(|| bad(C, format!("`{k}` must be a number of points")))).transpose();
|
||||
let (rows, row_h, cols, col_w) = (count("rows")?, size("rowHeight")?, count("columns")?, size("columnWidth")?);
|
||||
if rows.is_none() && row_h.is_none() && cols.is_none() && col_w.is_none() {
|
||||
return Err(bad(C, "give rows, rowHeight, columns or columnWidth"));
|
||||
}
|
||||
let ids = s.edit("Divide Slices", |d, sel| {
|
||||
let mut out = vec![];
|
||||
for id in &targets {
|
||||
let (Some(r), Some(o)) = (d.slice_bounds(*id), d.slice_options(*id).cloned()) else { continue };
|
||||
let ys = splits(r.y0, r.y1, rows, row_h).map_err(|e| bad(C, e))?;
|
||||
let xs = splits(r.x0, r.x1, cols, col_w).map_err(|e| bad(C, e))?;
|
||||
if (ys.len() - 1) * (xs.len() - 1) + out.len() > MAX_PIECES {
|
||||
return Err(bad(C, format!("that makes more than {MAX_PIECES} slices")));
|
||||
}
|
||||
remove_slice(d, *id);
|
||||
for (i, rect) in ys.windows(2).flat_map(|y| xs.windows(2).map(move |x| Rect::new(x[0], y[0], x[1], y[1]))).enumerate() {
|
||||
// The pieces keep the options; only the first keeps the name.
|
||||
let o = if i == 0 { o.clone() } else { SliceOptions { name: String::new(), ..o.clone() } };
|
||||
out.push(add_slice(d, rect, o));
|
||||
}
|
||||
}
|
||||
sel.set_slices(out.iter().copied());
|
||||
Ok(out)
|
||||
})?;
|
||||
Ok(json!({ "ids": ids_json(&ids) }))
|
||||
}
|
||||
|
||||
fn delete_all(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let n = s.edit("Delete All Slices", |d, _| {
|
||||
let objects = object_slice_ids(d);
|
||||
let n = d.slices.len() + objects.len();
|
||||
d.slices.clear();
|
||||
for id in objects {
|
||||
remove_slice(d, id);
|
||||
}
|
||||
Ok(n)
|
||||
})?;
|
||||
Ok(json!({ "count": n }))
|
||||
}
|
||||
|
||||
/// The Slice Options edits a command's params ask for.
|
||||
#[derive(Default)]
|
||||
struct Edits {
|
||||
kind: Option<SliceKind>,
|
||||
text: Vec<(&'static str, String)>,
|
||||
background: Option<String>,
|
||||
h_align: Option<CellAlign>,
|
||||
v_align: Option<CellVAlign>,
|
||||
}
|
||||
|
||||
impl Edits {
|
||||
const TEXT: [&'static str; 6] = ["name", "url", "target", "message", "alt", "text"];
|
||||
|
||||
fn parse(p: &Value) -> Result<Self> {
|
||||
const C: &str = "object.slice.options";
|
||||
let text = |k: &str| -> Result<Option<String>> {
|
||||
p.get(k).map(|v| v.as_str().map(str::to_string).ok_or_else(|| bad(C, format!("`{k}` must be a string")))).transpose()
|
||||
};
|
||||
let named = |k: &str| text(k).map(|v| v.map(|s| s.trim().to_string()));
|
||||
let mut e = Edits {
|
||||
kind: named("kind")?.map(|k| SliceKind::parse(&k).ok_or_else(|| bad(C, "`kind` must be image, noImage or htmlText"))).transpose()?,
|
||||
h_align: named("hAlign")?
|
||||
.map(|k| CellAlign::parse(&k).ok_or_else(|| bad(C, "`hAlign` must be default, left, center or right")))
|
||||
.transpose()?,
|
||||
v_align: named("vAlign")?
|
||||
.map(|k| CellVAlign::parse(&k).ok_or_else(|| bad(C, "`vAlign` must be default, top, middle, baseline or bottom")))
|
||||
.transpose()?,
|
||||
..Default::default()
|
||||
};
|
||||
e.background = named("background")?
|
||||
.map(|b| match b.to_ascii_lowercase().as_str() {
|
||||
"" | "none" => Ok(String::new()),
|
||||
"matte" => Ok("matte".to_string()),
|
||||
_ => vectorcraft_color::Color::from_hex(&b)
|
||||
.map(|c| c.to_hex())
|
||||
.ok_or_else(|| bad(C, "`background` must be \"\", \"matte\" or #rrggbb")),
|
||||
})
|
||||
.transpose()?;
|
||||
for k in Self::TEXT {
|
||||
// Names, links and frames are trimmed; the cell text and alt text keep their spaces.
|
||||
let v = if matches!(k, "text" | "alt" | "message") { text(k)? } else { named(k)? };
|
||||
if let Some(v) = v {
|
||||
e.text.push((k, v));
|
||||
}
|
||||
}
|
||||
Ok(e)
|
||||
}
|
||||
|
||||
fn is_empty(&self) -> bool {
|
||||
self.kind.is_none() && self.text.is_empty() && self.background.is_none() && self.h_align.is_none() && self.v_align.is_none()
|
||||
}
|
||||
|
||||
fn apply(&self, o: &mut SliceOptions) {
|
||||
if let Some(k) = self.kind {
|
||||
o.kind = k;
|
||||
}
|
||||
for (k, v) in &self.text {
|
||||
let field = match *k {
|
||||
"name" => &mut o.name,
|
||||
"url" => &mut o.url,
|
||||
"target" => &mut o.target,
|
||||
"message" => &mut o.message,
|
||||
"alt" => &mut o.alt,
|
||||
_ => &mut o.text,
|
||||
};
|
||||
field.clone_from(v);
|
||||
}
|
||||
if let Some(b) = &self.background {
|
||||
o.background.clone_from(b);
|
||||
}
|
||||
if let Some(a) = self.h_align {
|
||||
o.h_align = a;
|
||||
}
|
||||
if let Some(a) = self.v_align {
|
||||
o.v_align = a;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Can slice `id` be HTML Text: an object slice of a type object?
|
||||
fn html_text_allowed(d: &Document, id: NodeId) -> bool {
|
||||
d.slice(id).is_none() && d.node(id).is_some_and(|n| matches!(n.kind, NodeKind::Text(_)))
|
||||
}
|
||||
|
||||
fn options(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let targets = selected(s)?;
|
||||
let edits = Edits::parse(p)?;
|
||||
let html_ok = |d: &Document| targets.iter().all(|id| html_text_allowed(d, *id));
|
||||
if edits.kind == Some(SliceKind::HtmlText) && !html_ok(&s.doc()?.doc) {
|
||||
return Err(bad("object.slice.options", "HTML Text applies to object slices of type only"));
|
||||
}
|
||||
if !edits.is_empty() {
|
||||
s.edit("Slice Options", |d, _| {
|
||||
for id in &targets {
|
||||
let o = match d.slice_mut(*id) {
|
||||
Some(sl) => Some(&mut sl.options),
|
||||
None => d.node_mut(*id).and_then(|n| n.slice.as_deref_mut()),
|
||||
};
|
||||
if let Some(o) = o {
|
||||
edits.apply(o);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
}
|
||||
let d = &s.doc()?.doc;
|
||||
let o = targets.first().and_then(|id| d.slice_options(*id)).cloned().unwrap_or_default();
|
||||
let mut v = options_json(&o);
|
||||
v["htmlText"] = json!(html_ok(d));
|
||||
Ok(v)
|
||||
}
|
||||
|
||||
fn clip_to_artboard(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let on = p.get("on").and_then(Value::as_bool).unwrap_or(!s.doc()?.doc.slices_clip_to_artboard);
|
||||
if on != s.doc()?.doc.slices_clip_to_artboard {
|
||||
s.edit("Clip to Artboard", |d, _| {
|
||||
d.slices_clip_to_artboard = on;
|
||||
Ok(())
|
||||
})?;
|
||||
}
|
||||
Ok(json!({ "on": on }))
|
||||
}
|
||||
|
||||
fn hide(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let v = p.get("hidden").and_then(Value::as_bool).unwrap_or(!s.menu.slices_hidden);
|
||||
s.menu.slices_hidden = v;
|
||||
Ok(json!({ "hidden": v }))
|
||||
}
|
||||
|
||||
fn lock(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let v = p.get("locked").and_then(Value::as_bool).unwrap_or(!s.menu.slices_locked);
|
||||
s.menu.slices_locked = v;
|
||||
Ok(json!({ "locked": v }))
|
||||
}
|
||||
|
||||
fn list(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let st = s.doc()?;
|
||||
let d = &st.doc;
|
||||
let slices: Vec<Value> = d
|
||||
.slice_layout()
|
||||
.iter()
|
||||
.map(|a| {
|
||||
let o = a.id.and_then(|id| d.slice_options(id)).cloned().unwrap_or_default();
|
||||
json!({
|
||||
"number": a.number, "id": a.id.map(|i| i.0), "source": a.source.key(), "name": d.slice_name(a),
|
||||
"x": a.rect.x0, "y": a.rect.y0, "width": a.rect.width(), "height": a.rect.height(),
|
||||
"options": options_json(&o), "selected": a.id.is_some_and(|id| st.selection.slices.contains(&id)),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
Ok(json!({ "slices": slices, "clipToArtboard": d.slices_clip_to_artboard, "hidden": s.menu.slices_hidden, "locked": s.menu.slices_locked }))
|
||||
}
|
||||
|
||||
fn select_all(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let ids = s.doc()?.doc.slice_ids();
|
||||
let n = ids.len();
|
||||
s.select(|_, sel| sel.set_slices(ids))?;
|
||||
Ok(json!({ "count": n }))
|
||||
}
|
||||
@@ -1116,6 +1116,8 @@ mod tests_save;
|
||||
#[cfg(test)]
|
||||
mod tests_scalestrokes;
|
||||
#[cfg(test)]
|
||||
mod tests_slices;
|
||||
#[cfg(test)]
|
||||
mod tests_strokegeom;
|
||||
#[cfg(test)]
|
||||
mod tests_strokegradient;
|
||||
|
||||
@@ -0,0 +1,252 @@
|
||||
//! Object → Slice, View → Hide/Lock Slices, Select → Object → Slices and `slice.list`.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::SliceKind;
|
||||
|
||||
use super::*;
|
||||
|
||||
fn session() -> Session {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 300, "height": 300})).unwrap();
|
||||
// Unstroked art: visual bounds are the geometry.
|
||||
s.paint.stroke = vectorcraft_color::Paint::None;
|
||||
s
|
||||
}
|
||||
|
||||
fn run(s: &mut Session, id: &str, p: Value) -> Value {
|
||||
s.execute(id, &p).unwrap_or_else(|e| panic!("{id}: {e}"))
|
||||
}
|
||||
|
||||
fn rect(s: &mut Session, x: f64, y: f64, w: f64, h: f64) -> u64 {
|
||||
run(s, "shape.rectangle", json!({"x": x, "y": y, "width": w, "height": h}))["id"].as_u64().unwrap()
|
||||
}
|
||||
|
||||
/// `slice.list`'s slices.
|
||||
fn slices(s: &mut Session) -> Vec<Value> {
|
||||
run(s, "slice.list", json!({}))["slices"].as_array().unwrap().clone()
|
||||
}
|
||||
|
||||
/// The laid-out rectangle of slice `id` as [x, y, width, height].
|
||||
fn rect_of(s: &mut Session, id: u64) -> [f64; 4] {
|
||||
let v = slices(s).into_iter().find(|v| v["id"] == json!(id)).unwrap();
|
||||
["x", "y", "width", "height"].map(|k| v[k].as_f64().unwrap())
|
||||
}
|
||||
|
||||
fn user_count(s: &Session) -> usize {
|
||||
s.doc().unwrap().doc.slices.len()
|
||||
}
|
||||
|
||||
/// A user slice over `[x, y, w, h]`, made from a temporary rectangle.
|
||||
fn user_slice(s: &mut Session, r: [f64; 4]) -> u64 {
|
||||
let tmp = rect(s, r[0], r[1], r[2], r[3]);
|
||||
let id = run(s, "object.slice.fromSelection", json!({"ids": [tmp]}))["id"].as_u64().unwrap();
|
||||
run(s, "select.set", json!({"ids": [tmp]}));
|
||||
run(s, "edit.clear", json!({}));
|
||||
id
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_object_slice_follows_its_object_and_releases_back_to_it() {
|
||||
let mut s = session();
|
||||
let a = rect(&mut s, 100.0, 100.0, 50.0, 40.0);
|
||||
let made = run(&mut s, "object.slice.make", json!({}));
|
||||
assert_eq!(made["ids"], json!([a]));
|
||||
assert_eq!(rect_of(&mut s, a), [100.0, 100.0, 50.0, 40.0]);
|
||||
let list = slices(&mut s);
|
||||
assert!(list.iter().any(|v| v["source"] == "auto"), "auto slices fill the artboard");
|
||||
let area: f64 = list.iter().map(|v| v["width"].as_f64().unwrap() * v["height"].as_f64().unwrap()).sum();
|
||||
assert_eq!(area, 300.0 * 300.0);
|
||||
// Moving the object moves its slice; one undo step brings it back.
|
||||
run(&mut s, "object.move", json!({"dx": 20, "dy": 10}));
|
||||
assert_eq!(rect_of(&mut s, a), [120.0, 110.0, 50.0, 40.0]);
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(rect_of(&mut s, a), [100.0, 100.0, 50.0, 40.0]);
|
||||
// Make again changes nothing; Release leaves the object.
|
||||
assert_eq!(run(&mut s, "object.slice.make", json!({}))["ids"], json!([]));
|
||||
assert_eq!(run(&mut s, "object.slice.release", json!({}))["ids"], json!([a]));
|
||||
assert!(s.doc().unwrap().doc.node(NodeId(a)).unwrap().slice.is_none());
|
||||
assert!(slices(&mut s).is_empty());
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert!(s.doc().unwrap().doc.node(NodeId(a)).unwrap().slice.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn releasing_a_user_slice_leaves_an_unpainted_rectangle() {
|
||||
let mut s = session();
|
||||
let id = user_slice(&mut s, [10.0, 20.0, 30.0, 40.0]);
|
||||
run(&mut s, "select.object.slices", json!({}));
|
||||
let r = run(&mut s, "object.slice.release", json!({}));
|
||||
assert_eq!(s.doc().unwrap().doc.slice(NodeId(id)), None);
|
||||
let rid = r["ids"][0].as_u64().unwrap();
|
||||
let d = &s.doc().unwrap().doc;
|
||||
assert!(d.slices.is_empty());
|
||||
let n = d.node(NodeId(rid)).unwrap();
|
||||
assert_eq!(n.geometric_bounds().unwrap(), vectorcraft_geom::Rect::new(10.0, 20.0, 40.0, 60.0));
|
||||
assert!(matches!(n.appearance.fill_paint(), vectorcraft_color::Paint::None));
|
||||
assert_eq!(s.doc().unwrap().selection.objects, vec![NodeId(rid)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn guides_give_nine_slices() {
|
||||
let mut s = session();
|
||||
for (vertical, pos) in [(true, 100.0), (true, 200.0), (false, 50.0), (false, 250.0), (true, 400.0)] {
|
||||
run(&mut s, "guide.add", json!({"vertical": vertical, "pos": pos}));
|
||||
}
|
||||
user_slice(&mut s, [0.0, 0.0, 10.0, 10.0]);
|
||||
let ids = run(&mut s, "object.slice.fromGuides", json!({}))["ids"].as_array().unwrap().len();
|
||||
assert_eq!(ids, 9, "the guide off the artboard cuts nothing");
|
||||
assert_eq!(user_count(&s), 9, "the earlier slice is replaced");
|
||||
let list = slices(&mut s);
|
||||
assert_eq!(list.len(), 9, "the grid covers the artboard: no auto slices");
|
||||
assert_eq!(list[4]["x"], 100.0);
|
||||
assert_eq!(list[4]["width"], 100.0);
|
||||
assert_eq!(list[4]["height"], 200.0);
|
||||
assert_eq!(list.iter().map(|v| v["number"].as_u64().unwrap()).collect::<Vec<_>>(), (1..=9).collect::<Vec<_>>());
|
||||
// One undo step takes the grid back.
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(user_count(&s), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn divide_two_by_three_and_by_size() {
|
||||
let mut s = session();
|
||||
user_slice(&mut s, [0.0, 0.0, 90.0, 60.0]);
|
||||
run(&mut s, "select.object.slices", json!({}));
|
||||
run(&mut s, "object.slice.options", json!({"name": "hero", "url": "https://example.com"}));
|
||||
let pieces = run(&mut s, "object.slice.divide", json!({"rows": 2, "columns": 3}))["ids"].as_array().unwrap().clone();
|
||||
assert_eq!(pieces.len(), 6);
|
||||
let d = &s.doc().unwrap().doc;
|
||||
let rects: Vec<_> = d.slices.iter().map(|sl| (sl.rect.x0, sl.rect.y0, sl.rect.width(), sl.rect.height())).collect();
|
||||
assert_eq!(
|
||||
rects,
|
||||
[
|
||||
(0.0, 0.0, 30.0, 30.0),
|
||||
(30.0, 0.0, 30.0, 30.0),
|
||||
(60.0, 0.0, 30.0, 30.0),
|
||||
(0.0, 30.0, 30.0, 30.0),
|
||||
(30.0, 30.0, 30.0, 30.0),
|
||||
(60.0, 30.0, 30.0, 30.0)
|
||||
]
|
||||
);
|
||||
assert!(d.slices.iter().all(|sl| sl.options.url == "https://example.com"));
|
||||
assert_eq!(d.slices.iter().filter(|sl| sl.options.name == "hero").count(), 1, "only the first piece keeps the name");
|
||||
assert_eq!(s.doc().unwrap().selection.slices.len(), 6, "the pieces are selected");
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(user_count(&s), 1);
|
||||
// By size: 25 pt rows of a 60 pt slice: 25, 25 and 10.
|
||||
run(&mut s, "select.object.slices", json!({}));
|
||||
run(&mut s, "object.slice.divide", json!({"rowHeight": 25}));
|
||||
let hs: Vec<f64> = s.doc().unwrap().doc.slices.iter().map(|sl| sl.rect.height()).collect();
|
||||
assert_eq!(hs, [25.0, 25.0, 10.0]);
|
||||
assert!(s.execute("object.slice.divide", &json!({"rows": 0})).is_err());
|
||||
assert!(s.execute("object.slice.divide", &json!({"columns": 5000})).is_err());
|
||||
assert!(s.execute("object.slice.divide", &json!({})).is_err(), "nothing to divide by");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn combine_and_duplicate() {
|
||||
let mut s = session();
|
||||
user_slice(&mut s, [0.0, 0.0, 20.0, 20.0]);
|
||||
run(&mut s, "select.object.slices", json!({}));
|
||||
assert!(s.execute("object.slice.combine", &json!({})).is_err(), "two or more");
|
||||
let b = rect(&mut s, 50.0, 40.0, 10.0, 10.0);
|
||||
run(&mut s, "object.slice.make", json!({}));
|
||||
run(&mut s, "select.object.slices", json!({}));
|
||||
let c = run(&mut s, "object.slice.combine", json!({}))["id"].as_u64().unwrap();
|
||||
assert_eq!(rect_of(&mut s, c), [0.0, 0.0, 60.0, 50.0]);
|
||||
assert_eq!(user_count(&s), 1);
|
||||
assert!(s.doc().unwrap().doc.node(NodeId(b)).unwrap().slice.is_none(), "the object slice was released");
|
||||
assert_eq!(s.doc().unwrap().selection.slices, vec![NodeId(c)]);
|
||||
let dup = run(&mut s, "object.slice.duplicate", json!({}))["ids"][0].as_u64().unwrap();
|
||||
assert_eq!(rect_of(&mut s, dup), [10.0, 10.0, 60.0, 50.0]);
|
||||
assert_eq!(s.doc().unwrap().selection.slices, vec![NodeId(dup)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slice_options_set_read_and_undo_in_one_step() {
|
||||
let mut s = session();
|
||||
user_slice(&mut s, [0.0, 0.0, 50.0, 50.0]);
|
||||
run(&mut s, "select.object.slices", json!({}));
|
||||
let o = run(&mut s, "object.slice.options", json!({}));
|
||||
assert_eq!((o["kind"].clone(), o["htmlText"].clone()), (json!("image"), json!(false)));
|
||||
let undo = s.doc().unwrap().history.undo.len();
|
||||
let o = run(
|
||||
&mut s,
|
||||
"object.slice.options",
|
||||
json!({"kind": "No Image", "name": " cell ", "text": " <b>Hi</b>", "background": "#FF0000", "hAlign": "center", "vAlign": "bottom"}),
|
||||
);
|
||||
assert_eq!(
|
||||
o,
|
||||
json!({"kind": "noImage", "name": "cell", "url": "", "target": "", "message": "", "alt": "", "text": " <b>Hi</b>", "background": "#ff0000", "hAlign": "center", "vAlign": "bottom", "htmlText": false})
|
||||
);
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1);
|
||||
assert_eq!(slices(&mut s)[0]["name"], "cell");
|
||||
assert!(s.execute("object.slice.options", &json!({"kind": "htmlText"})).is_err(), "HTML Text is for type");
|
||||
assert!(s.execute("object.slice.options", &json!({"background": "pink-ish"})).is_err());
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(s.doc().unwrap().doc.slices[0].options, Default::default());
|
||||
// An object slice of type can be HTML Text.
|
||||
let t = run(&mut s, "text.create", json!({"x": 10, "y": 100, "text": "Hello"}))["id"].as_u64().unwrap();
|
||||
run(&mut s, "object.slice.make", json!({"ids": [t]}));
|
||||
run(&mut s, "select.set", json!({"ids": [t]}));
|
||||
let o = run(&mut s, "object.slice.options", json!({"kind": "htmlText"}));
|
||||
assert_eq!((o["kind"].clone(), o["htmlText"].clone()), (json!("htmlText"), json!(true)));
|
||||
assert_eq!(s.doc().unwrap().doc.node(NodeId(t)).unwrap().slice.as_ref().unwrap().kind, SliceKind::HtmlText);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clip_to_artboard_hide_lock_select_and_delete_all() {
|
||||
let mut s = session();
|
||||
let id = user_slice(&mut s, [250.0, 250.0, 100.0, 100.0]);
|
||||
assert_eq!(rect_of(&mut s, id), [250.0, 250.0, 50.0, 50.0], "clipped to the artboard");
|
||||
assert_eq!(run(&mut s, "object.slice.clipToArtboard", json!({}))["on"], false);
|
||||
assert_eq!(rect_of(&mut s, id), [250.0, 250.0, 100.0, 100.0]);
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert!(s.doc().unwrap().doc.slices_clip_to_artboard);
|
||||
assert_eq!(run(&mut s, "object.slice.clipToArtboard", json!({"on": true}))["on"], true);
|
||||
|
||||
assert_eq!(run(&mut s, "view.slices.hide", json!({}))["hidden"], true);
|
||||
assert!(s.slices_hidden());
|
||||
assert_eq!(run(&mut s, "view.slices.lock", json!({"locked": true}))["locked"], true);
|
||||
let l = run(&mut s, "slice.list", json!({}));
|
||||
assert_eq!((l["hidden"].clone(), l["locked"].clone(), l["clipToArtboard"].clone()), (json!(true), json!(true), json!(true)));
|
||||
run(&mut s, "view.slices.hide", json!({"hidden": false}));
|
||||
assert!(!s.slices_hidden());
|
||||
|
||||
let a = rect(&mut s, 10.0, 10.0, 10.0, 10.0);
|
||||
run(&mut s, "object.slice.make", json!({}));
|
||||
assert_eq!(run(&mut s, "select.object.slices", json!({}))["count"], 2);
|
||||
let sel = &s.doc().unwrap().selection;
|
||||
assert_eq!((sel.slices.clone(), sel.objects.is_empty()), (vec![NodeId(id), NodeId(a)], true));
|
||||
assert!(slices(&mut s).iter().filter(|v| v["selected"] == true).count() == 2);
|
||||
// Selecting objects drops the slice selection.
|
||||
run(&mut s, "select.all", json!({}));
|
||||
assert!(s.doc().unwrap().selection.slices.is_empty());
|
||||
|
||||
assert_eq!(run(&mut s, "object.slice.deleteAll", json!({}))["count"], 2);
|
||||
assert_eq!(user_count(&s), 0);
|
||||
assert!(s.doc().unwrap().doc.node(NodeId(a)).unwrap().slice.is_none());
|
||||
assert!(s.execute("object.slice.deleteAll", &json!({})).is_err(), "nothing to delete");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slices_survive_a_native_round_trip() {
|
||||
let mut s = session();
|
||||
user_slice(&mut s, [100.0, 100.0, 40.0, 40.0]);
|
||||
run(&mut s, "select.object.slices", json!({}));
|
||||
run(&mut s, "object.slice.options", json!({"kind": "noImage", "text": "x"}));
|
||||
let a = rect(&mut s, 10.0, 10.0, 10.0, 10.0);
|
||||
run(&mut s, "object.slice.make", json!({}));
|
||||
run(&mut s, "object.slice.options", json!({"alt": "logo"}));
|
||||
run(&mut s, "object.slice.clipToArtboard", json!({"on": false}));
|
||||
let doc = s.doc().unwrap().doc.clone();
|
||||
vectorcraft_testkit::invariants::check_native_roundtrip_exact(&doc).unwrap();
|
||||
let back = vectorcraft_format::load(&vectorcraft_format::save(&doc, false)).unwrap();
|
||||
assert_eq!(back.slices, doc.slices);
|
||||
assert_eq!(back.node(NodeId(a)).unwrap().slice.as_ref().unwrap().alt, "logo");
|
||||
assert!(!back.slices_clip_to_artboard);
|
||||
// A copy of an object slice is an object slice too, under its own id.
|
||||
run(&mut s, "select.set", json!({"ids": [a]}));
|
||||
run(&mut s, "edit.duplicate", json!({}));
|
||||
assert_eq!(s.doc().unwrap().doc.object_slices().len(), 2);
|
||||
}
|
||||
Reference in New Issue
Block a user