M3.76: Attributes panel with overprint, centre point, path direction, fill rule, image map, URL and note
Window > Attributes (Cmd+F11) shows Overprint Fill/Stroke, Show/Don't Show Center, Reverse Path Direction Off/On, the non-zero / even-odd fill rule, Image Map, a URL field with recent URLs and a Browser button, and a note. New commands attributes.set and path.setFillRule; path.reverse takes ids and reversed; attributes.info reports every value. Objects keep the attributes in Node.attrs. SVG export wraps linked objects in <a>, and import reads links back. The canvas draws the centre point of selected objects that show it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
9fcefde3f9
commit
4591c85845
|
|
@ -99,12 +99,16 @@ Generated-in-code art is original and has no file to list. This covers the defau
|
|||
| `assets/icons/dc-cap-butt.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
| `assets/icons/dc-cap-round.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
| `assets/icons/dc-cap-square.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
| `assets/icons/dc-center-hide.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
| `assets/icons/dc-center-show.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
| `assets/icons/dc-clear.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
| `assets/icons/dc-color-guide.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
| `assets/icons/dc-crystallize.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
| `assets/icons/dc-cube.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
| `assets/icons/dc-dash-align.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
| `assets/icons/dc-dash-exact.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
| `assets/icons/dc-dir-off.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
| `assets/icons/dc-dir-on.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
| `assets/icons/dc-direct.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
| `assets/icons/dc-dist-bottom.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
| `assets/icons/dc-dist-hcenter.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
|
|
@ -182,6 +186,8 @@ Generated-in-code art is original and has no file to list. This covers the defau
|
|||
| `assets/icons/dc-reverse.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
| `assets/icons/dc-rotate-view.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
| `assets/icons/dc-rounded-rect.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
| `assets/icons/dc-rule-evenodd.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
| `assets/icons/dc-rule-nonzero.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
| `assets/icons/dc-scallop.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
| `assets/icons/dc-screen-mode.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
| `assets/icons/dc-selection.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
|
|
|
|||
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="butt" stroke-linejoin="miter"><rect x="4.75" y="4.75" width="14.5" height="14.5"/></svg>
|
||||
|
After Width: | Height: | Size: 241 B |
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="butt" stroke-linejoin="miter"><rect x="4.75" y="4.75" width="14.5" height="14.5"/><rect x="10" y="10" width="4" height="4" fill="currentColor" stroke="none"/></svg>
|
||||
|
After Width: | Height: | Size: 317 B |
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="butt" stroke-linejoin="miter"><path d="M12 5.5A6.5 6.5 0 1 1 5.5 12"/><path d="M3 14.5L5.5 11.5L8 14.5"/></svg>
|
||||
|
After Width: | Height: | Size: 264 B |
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="butt" stroke-linejoin="miter"><path d="M12 5.5A6.5 6.5 0 1 0 18.5 12"/><path d="M16 14.5L18.5 11.5L21 14.5"/></svg>
|
||||
|
After Width: | Height: | Size: 268 B |
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="butt" stroke-linejoin="miter"><path d="M4 4H20V20H4Z M8.5 8.5H15.5V15.5H8.5Z" fill="currentColor" fill-opacity="0.55" fill-rule="evenodd" stroke-width="1"/></svg>
|
||||
|
After Width: | Height: | Size: 315 B |
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="butt" stroke-linejoin="miter"><path d="M4 4H20V20H4Z M8.5 8.5H15.5V15.5H8.5Z" fill="currentColor" fill-opacity="0.55" fill-rule="nonzero" stroke-width="1"/></svg>
|
||||
|
After Width: | Height: | Size: 315 B |
|
|
@ -43,6 +43,7 @@ pub use graph::{GraphKind, GraphSpec};
|
|||
pub use hit::{Hit, HitKind};
|
||||
pub use live::{BlendOrientation, BlendSpacing, BlendSpec, EnvelopeKind, GradientMesh, MeshPoint};
|
||||
pub use node::Knockout;
|
||||
pub use node::{ImageMap, ObjectAttributes};
|
||||
pub use node::{ImageObject, LAYER_COLORS, LayerColor, LiveShape, Node, NodeId, NodeKind, OpacityMask};
|
||||
pub use pattern::{Overlap, PatternDef, PatternEdit, RepeatKind, RepeatSpec, TileType};
|
||||
pub use selection::{AnchorRef, Selection};
|
||||
|
|
|
|||
|
|
@ -282,6 +282,9 @@ pub struct Node {
|
|||
/// that style's look: editing its appearance or transparency breaks the link.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub graphic_style: Option<u32>,
|
||||
/// Attributes panel: centre point display, image map, URL and note (`None`: all defaults).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub attrs: Option<Box<ObjectAttributes>>,
|
||||
}
|
||||
|
||||
/// Opacity mask: the luminance of the mask art sets the object's opacity (white = opaque).
|
||||
|
|
@ -328,6 +331,7 @@ impl Node {
|
|||
graph: None,
|
||||
kind,
|
||||
graphic_style: None,
|
||||
attrs: None,
|
||||
}
|
||||
}
|
||||
pub fn path(id: NodeId, path: PathData, appearance: Appearance) -> Self {
|
||||
|
|
@ -909,6 +913,77 @@ impl Node {
|
|||
}
|
||||
}
|
||||
|
||||
/// Window → Attributes: an object's settings that don't change how it prints.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ObjectAttributes {
|
||||
/// Show Center / Don't Show Center (`None`: the object kind's default, [`Node::shows_center`]).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub show_center: Option<bool>,
|
||||
/// Image Map: the clickable area the URL covers in web output.
|
||||
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
|
||||
pub image_map: ImageMap,
|
||||
/// URL the object links to (SVG `<a href>`; empty: none).
|
||||
#[serde(default, skip_serializing_if = "String::is_empty")]
|
||||
pub url: String,
|
||||
/// The note shown in the Attributes panel.
|
||||
#[serde(default, skip_serializing_if = "String::is_empty")]
|
||||
pub note: String,
|
||||
}
|
||||
|
||||
/// The Attributes panel's Image Map shapes.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum ImageMap {
|
||||
#[default]
|
||||
None,
|
||||
Rectangle,
|
||||
Polygon,
|
||||
}
|
||||
|
||||
impl ImageMap {
|
||||
pub const ALL: [ImageMap; 3] = [ImageMap::None, ImageMap::Rectangle, ImageMap::Polygon];
|
||||
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
ImageMap::None => "None",
|
||||
ImageMap::Rectangle => "Rectangle",
|
||||
ImageMap::Polygon => "Polygon",
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a label or serialized name, ignoring case.
|
||||
pub fn parse(s: &str) -> Option<Self> {
|
||||
Self::ALL.into_iter().find(|m| m.label().eq_ignore_ascii_case(s))
|
||||
}
|
||||
}
|
||||
|
||||
impl Node {
|
||||
/// The URL this object links to, if any.
|
||||
pub fn url(&self) -> Option<&str> {
|
||||
self.attrs.as_deref().map(|a| a.url.as_str()).filter(|u| !u.is_empty())
|
||||
}
|
||||
|
||||
/// Whether the canvas shows this object's centre point when it is selected: as set in the
|
||||
/// Attributes panel, else [`Self::shows_center_by_default`].
|
||||
pub fn shows_center(&self) -> bool {
|
||||
self.attrs.as_deref().and_then(|a| a.show_center).unwrap_or_else(|| self.shows_center_by_default())
|
||||
}
|
||||
|
||||
/// Shapes drawn with the shape tools (live rectangles, ellipses and polygons) show their centre.
|
||||
pub fn shows_center_by_default(&self) -> bool {
|
||||
matches!(&self.kind, NodeKind::Path { live: Some(l), .. } if !matches!(l, LiveShape::Line { .. }))
|
||||
}
|
||||
|
||||
/// Change this object's attributes with `f`; all-default attributes are dropped.
|
||||
pub fn edit_attrs<R>(&mut self, f: impl FnOnce(&mut ObjectAttributes) -> R) -> R {
|
||||
let mut a = self.attrs.take().unwrap_or_default();
|
||||
let r = f(&mut a);
|
||||
self.attrs = (*a != ObjectAttributes::default()).then_some(a);
|
||||
r
|
||||
}
|
||||
}
|
||||
|
||||
/// Is `a` a move plus a positive uniform scale (after which a refit gradient still matches)?
|
||||
fn keeps_gradient_fit(a: Affine) -> bool {
|
||||
let [m0, m1, m2, m3, _, _] = a.as_coeffs();
|
||||
|
|
|
|||
|
|
@ -147,6 +147,8 @@ fn piece(d: &mut Document, n: &Node, path: PathData, rule: FillRule, item: Appea
|
|||
trace: None,
|
||||
wrap: None,
|
||||
graph: None,
|
||||
// The group keeps the URL (one link around the pieces).
|
||||
attrs: None,
|
||||
kind,
|
||||
..n.clone()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,185 @@
|
|||
//! Window → Attributes: overprint, centre point display, image map, URL and note
|
||||
//! (`attributes.set`), the fill rule (`path.setFillRule`) and, through `path.reverse`, the path
|
||||
//! direction. The panel reads everything back with `attributes.info` ([`super::overprint`]).
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::{Document, ImageMap, Node, NodeKind, ObjectAttributes};
|
||||
use vectorcraft_geom::FillRule;
|
||||
|
||||
use super::overprint::{AttributesInfo, apply_overprint, edit_counted, overprint_jobs};
|
||||
use super::path::paths_in;
|
||||
use super::*;
|
||||
|
||||
/// How many URLs the Attributes panel's recent list keeps.
|
||||
const RECENT_URLS: usize = 10;
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(
|
||||
"attributes.set",
|
||||
"Attributes",
|
||||
[],
|
||||
None,
|
||||
"{overprintFill?: bool, overprintStroke?: bool (aimed as object.setOverprint's fill and stroke, item? too), showCenter?: bool (the canvas shows the selected object's centre point), imageMap?: \"none\"|\"rectangle\"|\"polygon\", url?: string (\"\" removes it; SVG export links the object), note?: string, ids?} set the Attributes panel's values on ids or the selection, as one undo step; a URL given joins the recent URLs (attributes.info) → {changed: objects}",
|
||||
has_doc,
|
||||
set
|
||||
),
|
||||
cmd!(
|
||||
"path.setFillRule",
|
||||
"Fill Rule",
|
||||
[],
|
||||
None,
|
||||
"{rule: \"nonZero\"|\"evenOdd\", ids?} the fill rule of the paths and compound paths in ids or the selection (groups: those inside), as one undo step → {changed}",
|
||||
has_doc,
|
||||
set_fill_rule
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
/// A fill rule as the commands name it.
|
||||
pub(super) fn rule_name(r: FillRule) -> &'static str {
|
||||
match r {
|
||||
FillRule::NonZero => "nonZero",
|
||||
FillRule::EvenOdd => "evenOdd",
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_rule(s: &str) -> Option<FillRule> {
|
||||
match s.to_ascii_lowercase().replace(['-', ' ', '_'], "").as_str() {
|
||||
"nonzero" | "nonzerowinding" => Some(FillRule::NonZero),
|
||||
"evenodd" => Some(FillRule::EvenOdd),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The one value every item of `it` shares, if any.
|
||||
fn same<T: PartialEq>(mut it: impl Iterator<Item = T>) -> Option<T> {
|
||||
let first = it.next()?;
|
||||
it.all(|v| v == first).then_some(first)
|
||||
}
|
||||
|
||||
/// The paths and compound paths in `n`'s subtree that have a fill rule of their own with it (not a
|
||||
/// compound path's members, which fill with its rule).
|
||||
fn ruled<'a>(n: &'a Node, out: &mut Vec<(&'a Node, FillRule)>) {
|
||||
match &n.kind {
|
||||
NodeKind::Path { rule, guide: false, .. } | NodeKind::Compound { rule, .. } => out.push((n, *rule)),
|
||||
_ => n.children().into_iter().flatten().for_each(|c| ruled(c, out)),
|
||||
}
|
||||
}
|
||||
|
||||
/// The Attributes panel's values of `info.ids` beyond overprinting.
|
||||
pub(super) fn fill_info(d: &Document, info: &mut AttributesInfo) {
|
||||
let nodes: Vec<&Node> = info.ids.iter().filter_map(|id| d.node(*id)).collect();
|
||||
let attrs = || nodes.iter().map(|n| n.attrs.as_deref().cloned().unwrap_or_default());
|
||||
info.show_center = same(nodes.iter().map(|n| n.shows_center()));
|
||||
info.image_map = same(attrs().map(|a| a.image_map));
|
||||
info.url = same(attrs().map(|a| a.url));
|
||||
info.note = same(attrs().map(|a| a.note));
|
||||
let mut all = vec![];
|
||||
nodes.iter().for_each(|n| ruled(n, &mut all));
|
||||
info.fill_rule = same(all.into_iter().map(|(_, r)| r));
|
||||
let paths = paths_in(d, &info.ids);
|
||||
let subpaths = paths.iter().filter_map(|id| d.node(*id)?.path_data()).flat_map(|p| &p.subpaths);
|
||||
info.reversed = same(subpaths.map(|sp| sp.signed_area() < 0.0));
|
||||
}
|
||||
|
||||
/// The attribute edits `p` asks for, applied to `a`.
|
||||
#[derive(Default)]
|
||||
struct Edits {
|
||||
show_center: Option<bool>,
|
||||
image_map: Option<ImageMap>,
|
||||
url: Option<String>,
|
||||
note: Option<String>,
|
||||
}
|
||||
|
||||
impl Edits {
|
||||
fn parse(p: &Value) -> Result<Self> {
|
||||
const C: &str = "attributes.set";
|
||||
let text = |k: &str| -> Result<Option<String>> {
|
||||
p.get(k).map(|v| v.as_str().map(|s| s.trim().to_string()).ok_or_else(|| bad(C, format!("`{k}` must be a string")))).transpose()
|
||||
};
|
||||
Ok(Self {
|
||||
show_center: p.get("showCenter").map(|v| v.as_bool().ok_or_else(|| bad(C, "`showCenter` must be true or false"))).transpose()?,
|
||||
image_map: text("imageMap")?
|
||||
.map(|s| ImageMap::parse(&s).ok_or_else(|| bad(C, "`imageMap` must be none, rectangle or polygon")))
|
||||
.transpose()?,
|
||||
url: text("url")?,
|
||||
note: text("note")?,
|
||||
})
|
||||
}
|
||||
|
||||
fn is_empty(&self) -> bool {
|
||||
self.show_center.is_none() && self.image_map.is_none() && self.url.is_none() && self.note.is_none()
|
||||
}
|
||||
|
||||
/// `n`'s attributes with the edits applied (Show Center kept only where it isn't the default).
|
||||
fn apply(&self, n: &Node) -> ObjectAttributes {
|
||||
let mut a = n.attrs.as_deref().cloned().unwrap_or_default();
|
||||
if let Some(v) = self.show_center {
|
||||
a.show_center = (v != n.shows_center_by_default()).then_some(v);
|
||||
}
|
||||
if let Some(v) = self.image_map {
|
||||
a.image_map = v;
|
||||
}
|
||||
if let Some(v) = &self.url {
|
||||
a.url.clone_from(v);
|
||||
}
|
||||
if let Some(v) = &self.note {
|
||||
a.note.clone_from(v);
|
||||
}
|
||||
a
|
||||
}
|
||||
}
|
||||
|
||||
fn set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "attributes.set";
|
||||
let jobs = overprint_jobs(s, p, C, ["overprintFill", "overprintStroke"])?;
|
||||
let edits = Edits::parse(p)?;
|
||||
if jobs.is_empty() && edits.is_empty() {
|
||||
return Err(bad(C, "give overprintFill, overprintStroke, showCenter, imageMap, url or note"));
|
||||
}
|
||||
let ids = targets(s, p)?;
|
||||
let changed = edit_counted(s, "Attributes", |d| {
|
||||
let mut changed = BTreeSet::new();
|
||||
apply_overprint(d, &jobs, C, &mut changed)?;
|
||||
if !edits.is_empty() {
|
||||
for id in &ids {
|
||||
let Some(n) = d.node(*id) else { continue };
|
||||
let new = edits.apply(n);
|
||||
if new != n.attrs.as_deref().cloned().unwrap_or_default()
|
||||
&& let Some(n) = d.node_mut(*id)
|
||||
{
|
||||
n.edit_attrs(|a| *a = new);
|
||||
changed.insert(*id);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(changed.len())
|
||||
})?;
|
||||
if let Some(url) = edits.url.filter(|u| !u.is_empty() && !ids.is_empty()) {
|
||||
s.recent_urls.retain(|u| *u != url);
|
||||
s.recent_urls.insert(0, url);
|
||||
s.recent_urls.truncate(RECENT_URLS);
|
||||
}
|
||||
Ok(json!({ "changed": changed }))
|
||||
}
|
||||
|
||||
fn set_fill_rule(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "path.setFillRule";
|
||||
let rule = str_param(p, "rule").and_then(parse_rule).ok_or_else(|| bad(C, "`rule` must be nonZero or evenOdd"))?;
|
||||
let ids = targets(s, p)?;
|
||||
let changed = edit_counted(s, "Fill Rule", |d| {
|
||||
let mut all = vec![];
|
||||
ids.iter().filter_map(|id| d.node(*id)).for_each(|n| ruled(n, &mut all));
|
||||
let todo: BTreeSet<_> = all.into_iter().filter(|(_, r)| *r != rule).map(|(n, _)| n.id).collect();
|
||||
for id in &todo {
|
||||
if let Some(NodeKind::Path { rule: r, .. } | NodeKind::Compound { rule: r, .. }) = d.node_mut(*id).map(|n| &mut n.kind) {
|
||||
*r = rule;
|
||||
}
|
||||
}
|
||||
Ok(todo.len())
|
||||
})?;
|
||||
Ok(json!({ "changed": changed }))
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
//! The command registry. Ids follow Illustrator's menu structure.
|
||||
|
||||
pub(crate) mod appearance;
|
||||
mod attributes;
|
||||
mod brushsym;
|
||||
mod buildcmds;
|
||||
pub mod clipboard;
|
||||
|
|
@ -204,6 +205,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
|
|||
v.extend(flatten::specs());
|
||||
v.extend(stylelib::specs());
|
||||
v.extend(expand::specs());
|
||||
v.extend(attributes::specs());
|
||||
v
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,7 +7,8 @@ use std::sync::Arc;
|
|||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::overprint::OverprintBlack;
|
||||
use vectorcraft_doc::{Document, Node, NodeId, NodeKind};
|
||||
use vectorcraft_doc::{Document, ImageMap, Node, NodeId, NodeKind};
|
||||
use vectorcraft_geom::FillRule;
|
||||
|
||||
use super::appearance::{ItemTarget, item_target};
|
||||
use super::edit::selected_roots;
|
||||
|
|
@ -29,7 +30,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
"Attributes",
|
||||
[],
|
||||
None,
|
||||
"{ids?, item?} → {ids, overprintFill, overprintStroke} the Attributes panel's values for `ids` or the selection, aimed as object.setOverprint aims; a value is null where they differ (or nothing has a fill or stroke)",
|
||||
"{ids?, item?} → {ids, overprintFill, overprintStroke, showCenter, imageMap: \"none\"|\"rectangle\"|\"polygon\", url, note, fillRule: \"nonZero\"|\"evenOdd\" (of the paths and compound paths in them), reversed (their subpaths run counter-clockwise on screen: Reverse Path Direction On), recentUrls: [newest first]} the Attributes panel's values for `ids` or the selection, overprint aimed as object.setOverprint aims; a value is null where they differ (or nothing has a fill, stroke or path)",
|
||||
has_doc,
|
||||
|s, p| Ok(attributes(s, p)?.to_json())
|
||||
),
|
||||
|
|
@ -45,6 +46,17 @@ pub struct AttributesInfo {
|
|||
pub overprint_fill: Option<bool>,
|
||||
/// Overprint Stroke; `None` where the targets differ or have no stroke.
|
||||
pub overprint_stroke: Option<bool>,
|
||||
/// The rest of the panel ([`super::attributes`]); `None` where the targets differ.
|
||||
pub show_center: Option<bool>,
|
||||
pub image_map: Option<ImageMap>,
|
||||
pub url: Option<String>,
|
||||
pub note: Option<String>,
|
||||
/// Fill rule of the paths and compound paths in the targets.
|
||||
pub fill_rule: Option<FillRule>,
|
||||
/// Whether their subpaths run counter-clockwise on screen (Reverse Path Direction On).
|
||||
pub reversed: Option<bool>,
|
||||
/// [`Session::recent_urls`].
|
||||
pub recent_urls: Vec<String>,
|
||||
}
|
||||
|
||||
impl AttributesInfo {
|
||||
|
|
@ -53,6 +65,13 @@ impl AttributesInfo {
|
|||
"ids": self.ids.iter().map(|id| id.0).collect::<Vec<_>>(),
|
||||
"overprintFill": self.overprint_fill,
|
||||
"overprintStroke": self.overprint_stroke,
|
||||
"showCenter": self.show_center,
|
||||
"imageMap": self.image_map,
|
||||
"url": self.url,
|
||||
"note": self.note,
|
||||
"fillRule": self.fill_rule.map(super::attributes::rule_name),
|
||||
"reversed": self.reversed,
|
||||
"recentUrls": self.recent_urls,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -97,9 +116,9 @@ fn flags_mut(n: &mut Node, fill: bool, index: Option<usize>) -> Vec<&mut bool> {
|
|||
v
|
||||
}
|
||||
|
||||
/// The `fill` and `stroke` params given: (fill?, on).
|
||||
fn kinds(p: &Value, cmd: &str) -> Result<Vec<(bool, bool)>> {
|
||||
[(true, "fill"), (false, "stroke")]
|
||||
/// The fill and stroke overprint params given under `keys` (fill's, stroke's): (fill?, on).
|
||||
fn kinds(p: &Value, cmd: &str, keys: [&str; 2]) -> Result<Vec<(bool, bool)>> {
|
||||
[(true, keys[0]), (false, keys[1])]
|
||||
.into_iter()
|
||||
.filter_map(|(fill, key)| {
|
||||
p.get(key).map(|v| v.as_bool().map(|on| (fill, on)).ok_or_else(|| bad(cmd, format!("`{key}` must be true or false"))))
|
||||
|
|
@ -121,7 +140,7 @@ fn aims(s: &Session, p: &Value, cmd: &str, kinds: impl IntoIterator<Item = bool>
|
|||
|
||||
/// Run `f` on a copy of the document and commit it as one undo step (`label`) only when `f`
|
||||
/// reports changes; returns their count.
|
||||
fn edit_counted(s: &mut Session, label: &str, f: impl FnOnce(&mut Document) -> Result<usize>) -> Result<usize> {
|
||||
pub(super) fn edit_counted(s: &mut Session, label: &str, f: impl FnOnce(&mut Document) -> Result<usize>) -> Result<usize> {
|
||||
let mut d = (*s.doc()?.doc).clone();
|
||||
let n = f(&mut d)?;
|
||||
if n > 0 {
|
||||
|
|
@ -133,27 +152,42 @@ fn edit_counted(s: &mut Session, label: &str, f: impl FnOnce(&mut Document) -> R
|
|||
Ok(n)
|
||||
}
|
||||
|
||||
fn set_overprint(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "object.setOverprint";
|
||||
let kinds = kinds(p, C)?;
|
||||
if kinds.is_empty() {
|
||||
return Err(bad(C, "give `fill` and/or `stroke`"));
|
||||
}
|
||||
let jobs: Vec<_> = aims(s, p, C, kinds.iter().map(|k| k.0))?.into_iter().zip(kinds.iter().map(|k| k.1)).collect();
|
||||
let changed = edit_counted(s, "Overprint", |d| {
|
||||
let mut changed = BTreeSet::new();
|
||||
for ((fill, item, ids), on) in &jobs {
|
||||
for id in ids {
|
||||
let Some(n) = d.node_mut(*id) else { continue };
|
||||
let index = item.resolve(&n.appearance, *fill, C)?;
|
||||
for f in flags_mut(n, *fill, index) {
|
||||
if *f != *on {
|
||||
*f = *on;
|
||||
changed.insert(*id);
|
||||
}
|
||||
/// One overprint edit: (fill?, item, objects), on.
|
||||
pub(super) type OverprintJob = ((bool, ItemTarget, Vec<NodeId>), bool);
|
||||
|
||||
/// The overprint edits `p` asks for under `keys` (the fill's and the stroke's), aimed as
|
||||
/// `object.setOverprint` aims.
|
||||
pub(super) fn overprint_jobs(s: &Session, p: &Value, cmd: &str, keys: [&str; 2]) -> Result<Vec<OverprintJob>> {
|
||||
let kinds = kinds(p, cmd, keys)?;
|
||||
Ok(aims(s, p, cmd, kinds.iter().map(|k| k.0))?.into_iter().zip(kinds.iter().map(|k| k.1)).collect())
|
||||
}
|
||||
|
||||
/// Apply `jobs` to `d`, adding the objects changed to `changed`.
|
||||
pub(super) fn apply_overprint(d: &mut Document, jobs: &[OverprintJob], cmd: &str, changed: &mut BTreeSet<NodeId>) -> Result<()> {
|
||||
for ((fill, item, ids), on) in jobs {
|
||||
for id in ids {
|
||||
let Some(n) = d.node_mut(*id) else { continue };
|
||||
let index = item.resolve(&n.appearance, *fill, cmd)?;
|
||||
for f in flags_mut(n, *fill, index) {
|
||||
if *f != *on {
|
||||
*f = *on;
|
||||
changed.insert(*id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn set_overprint(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "object.setOverprint";
|
||||
let jobs = overprint_jobs(s, p, C, ["fill", "stroke"])?;
|
||||
if jobs.is_empty() {
|
||||
return Err(bad(C, "give `fill` and/or `stroke`"));
|
||||
}
|
||||
let changed = edit_counted(s, "Overprint", |d| {
|
||||
let mut changed = BTreeSet::new();
|
||||
apply_overprint(d, &jobs, C, &mut changed)?;
|
||||
Ok(changed.len())
|
||||
})?;
|
||||
Ok(json!({ "changed": changed }))
|
||||
|
|
@ -162,7 +196,8 @@ fn set_overprint(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
fn attributes(s: &Session, p: &Value) -> Result<AttributesInfo> {
|
||||
const C: &str = "attributes.info";
|
||||
let d = &s.doc()?.doc;
|
||||
let mut info = AttributesInfo { ids: targets(s, p)?, ..Default::default() };
|
||||
let mut info = AttributesInfo { ids: targets(s, p)?, recent_urls: s.recent_urls.clone(), ..Default::default() };
|
||||
super::attributes::fill_info(d, &mut info);
|
||||
for (fill, item, ids) in aims(s, p, C, [true, false])? {
|
||||
let mut all =
|
||||
ids.iter().filter_map(|id| d.node(*id)).filter_map(|n| Some(flags(n, fill, item.resolve(&n.appearance, fill, C).ok()?))).flatten();
|
||||
|
|
|
|||
|
|
@ -49,7 +49,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
has_doc,
|
||||
set_anchors
|
||||
),
|
||||
cmd!("path.reverse", "Reverse Path Direction", ["Object", "Path"], None, "{}", has_selection, reverse),
|
||||
cmd!(
|
||||
"path.reverse",
|
||||
"Reverse Path Direction",
|
||||
["Object", "Path"],
|
||||
None,
|
||||
"{ids?, reversed?: bool (true: every subpath runs counter-clockwise on screen, false: clockwise, the Attributes panel's Reverse Path Direction On / Off; omitted: flip each path)} the paths in ids or the selection (groups and compound paths: the paths inside), as one undo step → {changed: paths}",
|
||||
has_doc,
|
||||
reverse
|
||||
),
|
||||
cmd!(
|
||||
"path.join",
|
||||
"Join",
|
||||
|
|
@ -233,15 +241,46 @@ fn selected_paths(s: &Session) -> Result<Vec<NodeId>> {
|
|||
Ok(st.selection.objects.iter().copied().filter(|id| matches!(st.doc.node(*id).map(|n| &n.kind), Some(NodeKind::Path { .. }))).collect())
|
||||
}
|
||||
|
||||
fn reverse(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let ids = selected_paths(s)?;
|
||||
s.edit("Reverse Path Direction", |d, _| {
|
||||
/// The paths in the subtrees of `ids` (in groups and compound paths too), each once.
|
||||
pub(super) fn paths_in(d: &vectorcraft_doc::Document, ids: &[NodeId]) -> Vec<NodeId> {
|
||||
let mut out = BTreeSet::new();
|
||||
for n in ids.iter().filter_map(|id| d.node(*id)) {
|
||||
n.walk(&mut |c| {
|
||||
if matches!(c.kind, NodeKind::Path { guide: false, .. }) {
|
||||
out.insert(c.id);
|
||||
}
|
||||
});
|
||||
}
|
||||
out.into_iter().collect()
|
||||
}
|
||||
|
||||
fn reverse(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "path.reverse";
|
||||
let want = p.get("reversed").map(|v| v.as_bool().ok_or_else(|| bad(C, "`reversed` must be true or false"))).transpose()?;
|
||||
let ids = paths_in(&s.doc()?.doc, &targets(s, p)?);
|
||||
if ids.is_empty() {
|
||||
return Err(bad(C, "select paths"));
|
||||
}
|
||||
let changed = super::overprint::edit_counted(s, "Reverse Path Direction", |d| {
|
||||
let mut changed = 0;
|
||||
for id in &ids {
|
||||
path_mut(d, *id)?.reverse();
|
||||
// The subpaths to flip: all, or those not yet running the wanted way.
|
||||
let flip: Vec<bool> = d
|
||||
.node(*id)
|
||||
.and_then(|n| n.path_data())
|
||||
.map_or(vec![], |pd| pd.subpaths.iter().map(|sp| want.is_none_or(|ccw| (sp.signed_area() < 0.0) != ccw)).collect());
|
||||
if flip.contains(&true) {
|
||||
for (sp, f) in path_mut(d, *id)?.subpaths.iter_mut().zip(flip) {
|
||||
if f {
|
||||
sp.reverse();
|
||||
}
|
||||
}
|
||||
changed += 1;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
Ok(changed)
|
||||
})?;
|
||||
ok()
|
||||
Ok(json!({ "changed": changed }))
|
||||
}
|
||||
|
||||
fn join(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
|
|
|
|||
|
|
@ -507,6 +507,8 @@ pub struct Session {
|
|||
pub(crate) freeform_point: Option<(usize, cmd::gradient::StopOwner)>,
|
||||
/// User Defined and loaded graphic style libraries (Window → Graphic Style Libraries); not saved.
|
||||
pub style_libraries: cmd::stylelib::Libraries,
|
||||
/// URLs recently given in the Attributes panel (`attributes.set {url}`), newest first; not saved.
|
||||
pub recent_urls: Vec<String>,
|
||||
}
|
||||
|
||||
impl Default for Session {
|
||||
|
|
@ -541,6 +543,7 @@ impl Session {
|
|||
swatch_libraries: Default::default(),
|
||||
freeform_point: None,
|
||||
style_libraries: Default::default(),
|
||||
recent_urls: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -787,6 +790,8 @@ mod tests;
|
|||
#[cfg(test)]
|
||||
mod tests_appearance;
|
||||
#[cfg(test)]
|
||||
mod tests_attributes;
|
||||
#[cfg(test)]
|
||||
mod tests_brushsym;
|
||||
#[cfg(test)]
|
||||
mod tests_build;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,192 @@
|
|||
//! Window → Attributes (`attributes.set`, `attributes.info`, `path.setFillRule`, `path.reverse`)
|
||||
//! and the links SVG export writes for objects with a URL.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::{ImageMap, NodeKind};
|
||||
use vectorcraft_geom::{Affine, FillRule};
|
||||
use vectorcraft_render::{RenderOptions, Renderer};
|
||||
|
||||
use super::*;
|
||||
|
||||
fn session() -> Session {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 100, "height": 100})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
fn run(s: &mut Session, id: &str, p: Value) -> Value {
|
||||
s.execute(id, &p).unwrap()
|
||||
}
|
||||
|
||||
fn rect(s: &mut Session, x: f64) -> NodeId {
|
||||
NodeId(run(s, "shape.rectangle", json!({"x": x, "y": 10, "width": 30, "height": 30}))["id"].as_u64().unwrap())
|
||||
}
|
||||
|
||||
fn info(s: &mut Session, p: Value) -> Value {
|
||||
run(s, "attributes.info", p)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn each_attribute_sets_reads_back_and_undoes_in_one_step() {
|
||||
let mut s = session();
|
||||
let a = rect(&mut s, 10.0);
|
||||
let i = info(&mut s, json!({}));
|
||||
assert_eq!((i["overprintFill"].clone(), i["showCenter"].clone(), i["imageMap"].clone()), (json!(false), json!(true), json!("none")));
|
||||
assert_eq!((i["url"].clone(), i["note"].clone(), i["fillRule"].clone()), (json!(""), json!(""), json!("nonZero")));
|
||||
let changed = run(
|
||||
&mut s,
|
||||
"attributes.set",
|
||||
json!({"overprintFill": true, "overprintStroke": true, "showCenter": false, "imageMap": "Rectangle", "url": " https://example.com ", "note": "hello"}),
|
||||
);
|
||||
assert_eq!(changed["changed"], 1);
|
||||
let i = info(&mut s, json!({}));
|
||||
for (k, v) in [
|
||||
("overprintFill", json!(true)),
|
||||
("overprintStroke", json!(true)),
|
||||
("showCenter", json!(false)),
|
||||
("imageMap", json!("rectangle")),
|
||||
("url", json!("https://example.com")),
|
||||
("note", json!("hello")),
|
||||
("recentUrls", json!(["https://example.com"])),
|
||||
] {
|
||||
assert_eq!(i[k], v, "{k}");
|
||||
}
|
||||
let n = s.doc().unwrap().doc.node(a).unwrap().clone();
|
||||
let at = n.attrs.as_deref().unwrap();
|
||||
assert_eq!((at.show_center, at.image_map, n.url()), (Some(false), ImageMap::Rectangle, Some("https://example.com")));
|
||||
assert!(!n.shows_center());
|
||||
// One undo step takes everything back, and the attributes go away entirely.
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
let n = s.doc().unwrap().doc.node(a).unwrap().clone();
|
||||
assert!(n.attrs.is_none() && !n.has_overprint());
|
||||
// Nothing to change: no undo step; bad values are refused.
|
||||
let before = s.doc().unwrap().revision;
|
||||
assert_eq!(run(&mut s, "attributes.set", json!({"showCenter": true}))["changed"], 0);
|
||||
assert_eq!(s.doc().unwrap().revision, before);
|
||||
assert!(s.execute("attributes.set", &json!({"imageMap": "circle"})).is_err());
|
||||
assert!(s.execute("attributes.set", &json!({})).is_err());
|
||||
// Clearing the URL and note drops the attributes.
|
||||
run(&mut s, "attributes.set", json!({"url": "u", "note": "n"}));
|
||||
run(&mut s, "attributes.set", json!({"url": "", "note": ""}));
|
||||
assert!(s.doc().unwrap().doc.node(a).unwrap().attrs.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn values_that_differ_read_null_and_recent_urls_keep_the_newest_first() {
|
||||
let mut s = session();
|
||||
let a = rect(&mut s, 0.0);
|
||||
let b = rect(&mut s, 50.0);
|
||||
run(&mut s, "attributes.set", json!({"ids": [a.0], "url": "https://a.example", "note": "a"}));
|
||||
run(&mut s, "attributes.set", json!({"ids": [b.0], "url": "https://b.example"}));
|
||||
run(&mut s, "attributes.set", json!({"ids": [a.0], "url": "https://a.example"}));
|
||||
let i = info(&mut s, json!({"ids": [a.0, b.0]}));
|
||||
assert_eq!((i["url"].clone(), i["note"].clone(), i["imageMap"].clone()), (Value::Null, Value::Null, json!("none")));
|
||||
assert_eq!(i["recentUrls"], json!(["https://a.example", "https://b.example"]));
|
||||
// A pen path doesn't show its centre by default; a rectangle does.
|
||||
let p = NodeId(run(&mut s, "path.create", json!({"anchors": [{"x": 0, "y": 0}, {"x": 10, "y": 10}]}))["id"].as_u64().unwrap());
|
||||
assert_eq!(info(&mut s, json!({"ids": [p.0]}))["showCenter"], false);
|
||||
assert_eq!(info(&mut s, json!({"ids": [p.0, a.0]}))["showCenter"], Value::Null);
|
||||
}
|
||||
|
||||
/// A five-pointed star drawn as one self-intersecting path (its centre is wound twice).
|
||||
fn star(s: &mut Session) -> NodeId {
|
||||
let (c, r) = (50.0f64, 45.0f64);
|
||||
let anchors: Vec<Value> = (0..5)
|
||||
.map(|i| {
|
||||
let a = (-90.0 + 144.0 * i as f64).to_radians();
|
||||
json!({"x": c + r * a.cos(), "y": c + r * a.sin()})
|
||||
})
|
||||
.collect();
|
||||
let id = NodeId(run(s, "path.create", json!({"anchors": anchors, "closed": true}))["id"].as_u64().unwrap());
|
||||
run(s, "paint.setFill", json!({"ids": [id.0], "color": "#ff0000"}));
|
||||
run(s, "paint.setStroke", json!({"ids": [id.0], "none": true}));
|
||||
id
|
||||
}
|
||||
|
||||
fn red_at_centre(s: &Session) -> bool {
|
||||
let doc = s.doc().unwrap().doc.clone();
|
||||
let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() };
|
||||
let p = Renderer::new().render(&doc, 100, 100, Affine::IDENTITY, &opts).pixel(50, 52);
|
||||
p[0] > 200 && p[1] < 80
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_even_odd_star_has_a_hollow_centre() {
|
||||
let mut s = session();
|
||||
let id = star(&mut s);
|
||||
assert!(red_at_centre(&s), "non-zero fills the centre");
|
||||
assert_eq!(run(&mut s, "path.setFillRule", json!({"rule": "evenOdd"}))["changed"], 1);
|
||||
assert!(matches!(s.doc().unwrap().doc.node(id).unwrap().kind, NodeKind::Path { rule: FillRule::EvenOdd, .. }));
|
||||
assert_eq!(info(&mut s, json!({}))["fillRule"], "evenOdd");
|
||||
assert!(!red_at_centre(&s), "even-odd leaves it empty");
|
||||
assert_eq!(run(&mut s, "path.setFillRule", json!({"rule": "evenOdd"}))["changed"], 0);
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert!(red_at_centre(&s));
|
||||
assert!(s.execute("path.setFillRule", &json!({"rule": "odd"})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fill_rule_reaches_compound_paths_and_the_paths_in_groups() {
|
||||
let mut s = session();
|
||||
let a = rect(&mut s, 0.0);
|
||||
let b = rect(&mut s, 50.0);
|
||||
run(&mut s, "select.set", json!({"ids": [a.0, b.0]}));
|
||||
run(&mut s, "object.compoundPath.make", json!({}));
|
||||
let compound = s.doc().unwrap().selection.objects[0];
|
||||
let d = rect(&mut s, 0.0);
|
||||
run(&mut s, "select.set", json!({"ids": [compound.0, d.0]}));
|
||||
run(&mut s, "object.group", json!({}));
|
||||
assert_eq!(run(&mut s, "path.setFillRule", json!({"rule": "evenOdd"}))["changed"], 2, "the compound path and the rectangle");
|
||||
let doc = &s.doc().unwrap().doc;
|
||||
assert!(matches!(doc.node(compound).unwrap().kind, NodeKind::Compound { rule: FillRule::EvenOdd, .. }));
|
||||
assert!(matches!(doc.node(d).unwrap().kind, NodeKind::Path { rule: FillRule::EvenOdd, .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reverse_path_direction_on_and_off_set_the_winding() {
|
||||
let mut s = session();
|
||||
let a = rect(&mut s, 10.0);
|
||||
let ccw = |s: &Session| s.doc().unwrap().doc.node(a).unwrap().path_data().unwrap().subpaths[0].signed_area() < 0.0;
|
||||
let start = ccw(&s);
|
||||
assert_eq!(info(&mut s, json!({}))["reversed"], start);
|
||||
// Off / On set it; asking again changes nothing.
|
||||
assert_eq!(run(&mut s, "path.reverse", json!({"reversed": !start}))["changed"], 1);
|
||||
assert_eq!(ccw(&s), !start);
|
||||
assert_eq!(info(&mut s, json!({}))["reversed"], !start);
|
||||
assert_eq!(run(&mut s, "path.reverse", json!({"reversed": !start}))["changed"], 0);
|
||||
// Without `reversed` it flips.
|
||||
run(&mut s, "path.reverse", json!({}));
|
||||
assert_eq!(ccw(&s), start);
|
||||
run(&mut s, "select.none", json!({}));
|
||||
assert!(s.execute("path.reverse", &json!({})).is_err(), "nothing to reverse");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attributes_survive_a_native_round_trip() {
|
||||
let mut s = session();
|
||||
let a = rect(&mut s, 10.0);
|
||||
run(&mut s, "attributes.set", json!({"showCenter": false, "imageMap": "polygon", "url": "https://example.com/x", "note": "keep"}));
|
||||
let doc = s.doc().unwrap().doc.clone();
|
||||
let back = vectorcraft_format::load(&vectorcraft_format::save(&doc, false)).unwrap();
|
||||
assert_eq!(back.node(a).unwrap().attrs, doc.node(a).unwrap().attrs);
|
||||
assert!(back.node(a).unwrap().attrs.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn svg_export_links_objects_and_import_reads_the_links_back() {
|
||||
let mut s = session();
|
||||
let a = rect(&mut s, 10.0);
|
||||
rect(&mut s, 50.0);
|
||||
run(&mut s, "attributes.set", json!({"ids": [a.0], "url": "https://example.com/?a=1&b=2"}));
|
||||
let svg = vectorcraft_svg::export(&s.doc().unwrap().doc, &Default::default());
|
||||
assert_eq!(svg.matches("<a ").count(), 1, "{svg}");
|
||||
assert!(svg.contains("xlink:href=\"https://example.com/?a=1&b=2\""), "{svg}");
|
||||
let back = vectorcraft_svg::import(&svg).unwrap();
|
||||
let mut urls = vec![];
|
||||
back.walk(|n| {
|
||||
if let NodeKind::Path { .. } = n.kind {
|
||||
urls.push(n.url().map(str::to_string));
|
||||
}
|
||||
});
|
||||
assert_eq!(urls, [Some("https://example.com/?a=1&b=2".to_string()), None], "the link wraps the path alone");
|
||||
}
|
||||
|
|
@ -221,6 +221,16 @@ impl SubPath {
|
|||
*a = a.reversed();
|
||||
}
|
||||
}
|
||||
/// Signed area, closing an open subpath with a line: positive when it runs clockwise on
|
||||
/// screen (y down), negative when counter-clockwise.
|
||||
pub fn signed_area(&self) -> f64 {
|
||||
use kurbo::ParamCurveArea;
|
||||
let a: f64 = (0..self.segment_count()).map(|i| self.segment(i).signed_area()).sum();
|
||||
match (self.closed, self.anchors.first(), self.anchors.last()) {
|
||||
(false, Some(f), Some(l)) => a + kurbo::Line::new(l.p, f.p).signed_area(),
|
||||
_ => a,
|
||||
}
|
||||
}
|
||||
/// Split segment `seg` at parameter `t`, inserting a new anchor. Returns the new anchor index.
|
||||
pub fn insert_anchor(&mut self, seg: usize, t: f64) -> usize {
|
||||
let n = self.anchors.len();
|
||||
|
|
|
|||
|
|
@ -685,7 +685,7 @@ impl Writer<'_> {
|
|||
self.depth += 1;
|
||||
let mut bare = n.clone();
|
||||
bare.mask = None;
|
||||
self.node(&bare);
|
||||
self.node_body(&bare);
|
||||
self.depth -= 1;
|
||||
self.line("</g>");
|
||||
}
|
||||
|
|
@ -769,7 +769,21 @@ impl Writer<'_> {
|
|||
mid
|
||||
}
|
||||
|
||||
/// An object, inside `<a>` when it links to a URL (Attributes panel).
|
||||
fn node(&mut self, n: &Node) {
|
||||
match n.url().filter(|_| n.visible && !self.anonymous) {
|
||||
Some(url) => {
|
||||
self.line(&format!("<a xlink:href=\"{}\">", xml_escape(url)));
|
||||
self.depth += 1;
|
||||
self.node_body(n);
|
||||
self.depth -= 1;
|
||||
self.line("</a>");
|
||||
}
|
||||
None => self.node_body(n),
|
||||
}
|
||||
}
|
||||
|
||||
fn node_body(&mut self, n: &Node) {
|
||||
if !n.visible {
|
||||
return;
|
||||
}
|
||||
|
|
@ -887,7 +901,7 @@ impl Writer<'_> {
|
|||
// Live blends/envelopes/meshes export their evaluated (expanded) form.
|
||||
NodeKind::Blend { .. } | NodeKind::Envelope { .. } | NodeKind::Mesh(_) | NodeKind::Repeat(_) => {
|
||||
let g = vectorcraft_doc::live::expand_deep(n, None);
|
||||
self.node(&g);
|
||||
self.node_body(&g);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -898,7 +912,7 @@ impl Writer<'_> {
|
|||
let mut inner = n.clone();
|
||||
inner.appearance.effects.retain(|e| !vectorcraft_effects::is_raster(&e.id));
|
||||
let opened = self.open_filters(n, &n.appearance.effects);
|
||||
self.node(&inner);
|
||||
self.node_body(&inner);
|
||||
self.close_filters(opened);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -15,6 +15,8 @@ use vectorcraft_geom::{Affine, BezPath, FillRule, PathData, Point, Vec2};
|
|||
use crate::SvgError;
|
||||
|
||||
pub(crate) fn import(svg: &str) -> Result<(Document, Vec<String>), SvgError> {
|
||||
let (svg, links) = link_ids(svg);
|
||||
let svg = svg.as_ref();
|
||||
let opt = usvg::Options::default();
|
||||
let tree = usvg::Tree::from_str(svg, &opt).map_err(|e| SvgError::Parse(e.to_string()))?;
|
||||
let size = tree.size();
|
||||
|
|
@ -22,7 +24,7 @@ pub(crate) fn import(svg: &str) -> Result<(Document, Vec<String>), SvgError> {
|
|||
// absolute units is that physical size (usvg resolves it to 96 px per inch, i.e. 4/3 px per pt).
|
||||
let (kx, ky) = physical_scale(svg);
|
||||
let doc = Document::new(size.width() as f64 * kx, size.height() as f64 * ky);
|
||||
let mut im = Importer { doc, warnings: Vec::new(), mask_flags: mask_flags(svg) };
|
||||
let mut im = Importer { doc, warnings: Vec::new(), mask_flags: mask_flags(svg), links };
|
||||
|
||||
// usvg wraps everything in an id-less group carrying the viewBox transform when needed.
|
||||
let mut top = tree.root();
|
||||
|
|
@ -37,8 +39,8 @@ pub(crate) fn import(svg: &str) -> Result<(Document, Vec<String>), SvgError> {
|
|||
|
||||
// Top-level `<g id>` elements become layers (Illustrator does the same); otherwise all art goes
|
||||
// into "Layer 1".
|
||||
let layer_mode =
|
||||
!top.children().is_empty() && top.children().iter().all(|c| matches!(c, usvg::Node::Group(g) if !g.id().is_empty() && is_plain(g)));
|
||||
let layer_mode = !top.children().is_empty()
|
||||
&& top.children().iter().all(|c| matches!(c, usvg::Node::Group(g) if !g.id().is_empty() && !im.links.contains_key(g.id()) && is_plain(g)));
|
||||
if layer_mode {
|
||||
im.doc.layers.clear();
|
||||
for (i, c) in top.children().iter().enumerate() {
|
||||
|
|
@ -87,6 +89,8 @@ struct Importer {
|
|||
warnings: Vec<String>,
|
||||
/// Opacity-mask options our export wrote, by mask id (see [`mask_flags`]).
|
||||
mask_flags: HashMap<String, (bool, bool)>,
|
||||
/// The URL of each `<a href>`'s group, by its id ([`link_ids`]).
|
||||
links: HashMap<String, String>,
|
||||
}
|
||||
|
||||
/// The options [`crate::export::MASK_FLAGS`] records on exported `<mask>` elements, by mask id:
|
||||
|
|
@ -106,6 +110,61 @@ fn mask_flags(svg: &str) -> HashMap<String, (bool, bool)> {
|
|||
xml.descendants().filter(|n| n.tag_name().name() == "mask" && n.has_attribute(attr)).filter_map(flags).collect()
|
||||
}
|
||||
|
||||
/// Prefix of the ids [`link_ids`] gives the `<a>` elements that have none.
|
||||
const LINK_ID: &str = "vectorcraft-link-";
|
||||
|
||||
/// The URL an `<a>` element links to.
|
||||
fn href<'a>(a: XNode<'a, '_>) -> Option<&'a str> {
|
||||
a.attribute("href").or_else(|| a.attribute(("http://www.w3.org/1999/xlink", "href"))).filter(|h| !h.is_empty())
|
||||
}
|
||||
|
||||
/// usvg reads `<a href>` as a plain group and drops the URL. So that the group can be found, every
|
||||
/// link without an id gets one ([`LINK_ID`]…); returns the SVG with those ids and the URLs by id.
|
||||
fn link_ids(svg: &str) -> (std::borrow::Cow<'_, str>, HashMap<String, String>) {
|
||||
let mut links = HashMap::new();
|
||||
if !svg.contains("<a") {
|
||||
return (svg.into(), links);
|
||||
}
|
||||
let Ok(xml) = roxmltree::Document::parse_with_options(svg, roxmltree::ParsingOptions { allow_dtd: true, ..Default::default() }) else {
|
||||
return (svg.into(), links);
|
||||
};
|
||||
// Where to insert each new id: right after the tag name.
|
||||
let mut inserts = vec![];
|
||||
for a in xml.descendants().filter(|n| n.is_element() && n.tag_name().name() == "a") {
|
||||
let Some(url) = href(a) else { continue };
|
||||
let id = match a.attribute("id") {
|
||||
Some(id) => id.to_string(),
|
||||
None => {
|
||||
let start = a.range().start;
|
||||
let end = svg[start + 1..].find(|c: char| c.is_whitespace() || c == '/' || c == '>').map_or(start + 2, |i| start + 1 + i);
|
||||
let id = format!("{LINK_ID}{}", inserts.len());
|
||||
inserts.push((end, id.clone()));
|
||||
id
|
||||
}
|
||||
};
|
||||
links.insert(id, url.to_string());
|
||||
}
|
||||
if inserts.is_empty() {
|
||||
return (svg.into(), links);
|
||||
}
|
||||
let mut out = String::with_capacity(svg.len() + inserts.len() * 32);
|
||||
let mut last = 0;
|
||||
for (at, id) in inserts {
|
||||
out.push_str(&svg[last..at]);
|
||||
out.push_str(&format!(" id=\"{id}\""));
|
||||
last = at;
|
||||
}
|
||||
out.push_str(&svg[last..]);
|
||||
(out.into(), links)
|
||||
}
|
||||
|
||||
/// Make `n` link to `url`, unless it links somewhere already (an inner link wins).
|
||||
fn link(n: &mut Node, url: &str) {
|
||||
if n.url().is_none() {
|
||||
n.edit_attrs(|a| a.url = url.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
fn aff(t: usvg::Transform) -> Affine {
|
||||
Affine::new([t.sx as f64, t.ky as f64, t.kx as f64, t.sy as f64, t.tx as f64, t.ty as f64])
|
||||
}
|
||||
|
|
@ -199,8 +258,19 @@ impl Importer {
|
|||
}
|
||||
|
||||
fn group(&mut self, g: &usvg::Group, acc: Affine) -> Option<Node> {
|
||||
let url = self.links.get(g.id()).cloned();
|
||||
let mut n = self.group_node(g, acc)?;
|
||||
if let Some(url) = url {
|
||||
link(&mut n, &url);
|
||||
}
|
||||
Some(n)
|
||||
}
|
||||
|
||||
fn group_node(&mut self, g: &usvg::Group, acc: Affine) -> Option<Node> {
|
||||
let ts = acc * aff(g.transform());
|
||||
let label = if g.id().is_empty() { "a group".to_string() } else { format!("'{}'", g.id()) };
|
||||
// The made-up ids of links aren't names.
|
||||
let id = if g.id().starts_with(LINK_ID) { "" } else { g.id() };
|
||||
let label = if id.is_empty() { "a group".to_string() } else { format!("'{id}'") };
|
||||
let mask = g.mask().and_then(|m| self.opacity_mask(m, ts, &label));
|
||||
if !g.filters().is_empty() {
|
||||
self.warn(format!("filter on {label} ignored"));
|
||||
|
|
@ -213,14 +283,14 @@ impl Importer {
|
|||
let clip = self.clip_node(cp, ts)?;
|
||||
let mut ch = vec![Arc::new(clip)];
|
||||
ch.extend(children);
|
||||
self.named(g.id(), NodeKind::Group { children: ch, clip: true })
|
||||
self.named(id, NodeKind::Group { children: ch, clip: true })
|
||||
} else {
|
||||
if children.is_empty() {
|
||||
return None;
|
||||
}
|
||||
// An id-less wrapper around a single object (usvg adds these for opacity/transform on
|
||||
// shapes): fold its opacity and blend into the object.
|
||||
if g.id().is_empty()
|
||||
if id.is_empty()
|
||||
&& mask.is_none()
|
||||
&& children.len() == 1
|
||||
&& (g.blend_mode() == usvg::BlendMode::Normal || children[0].blend == BlendMode::Normal)
|
||||
|
|
@ -233,7 +303,7 @@ impl Importer {
|
|||
c.isolate |= g.isolate();
|
||||
return Some(c);
|
||||
}
|
||||
self.named(g.id(), NodeKind::Group { children, clip: false })
|
||||
self.named(id, NodeKind::Group { children, clip: false })
|
||||
};
|
||||
n.opacity = g.opacity().get();
|
||||
n.blend = blend(g.blend_mode());
|
||||
|
|
@ -799,6 +869,9 @@ fn text_fallback(im: &mut Importer, svg: &str, w: f64, h: f64, (kx, ky): (f64, f
|
|||
};
|
||||
obj.para.justify = justify;
|
||||
let mut node = im.named(t.attribute("id").unwrap_or(""), NodeKind::Text(Box::new(obj)));
|
||||
if let Some(url) = chain.iter().filter(|a| a.tag_name().name() == "a").find_map(|a| href(*a)) {
|
||||
link(&mut node, url);
|
||||
}
|
||||
if let Some(o) = ctx.own(t, "opacity").and_then(|o| o.trim().parse::<f32>().ok()) {
|
||||
node.opacity = o.clamp(0.0, 1.0);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -886,6 +886,12 @@ fn selection_overlay(app: &VectorcraftApp, p: &egui::Painter, xf: &Xf) {
|
|||
);
|
||||
}
|
||||
});
|
||||
// Centre point (Attributes panel → Show Center).
|
||||
if n.shows_center()
|
||||
&& let Some(b) = n.geometric_bounds()
|
||||
{
|
||||
anchor_square(p, xf.to_screen(b.center()), color, true, 4.0);
|
||||
}
|
||||
// Text: baseline marker.
|
||||
if let NodeKind::Text(tx) = &n.kind {
|
||||
let o = xf.to_screen(tx.xf * Point::ZERO);
|
||||
|
|
|
|||
|
|
@ -54,12 +54,16 @@ pub static ICONS: &[(&str, &[u8])] = &[
|
|||
("dc-cap-butt", include_bytes!("../../../assets/icons/dc-cap-butt.svg")),
|
||||
("dc-cap-round", include_bytes!("../../../assets/icons/dc-cap-round.svg")),
|
||||
("dc-cap-square", include_bytes!("../../../assets/icons/dc-cap-square.svg")),
|
||||
("dc-center-hide", include_bytes!("../../../assets/icons/dc-center-hide.svg")),
|
||||
("dc-center-show", include_bytes!("../../../assets/icons/dc-center-show.svg")),
|
||||
("dc-clear", include_bytes!("../../../assets/icons/dc-clear.svg")),
|
||||
("dc-color-guide", include_bytes!("../../../assets/icons/dc-color-guide.svg")),
|
||||
("dc-crystallize", include_bytes!("../../../assets/icons/dc-crystallize.svg")),
|
||||
("dc-cube", include_bytes!("../../../assets/icons/dc-cube.svg")),
|
||||
("dc-dash-align", include_bytes!("../../../assets/icons/dc-dash-align.svg")),
|
||||
("dc-dash-exact", include_bytes!("../../../assets/icons/dc-dash-exact.svg")),
|
||||
("dc-dir-off", include_bytes!("../../../assets/icons/dc-dir-off.svg")),
|
||||
("dc-dir-on", include_bytes!("../../../assets/icons/dc-dir-on.svg")),
|
||||
("dc-direct", include_bytes!("../../../assets/icons/dc-direct.svg")),
|
||||
("dc-dist-bottom", include_bytes!("../../../assets/icons/dc-dist-bottom.svg")),
|
||||
("dc-dist-hcenter", include_bytes!("../../../assets/icons/dc-dist-hcenter.svg")),
|
||||
|
|
@ -137,6 +141,8 @@ pub static ICONS: &[(&str, &[u8])] = &[
|
|||
("dc-reverse", include_bytes!("../../../assets/icons/dc-reverse.svg")),
|
||||
("dc-rotate-view", include_bytes!("../../../assets/icons/dc-rotate-view.svg")),
|
||||
("dc-rounded-rect", include_bytes!("../../../assets/icons/dc-rounded-rect.svg")),
|
||||
("dc-rule-evenodd", include_bytes!("../../../assets/icons/dc-rule-evenodd.svg")),
|
||||
("dc-rule-nonzero", include_bytes!("../../../assets/icons/dc-rule-nonzero.svg")),
|
||||
("dc-scallop", include_bytes!("../../../assets/icons/dc-scallop.svg")),
|
||||
("dc-screen-mode", include_bytes!("../../../assets/icons/dc-screen-mode.svg")),
|
||||
("dc-selection", include_bytes!("../../../assets/icons/dc-selection.svg")),
|
||||
|
|
|
|||
|
|
@ -315,6 +315,12 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
|
|||
"",
|
||||
"{} open Expand for the selection (dialog `expand`, fields object, fill, stroke (all on; one the selection has nothing for is disabled, see object.expand.info), gradient: objects|mesh, steps 1..1000 (255)): OK runs object.expand with them as one undo step",
|
||||
),
|
||||
(
|
||||
"attributes.openUrl",
|
||||
"Browser",
|
||||
"",
|
||||
"{url?} open url, else the selection's URL (attributes.info), in the web browser (the Attributes panel's Browser button) → {url}",
|
||||
),
|
||||
];
|
||||
|
||||
/// Handle a UI command. `None` = not a UI command (the engine handles it).
|
||||
|
|
@ -682,6 +688,7 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option<R
|
|||
None => Err("no such user library".into()),
|
||||
},
|
||||
"ui.expandDialog" => crate::dialogs::expand::open(app),
|
||||
"attributes.openUrl" => crate::panels::attributes::open_url(app, p),
|
||||
_ => return None,
|
||||
};
|
||||
Some(r)
|
||||
|
|
@ -1417,7 +1424,7 @@ pub fn menu_tree() -> Vec<(&'static str, Vec<Item>)> {
|
|||
panel("Appearance", "appearance"),
|
||||
panel("Artboards", "artboards"),
|
||||
todo("Asset Export"),
|
||||
todo("Attributes"),
|
||||
panel("Attributes", "attributes"),
|
||||
panel("Brushes", "brushes"),
|
||||
panel("Color", "color"),
|
||||
panel("Color Guide", "colorGuide"),
|
||||
|
|
|
|||
|
|
@ -0,0 +1,226 @@
|
|||
//! Attributes panel: Overprint Fill / Overprint Stroke, Show / Don't Show Center, Reverse Path
|
||||
//! Direction Off / On, the fill rule (non-zero winding or even-odd), Image Map, URL (with the
|
||||
//! recent URLs and a Browser button) and the note. The menu hides the options and shows the note.
|
||||
//! Everything reads `attributes.info` ([`vectorcraft_engine::Session::attributes_info`]) and acts
|
||||
//! through `attributes.set`, `path.reverse` and `path.setFillRule`.
|
||||
|
||||
use egui::Ui;
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::{ImageMap, NodeId, NodeKind};
|
||||
use vectorcraft_engine::cmd::AttributesInfo;
|
||||
use vectorcraft_geom::FillRule;
|
||||
|
||||
use super::{pstate, selection_len, set_pstate};
|
||||
use crate::VectorcraftApp;
|
||||
use crate::widgets::{self, menu_item};
|
||||
|
||||
/// Panel state: the options (centre, direction, fill rule, image map, URL) are hidden.
|
||||
const HIDE_OPTIONS: &str = "attr-hide-options";
|
||||
/// Panel state: the note field is hidden.
|
||||
const HIDE_NOTE: &str = "attr-hide-note";
|
||||
const LABEL_W: f32 = 70.0;
|
||||
|
||||
/// The panel's values and whether the targets hold paths (direction and fill rule apply),
|
||||
/// recomputed only when the document or the selection changes.
|
||||
fn state(app: &VectorcraftApp, ctx: &egui::Context) -> (AttributesInfo, bool) {
|
||||
type Key = (u64, u64, usize, Option<NodeId>, Option<NodeId>);
|
||||
let Some(st) = app.session.active() else { return Default::default() };
|
||||
let sel = &st.selection.objects;
|
||||
let key: Key = (st.uid, st.revision, sel.len(), sel.first().copied(), sel.last().copied());
|
||||
let id = egui::Id::new("attr-state");
|
||||
if let Some((k, v)) = ctx.data(|d| d.get_temp::<(Key, (AttributesInfo, bool))>(id))
|
||||
&& k == key
|
||||
{
|
||||
return v;
|
||||
}
|
||||
let info = app.session.attributes_info();
|
||||
let mut paths = false;
|
||||
for n in info.ids.iter().filter_map(|id| st.doc.node(*id)) {
|
||||
n.walk(&mut |c| paths |= matches!(c.kind, NodeKind::Path { guide: false, .. }));
|
||||
}
|
||||
let v = (info, paths);
|
||||
ctx.data_mut(|d| d.insert_temp(id, (key, v.clone())));
|
||||
v
|
||||
}
|
||||
|
||||
/// Run `attributes.set` with `p` on the selection (errors show in the status bar).
|
||||
fn set(app: &mut VectorcraftApp, p: Value) {
|
||||
app.run("attributes.set", p).ok();
|
||||
}
|
||||
|
||||
/// A pair of icon buttons choosing one of two values (`current`: the targets' value, `None` where
|
||||
/// they differ); returns the value clicked.
|
||||
fn pair<T: Copy + PartialEq>(ui: &mut Ui, enabled: bool, current: Option<T>, items: [(&str, &str, T); 2]) -> Option<T> {
|
||||
let mut clicked = None;
|
||||
for (icon, tip, v) in items {
|
||||
if widgets::icon_button_enabled(ui, icon, tip, enabled && current == Some(v), enabled, 24.0).clicked() {
|
||||
clicked = Some(v);
|
||||
}
|
||||
}
|
||||
clicked
|
||||
}
|
||||
|
||||
pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
let (info, paths) = state(app, ui.ctx());
|
||||
let has = !info.ids.is_empty();
|
||||
ui.horizontal(|ui| {
|
||||
for (label, key, value) in
|
||||
[("Overprint Fill", "overprintFill", info.overprint_fill), ("Overprint Stroke", "overprintStroke", info.overprint_stroke)]
|
||||
{
|
||||
if widgets::check3(ui, label, value, has) {
|
||||
set(app, json!({ key: value != Some(true) }));
|
||||
}
|
||||
ui.add_space(8.0);
|
||||
}
|
||||
});
|
||||
if !pstate::<bool>(ui.ctx(), HIDE_OPTIONS) {
|
||||
widgets::divider(ui);
|
||||
ui.horizontal(|ui| {
|
||||
let center = [("dc-center-hide", "Don't Show Center", false), ("dc-center-show", "Show Center", true)];
|
||||
if let Some(v) = pair(ui, has, info.show_center, center) {
|
||||
set(app, json!({ "showCenter": v }));
|
||||
}
|
||||
ui.add_space(10.0);
|
||||
let dir = [("dc-dir-off", "Reverse Path Direction Off", false), ("dc-dir-on", "Reverse Path Direction On", true)];
|
||||
if let Some(v) = pair(ui, paths, info.reversed, dir) {
|
||||
app.run("path.reverse", json!({ "reversed": v })).ok();
|
||||
}
|
||||
ui.add_space(10.0);
|
||||
let rules = [
|
||||
("dc-rule-nonzero", "Use Non-Zero Winding Fill Rule", FillRule::NonZero),
|
||||
("dc-rule-evenodd", "Use Even-Odd Fill Rule", FillRule::EvenOdd),
|
||||
];
|
||||
if let Some(r) = pair(ui, paths, info.fill_rule, rules) {
|
||||
app.run("path.setFillRule", json!({ "rule": if r == FillRule::EvenOdd { "evenOdd" } else { "nonZero" } })).ok();
|
||||
}
|
||||
});
|
||||
ui.add_space(4.0);
|
||||
let url = info.url.clone().filter(|u| !u.is_empty());
|
||||
widgets::label_row(ui, "Image Map:", LABEL_W, |ui| {
|
||||
ui.add_enabled_ui(has, |ui| {
|
||||
let labels = ImageMap::ALL.map(ImageMap::label);
|
||||
if let Some(i) = widgets::dropdown(ui, "attr-map", info.image_map.map_or("", ImageMap::label), &labels, 100.0) {
|
||||
set(app, json!({ "imageMap": ImageMap::ALL[i] }));
|
||||
}
|
||||
});
|
||||
ui.add_space(6.0);
|
||||
let r = ui.add_enabled_ui(url.is_some(), |ui| widgets::flat_button(ui, "Browser", 64.0)).inner;
|
||||
if r.on_hover_text("Open the URL in the web browser").clicked() {
|
||||
app.run("attributes.openUrl", json!({})).ok();
|
||||
}
|
||||
});
|
||||
widgets::label_row(ui, "URL:", LABEL_W, |ui| {
|
||||
ui.add_enabled_ui(has, |ui| {
|
||||
let width = (ui.available_width() - 26.0).max(80.0);
|
||||
if let Some(u) = widgets::text_field(ui, "attr-url", info.url.as_deref(), width, 1) {
|
||||
set(app, json!({ "url": u }));
|
||||
}
|
||||
recent_urls(app, ui);
|
||||
});
|
||||
});
|
||||
}
|
||||
if !pstate::<bool>(ui.ctx(), HIDE_NOTE) {
|
||||
widgets::divider(ui);
|
||||
widgets::subheader(ui, "Note:");
|
||||
ui.add_enabled_ui(has, |ui| {
|
||||
if let Some(n) = widgets::text_field(ui, "attr-note", info.note.as_deref(), ui.available_width(), 3) {
|
||||
set(app, json!({ "note": n }));
|
||||
}
|
||||
});
|
||||
}
|
||||
if !has {
|
||||
widgets::dim_label(ui, if selection_len(app) == 0 { "No Selection" } else { "" });
|
||||
}
|
||||
}
|
||||
|
||||
/// The recent URLs button: a menu of the URLs given lately; picking one sets it.
|
||||
fn recent_urls(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
let resp = widgets::icon_button_enabled(ui, "chevron-down", "Recent URLs", false, !app.session.recent_urls.is_empty(), 20.0);
|
||||
let mut chosen = None;
|
||||
egui::Popup::menu(&resp).show(|ui| {
|
||||
ui.set_min_width(220.0);
|
||||
for u in &app.session.recent_urls {
|
||||
if menu_item(ui, u, true, false) {
|
||||
chosen = Some(u.clone());
|
||||
}
|
||||
}
|
||||
});
|
||||
if let Some(u) = chosen {
|
||||
set(app, json!({ "url": u }));
|
||||
}
|
||||
}
|
||||
|
||||
pub fn menu(_app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
for (key, hide, show) in [(HIDE_OPTIONS, "Hide Options", "Show Options"), (HIDE_NOTE, "Hide Note", "Show Note")] {
|
||||
let hidden: bool = pstate(ui.ctx(), key);
|
||||
if menu_item(ui, if hidden { show } else { hide }, true, false) {
|
||||
set_pstate(ui.ctx(), key, !hidden);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `attributes.openUrl {url?}`: open `url`, else the selection's URL, in the web browser.
|
||||
pub(crate) fn open_url(app: &mut VectorcraftApp, p: &Value) -> Result<Value, String> {
|
||||
let url = match p.get("url").and_then(Value::as_str) {
|
||||
Some(u) => u.to_string(),
|
||||
None => app.session.attributes_info().url.filter(|u| !u.is_empty()).ok_or("the selection has no URL (or they differ)")?,
|
||||
};
|
||||
app.open_url(&url);
|
||||
Ok(json!({ "url": url }))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use vectorcraft_engine::Session;
|
||||
|
||||
/// Run the panel and its menu for one headless frame: the texts drawn.
|
||||
fn frame(app: &mut VectorcraftApp) -> Vec<String> {
|
||||
fn texts(s: &egui::Shape, out: &mut Vec<String>) {
|
||||
match s {
|
||||
egui::Shape::Text(t) => out.push(t.galley.text().to_string()),
|
||||
egui::Shape::Vec(v) => v.iter().for_each(|s| texts(s, out)),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let ctx = egui::Context::default();
|
||||
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| {
|
||||
show(app, ui);
|
||||
menu(app, ui);
|
||||
});
|
||||
out.textures_delta.clear();
|
||||
let mut v = vec![];
|
||||
out.shapes.iter().for_each(|c| texts(&c.shape, &mut v));
|
||||
v
|
||||
}
|
||||
|
||||
fn run(app: &mut VectorcraftApp, id: &str, p: Value) -> Value {
|
||||
app.session.execute(id, &p).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn panel_shows_the_selections_attributes() {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
run(&mut app, "file.new", json!({"width": 100, "height": 100}));
|
||||
let texts = frame(&mut app);
|
||||
assert!(texts.iter().any(|t| t == "No Selection") && texts.iter().any(|t| t == "Overprint Fill"), "{texts:?}");
|
||||
run(&mut app, "shape.rectangle", json!({"x": 0, "y": 0, "width": 50, "height": 50}));
|
||||
run(&mut app, "attributes.set", json!({"url": "https://example.com/a", "note": "first note", "imageMap": "polygon"}));
|
||||
let texts = frame(&mut app);
|
||||
for want in ["https://example.com/a", "first note", "Polygon", "Image Map:", "URL:", "Browser", " Hide Options", " Hide Note"] {
|
||||
assert!(texts.iter().any(|t| t == want), "{want} in {texts:?}");
|
||||
}
|
||||
assert!(!texts.iter().any(|t| t == "No Selection"));
|
||||
// The URL button opens the selection's URL.
|
||||
let (info, paths) = state(&app, &egui::Context::default());
|
||||
assert!(paths && info.url.as_deref() == Some("https://example.com/a"));
|
||||
assert_eq!(open_url(&mut app, &json!({})).unwrap()["url"], "https://example.com/a");
|
||||
// Two objects whose values differ show blanks.
|
||||
let b = run(&mut app, "shape.ellipse", json!({"x": 60, "y": 0, "width": 30, "height": 30}))["id"].clone();
|
||||
let a = app.session.active().unwrap().doc.layers[0].children().unwrap()[0].id.0;
|
||||
run(&mut app, "select.set", json!({"ids": [a, b]}));
|
||||
let texts = frame(&mut app);
|
||||
assert!(!texts.iter().any(|t| t == "https://example.com/a" || t == "first note"), "{texts:?}");
|
||||
assert!(open_url(&mut app, &json!({})).is_err());
|
||||
}
|
||||
}
|
||||
|
|
@ -7,6 +7,7 @@ pub mod actions;
|
|||
pub mod align;
|
||||
pub mod appearance;
|
||||
pub mod artboards;
|
||||
pub mod attributes;
|
||||
pub mod brushes;
|
||||
pub mod character;
|
||||
pub mod color;
|
||||
|
|
@ -90,6 +91,7 @@ pub fn show_icon_panel(app: &mut VectorcraftApp, ui: &mut Ui, id: &str) {
|
|||
"magicWand" => magic_wand::show(app, ui),
|
||||
"tabs" => tabs::show(app, ui),
|
||||
flattener_preview::ID => flattener_preview::show(app, ui),
|
||||
"attributes" => attributes::show(app, ui),
|
||||
_ => {
|
||||
dim_label(ui, "This panel is on the roadmap (see the parity plan).");
|
||||
}
|
||||
|
|
@ -129,6 +131,7 @@ pub fn panel_menu_items(app: &mut VectorcraftApp, ui: &mut Ui, id: &str) {
|
|||
"magicWand" => magic_wand::menu(app, ui),
|
||||
"tabs" => tabs::menu(app, ui),
|
||||
flattener_preview::ID => flattener_preview::menu(app, ui),
|
||||
"attributes" => attributes::menu(app, ui),
|
||||
_ => {
|
||||
ui.add_enabled(false, egui::Button::new("No options").frame(false));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,12 +23,7 @@ fn editing(app: &VectorcraftApp) -> Option<(PatternDef, Unit)> {
|
|||
}
|
||||
|
||||
fn row(ui: &mut Ui, label: &str, add: impl FnOnce(&mut Ui)) {
|
||||
ui.horizontal(|ui| {
|
||||
let (r, _) = ui.allocate_exact_size(egui::vec2(96.0, 24.0), egui::Sense::hover());
|
||||
let t = crate::theme::Tokens::get(ui.ctx());
|
||||
ui.painter().text(r.left_center(), egui::Align2::LEFT_CENTER, label, egui::FontId::proportional(12.5), t.text);
|
||||
add(ui);
|
||||
});
|
||||
widgets::label_row(ui, label, 96.0, add);
|
||||
}
|
||||
|
||||
fn set(app: &mut VectorcraftApp, name: &str, mut p: Value) {
|
||||
|
|
|
|||
|
|
@ -89,6 +89,7 @@ pub const PANEL_SHORTCUTS: &[(&str, &str)] = &[
|
|||
("stroke", "Cmd+F10"),
|
||||
("gradient", "Cmd+F9"),
|
||||
("transparency", "Cmd+Shift+F10"),
|
||||
("attributes", "Cmd+F11"),
|
||||
];
|
||||
|
||||
/// Default shortcut of an entry key (`tool:<id>`, `panel:<id>` or a command id).
|
||||
|
|
|
|||
|
|
@ -89,6 +89,7 @@ pub const ICON_PANELS: &[(&str, &str, &str)] = &[
|
|||
("imageTrace", "Image Trace", "image"),
|
||||
("magicWand", "Magic Wand", "wand-sparkles"),
|
||||
(crate::panels::flattener_preview::ID, "Flattener Preview", "eye"),
|
||||
("attributes", "Attributes", "settings"),
|
||||
];
|
||||
|
||||
/// Groups of icon panels separated by dividers in the collapsed column.
|
||||
|
|
|
|||
|
|
@ -130,6 +130,59 @@ pub fn num_field(ui: &mut Ui, id: impl std::hash::Hash + std::fmt::Debug, value:
|
|||
if commit { unit.parse(&buf) } else { None }
|
||||
}
|
||||
|
||||
/// The recessed text box of the panel fields showing `shown`, `rows` lines tall (1: single line),
|
||||
/// its text kept under `id` while it has focus. Returns the text (as edited) and the response.
|
||||
fn recessed_text(ui: &mut Ui, id: egui::Id, shown: &str, width: f32, rows: usize) -> (String, Response) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let editing = ui.memory(|m| m.has_focus(id));
|
||||
let mut buf: String = if editing { ui.data_mut(|d| d.get_temp::<String>(id)).unwrap_or_else(|| shown.to_string()) } else { shown.to_string() };
|
||||
let height = 26.0 + 16.0 * (rows.max(1) - 1) as f32;
|
||||
let resp = ui
|
||||
.allocate_ui_with_layout(vec2(width, height), egui::Layout::left_to_right(egui::Align::Center), |ui| {
|
||||
ui.set_min_width(width);
|
||||
egui::Frame::NONE
|
||||
.fill(t.input)
|
||||
.stroke(Stroke::new(1.0, if editing { t.accent } else { t.input_border }))
|
||||
.corner_radius(CornerRadius::same(2))
|
||||
.inner_margin(egui::Margin::symmetric(6, 4))
|
||||
.show(ui, |ui| {
|
||||
let edit = if rows > 1 { egui::TextEdit::multiline(&mut buf).desired_rows(rows) } else { egui::TextEdit::singleline(&mut buf) };
|
||||
ui.add(
|
||||
edit.id(id)
|
||||
.frame(egui::Frame::NONE)
|
||||
.desired_width(width - 14.0)
|
||||
.min_size(vec2(width - 14.0, 0.0))
|
||||
.font(egui::FontId::proportional(12.5))
|
||||
.text_color(t.text_strong),
|
||||
)
|
||||
})
|
||||
.inner
|
||||
})
|
||||
.inner;
|
||||
ui.data_mut(|d| d.insert_temp(id, buf.clone()));
|
||||
(buf, resp)
|
||||
}
|
||||
|
||||
/// A recessed text field showing `value` (blank when `None`: the selection's values differ),
|
||||
/// `rows` lines tall (1: single line, which Enter commits). Returns the new text, trimmed, when a
|
||||
/// change is committed (Enter or focus loss).
|
||||
pub fn text_field(ui: &mut Ui, id: impl std::hash::Hash + std::fmt::Debug, value: Option<&str>, width: f32, rows: usize) -> Option<String> {
|
||||
let id = ui.id().with(id);
|
||||
let shown = value.unwrap_or_default();
|
||||
let (buf, resp) = recessed_text(ui, id, shown, width, rows);
|
||||
(resp.lost_focus() && buf.trim() != shown).then(|| buf.trim().to_string())
|
||||
}
|
||||
|
||||
/// A form row: `label` in a column `label_width` wide, then what `add` draws.
|
||||
pub fn label_row(ui: &mut Ui, label: &str, label_width: f32, add: impl FnOnce(&mut Ui)) {
|
||||
ui.horizontal(|ui| {
|
||||
let (r, _) = ui.allocate_exact_size(vec2(label_width, 24.0), Sense::hover());
|
||||
let t = Tokens::get(ui.ctx());
|
||||
ui.painter().text(r.left_center(), egui::Align2::LEFT_CENTER, label, egui::FontId::proportional(12.5), t.text);
|
||||
add(ui);
|
||||
});
|
||||
}
|
||||
|
||||
/// A plain number field (percent, degrees, counts) with optional suffix.
|
||||
pub fn plain_field(ui: &mut Ui, id: impl std::hash::Hash + std::fmt::Debug, value: f64, suffix: &str, decimals: usize, width: f32) -> Option<f64> {
|
||||
mixed_field(ui, id, Some(value), suffix, decimals, width)
|
||||
|
|
@ -144,7 +197,6 @@ pub fn mixed_field(
|
|||
decimals: usize,
|
||||
width: f32,
|
||||
) -> Option<f64> {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let id = ui.id().with(id);
|
||||
let shown = value
|
||||
.map(|value| {
|
||||
|
|
@ -153,31 +205,7 @@ pub fn mixed_field(
|
|||
format!("{s}{suffix}")
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let editing = ui.memory(|m| m.has_focus(id));
|
||||
let mut buf: String = if editing { ui.data_mut(|d| d.get_temp::<String>(id)).unwrap_or_else(|| shown.clone()) } else { shown.clone() };
|
||||
let resp = ui
|
||||
.allocate_ui_with_layout(vec2(width, 26.0), egui::Layout::left_to_right(egui::Align::Center), |ui| {
|
||||
ui.set_min_width(width);
|
||||
egui::Frame::NONE
|
||||
.fill(t.input)
|
||||
.stroke(Stroke::new(1.0, if editing { t.accent } else { t.input_border }))
|
||||
.corner_radius(CornerRadius::same(2))
|
||||
.inner_margin(egui::Margin::symmetric(6, 4))
|
||||
.show(ui, |ui| {
|
||||
ui.add(
|
||||
egui::TextEdit::singleline(&mut buf)
|
||||
.id(id)
|
||||
.frame(egui::Frame::NONE)
|
||||
.desired_width(width - 14.0)
|
||||
.min_size(vec2(width - 14.0, 0.0))
|
||||
.font(egui::FontId::proportional(12.5))
|
||||
.text_color(t.text_strong),
|
||||
)
|
||||
})
|
||||
.inner
|
||||
})
|
||||
.inner;
|
||||
ui.data_mut(|d| d.insert_temp(id, buf.clone()));
|
||||
let (buf, resp) = recessed_text(ui, id, &shown, width, 1);
|
||||
if resp.lost_focus() && buf != shown {
|
||||
buf.trim().trim_end_matches(suffix.trim()).trim().trim_end_matches(['%', '°']).parse::<f64>().ok()
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -144,3 +144,7 @@ opens the `expand` dialog for the selection. Fields: `object`, `fill`, `stroke`
|
|||
`__stroke` say which the selection has something for, as `object.expand.info` does, and the others are disabled),
|
||||
`gradient` (`objects` or `mesh`) and `steps` (1–1000, default 255). `ui.dialog.confirm` runs `object.expand` with them as
|
||||
one undo step.
|
||||
|
||||
Attributes panel: `window.panel {panel: "attributes"}` (Cmd+F11) shows it; its controls run `attributes.set`,
|
||||
`path.reverse {reversed}` and `path.setFillRule {rule}`, and its Browser button runs the UI command
|
||||
`attributes.openUrl {url?}`, which opens the URL (default: the selection's) in the web browser and answers `{url}`.
|
||||
|
|
|
|||
14
docs/mcp.md
14
docs/mcp.md
|
|
@ -567,3 +567,17 @@ stroke}`), and `object.mesh.create` on a gradient-filled object colours the mesh
|
|||
{"name":"run_command","arguments":{"command":"object.expand","params":{"stroke":false,"gradient":"mesh"}}}
|
||||
{"name":"run_command","arguments":{"command":"object.expand","params":{"steps":16}}}
|
||||
```
|
||||
|
||||
## Attributes, URLs and image maps
|
||||
|
||||
The Attributes panel (`window.panel {panel: "attributes"}`, Cmd+F11) reads `attributes.info {ids?}`:
|
||||
`overprintFill`, `overprintStroke`, `showCenter`, `imageMap`, `url`, `note`, `fillRule` and `reversed` (null where the
|
||||
objects differ) and `recentUrls`. `attributes.set {overprintFill?, overprintStroke?, showCenter?, imageMap?, url?,
|
||||
note?, ids?}` sets them in one undo step; `path.setFillRule {rule: "nonZero"|"evenOdd"}` sets the fill rule of the
|
||||
paths and compound paths, and `path.reverse {reversed?}` makes subpaths run counter-clockwise (true) or clockwise
|
||||
(false). SVG export wraps an object with a URL in `<a xlink:href>`, and SVG import reads `<a href>` links back.
|
||||
|
||||
```json
|
||||
{"name":"run_command","arguments":{"command":"attributes.set","params":{"ids":[12],"url":"https://example.com","imageMap":"rectangle"}}}
|
||||
{"name":"run_command","arguments":{"command":"path.setFillRule","params":{"rule":"evenOdd"}}}
|
||||
```
|
||||
|
|
|
|||
Loading…
Reference in New Issue