mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-27 11:17:01 -06:00
Align to Artboard aligned the selection to the artboard under its center, or to the first artboard when no artboard was under the center. A press with a selection tool inside an artboard makes that artboard the active one (#693). With the Selection tool, a Shift-click on an empty spot inside an artboard also makes it active and keeps the selection. Align to Artboard did not use the active artboard. object.align takes artboard, a 0-based artboard index, and uses it when its to param is "artboard". The app passes its active artboard, the one the Artboards panel highlights, to each object.align it runs that names none, as it does for Paste in Place, Paste in Front, Paste in Back (#693) and Select › All on Active Artboard (#1006). MCP calls in remote mode also get it. Without artboard (headless MCP and the CLI), or with an index the document doesn't have, object.align aligns to the artboard under the center of the selection, or to the first artboard when no artboard is under the center. command.batch takes artboard too, and the app passes it the active artboard. The batch passes it on to each step of these five commands that names none, when the step runs. When the document a step runs in has no artboard at that index (for example, after an earlier step deleted the active artboard when it was the last one), the step gets that document's last artboard. A step in a document that an earlier step opened, created or switched to gets the same index, not that document's own active artboard. With Align to Selection, one selected object aligned from the Align panel, the Control bar or the Properties panel is aligned to the active artboard too, unless it is the key object. Recorded actions leave out the artboard that the app passes to these commands and to the batch. The Actions panel plays an action as a batch. Its Align to Artboard, All on Active Artboard and in-place paste steps that name no artboard use the artboard that is active when it plays, and the default Center on Artboard action now centers on the active artboard. In-place paste steps recorded since #693 and before this change still paste onto the artboard that was active when they were recorded. Tests cover object.align with and without artboard on three artboards; a Shift-click that makes an artboard active, after which Align to Artboard aligns to that artboard but keeps an artboard the call names; a batch that passes its artboard to each step when the step runs, after a step deletes the last artboard and in a new document; recorded actions that leave the artboard out; and recorded Align to Artboard and Paste in Place steps and the default Center on Artboard action, which play on the artboard that is active when they play. Docs: docs/mcp.md (Align to Artboard), docs/target-app-parity.md.
513 lines
21 KiB
Rust
513 lines
21 KiB
Rust
//! Select menu.
|
||
|
||
use std::collections::BTreeSet;
|
||
|
||
use serde_json::{Value, json};
|
||
use vectorcraft_color::Paint;
|
||
use vectorcraft_doc::{Document, Node, NodeId, NodeKind};
|
||
|
||
use super::*;
|
||
|
||
pub fn specs() -> Vec<CommandSpec> {
|
||
vec![
|
||
cmd!(
|
||
"select.all",
|
||
"All",
|
||
["Select"],
|
||
Some("Cmd+A"),
|
||
"{} → {count}; while the Type tool edits text, all of that text instead → {editing, start, end} (byte offsets)",
|
||
has_doc,
|
||
all
|
||
),
|
||
cmd!(
|
||
"select.allOnArtboard",
|
||
"All on Active Artboard",
|
||
["Select"],
|
||
Some("Cmd+Alt+A"),
|
||
"{artboard?: index (0-based; the app passes the active one; default: the first)}",
|
||
has_doc,
|
||
all_on_artboard
|
||
),
|
||
cmd!("select.none", "Deselect", ["Select"], Some("Cmd+Shift+A"), "{}", has_doc, none),
|
||
cmd!("select.reselect", "Reselect", ["Select"], Some("Cmd+6"), "{}", has_doc, reselect),
|
||
cmd!("select.inverse", "Inverse", ["Select"], None, "{}", has_doc, inverse),
|
||
cmd!("select.nextAbove", "Next Object Above", ["Select"], Some("Cmd+Alt+]"), "{}", has_selection, |s, _| step(s, 1)),
|
||
cmd!("select.nextBelow", "Next Object Below", ["Select"], Some("Cmd+Alt+["), "{}", has_selection, |s, _| step(s, -1)),
|
||
cmd!(
|
||
"select.set",
|
||
"Select Objects",
|
||
[],
|
||
None,
|
||
"{ids: [id…]} all ids must be non-negative integers naming existing objects → {count, ids} (resulting selection)",
|
||
has_doc,
|
||
set
|
||
),
|
||
cmd!(
|
||
"select.add",
|
||
"Add to Selection",
|
||
[],
|
||
None,
|
||
"{ids: [id…]} all ids must be non-negative integers naming existing objects → {count, ids} (resulting selection)",
|
||
has_doc,
|
||
add
|
||
),
|
||
cmd!(
|
||
"select.toggle",
|
||
"Toggle Selection",
|
||
[],
|
||
None,
|
||
"{id} or {ids: [id…]}: each selected one leaves the selection, the others join it; all ids must be non-negative integers naming existing objects → {count, ids} (resulting selection)",
|
||
has_doc,
|
||
toggle
|
||
),
|
||
cmd!(
|
||
"select.key",
|
||
"Set Key Object",
|
||
[],
|
||
None,
|
||
"{id?: a selected object, one of two or more (the Selection tool: a click on one object of a selection of several; on the key again: none)} the key object Align aligns to and Distribute Spacing spaces from; none clears it, as does a selection that holds only the key and objects inside it (one object, or a group key and its own members)",
|
||
has_doc,
|
||
key
|
||
),
|
||
cmd!("select.anchors", "Select Anchors", [], None, "{id, anchors: [[subpath, anchor]…], mode: \"set\"|\"add\"|\"toggle\"}", has_doc, anchors),
|
||
cmd!(
|
||
"select.anchorsMany",
|
||
"Select Anchors",
|
||
[],
|
||
None,
|
||
"{items: [{id, anchors}], mode?: \"set\"|\"add\"|\"toggle\"|\"subtract\" (set by default; add: true is mode add)}: toggle selects the anchors not selected and deselects the others, subtract deselects them; a path left with none leaves the selection",
|
||
has_doc,
|
||
anchors_many
|
||
),
|
||
cmd!(
|
||
"select.same.fillColor",
|
||
"Fill Color",
|
||
["Select", "Same"],
|
||
None,
|
||
"{} the objects filled with the first selected object's fill colour (any tint of its global or spot swatch, the same tint with prefs selectSameTintPercent)",
|
||
has_selection,
|
||
|s, _| {
|
||
let tint = s.prefs.select_same_tint_percent;
|
||
same(s, "select.same.fillColor", |a, b| same_paint(&a.appearance.fill_paint(), &b.appearance.fill_paint(), tint))
|
||
}
|
||
),
|
||
cmd!(
|
||
"select.same.strokeColor",
|
||
"Stroke Color",
|
||
["Select", "Same"],
|
||
None,
|
||
"{} the objects stroked with the first selected object's stroke colour (tints as select.same.fillColor)",
|
||
has_selection,
|
||
|s, _| {
|
||
let tint = s.prefs.select_same_tint_percent;
|
||
same(s, "select.same.strokeColor", |a, b| same_paint(&a.appearance.stroke_paint(), &b.appearance.stroke_paint(), tint))
|
||
}
|
||
),
|
||
cmd!("select.same.strokeWeight", "Stroke Weight", ["Select", "Same"], None, "{}", has_selection, |s, _| same(
|
||
s,
|
||
"select.same.strokeWeight",
|
||
|a, b| (a.appearance.stroke_width() - b.appearance.stroke_width()).abs() < 1e-9
|
||
)),
|
||
cmd!(
|
||
"select.same.fillAndStroke",
|
||
"Fill & Stroke",
|
||
["Select", "Same"],
|
||
None,
|
||
"{} the objects with the first selected object's fill and stroke colours (tints as select.same.fillColor)",
|
||
has_selection,
|
||
|s, _| {
|
||
let tint = s.prefs.select_same_tint_percent;
|
||
same(s, "select.same.fillAndStroke", |a, b| {
|
||
let (x, y) = (&a.appearance, &b.appearance);
|
||
same_paint(&x.fill_paint(), &y.fill_paint(), tint) && same_paint(&x.stroke_paint(), &y.stroke_paint(), tint)
|
||
})
|
||
}
|
||
),
|
||
cmd!("select.same.opacity", "Opacity", ["Select", "Same"], None, "{}", has_selection, |s, _| same(s, "select.same.opacity", |a, b| (a
|
||
.opacity
|
||
- b.opacity)
|
||
.abs()
|
||
< 1e-6)),
|
||
cmd!("select.same.blendingMode", "Blending Mode", ["Select", "Same"], None, "{}", has_selection, |s, _| same(
|
||
s,
|
||
"select.same.blendingMode",
|
||
|a, b| a.blend == b.blend
|
||
)),
|
||
cmd!("select.same.appearance", "Appearance", ["Select", "Same"], None, "{}", has_selection, |s, _| same(
|
||
s,
|
||
"select.same.appearance",
|
||
|a, b| a.appearance == b.appearance && a.opacity == b.opacity && a.blend == b.blend
|
||
)),
|
||
cmd!("select.same.shapeType", "Shape", ["Select", "Same"], None, "{}", has_selection, |s, _| same(s, "select.same.shapeType", |a, b| a
|
||
.kind_label()
|
||
== b.kind_label())),
|
||
cmd!("select.object.allOnSameLayers", "All on Same Layers", ["Select", "Object"], None, "{}", has_selection, same_layers),
|
||
cmd!("select.object.clippingMasks", "Clipping Masks", ["Select", "Object"], None, "{}", has_doc, |s, _| by_kind(s, |n| matches!(
|
||
n.kind,
|
||
NodeKind::Path { clipping: true, .. }
|
||
))),
|
||
cmd!("select.object.textObjects", "All Text Objects", ["Select", "Object"], None, "{}", has_doc, |s, _| by_kind(s, |n| matches!(
|
||
n.kind,
|
||
NodeKind::Text(_)
|
||
))),
|
||
cmd!("select.object.strayPoints", "Stray Points", ["Select", "Object"], None, "{}", has_doc, |s, _| by_kind(s, |n| n
|
||
.path_data()
|
||
.is_some_and(|p| p.anchor_count() == 1))),
|
||
cmd!("select.object.openPaths", "Open Paths", ["Select", "Object"], None, "{}", has_doc, |s, _| by_kind(s, |n| n
|
||
.path_data()
|
||
.is_some_and(|p| !p.is_closed()))),
|
||
cmd!(
|
||
"select.same.graphicStyle",
|
||
"Graphic Style",
|
||
["Select", "Same"],
|
||
None,
|
||
"{} the objects linked to the graphic style the first selected object is linked to",
|
||
has_selection,
|
||
same_graphic_style
|
||
),
|
||
cmd!(
|
||
"select.same.appearanceAttribute",
|
||
"Appearance Attribute",
|
||
["Select", "Same"],
|
||
None,
|
||
"{item?: appearance item index} the objects sharing an appearance attribute of the first selected object: its fill or stroke `item` (default: the Appearance panel's active item), else its first effect, else its topmost fill",
|
||
has_selection,
|
||
same_attribute
|
||
),
|
||
]
|
||
}
|
||
|
||
/// Selectable objects: children of visible, unlocked layers (or of the isolation container).
|
||
/// The objects Select All takes: those of the isolated container, else of every layer (looking
|
||
/// through sublayers, which are not objects).
|
||
fn selectable(d: &Document, iso: Option<NodeId>) -> Vec<NodeId> {
|
||
match iso.and_then(|i| d.node(i)) {
|
||
Some(c) => c.layer_art(true),
|
||
None => d.selectable_art(),
|
||
}
|
||
}
|
||
|
||
fn all(s: &mut Session, _: &Value) -> Result<Value> {
|
||
// While the Type tool edits text, Select All takes all of that text and leaves the art
|
||
// selection as it is (Illustrator: text cursor in a text object).
|
||
if s.tool_wants_text() {
|
||
s.set_tool_option("selectAll", &json!(true));
|
||
let editing = s.tool_options()["editing"].clone();
|
||
let id = editing.as_u64().map(NodeId);
|
||
let end = id.and_then(|id| match &s.doc().ok()?.doc.node(id)?.kind {
|
||
NodeKind::Text(t) => Some(t.plain_text().len()),
|
||
_ => None,
|
||
});
|
||
return Ok(json!({ "editing": editing, "start": 0, "end": end.unwrap_or(0) }));
|
||
}
|
||
let st = s.doc()?;
|
||
let ids = selectable(&st.doc, st.isolation);
|
||
s.select(|_, sel| sel.set(ids.iter().copied()))?;
|
||
Ok(json!({ "count": s.doc()?.selection.len() }))
|
||
}
|
||
|
||
fn all_on_artboard(s: &mut Session, p: &Value) -> Result<Value> {
|
||
let st = s.doc()?;
|
||
let i = p.get("artboard").and_then(Value::as_u64).unwrap_or(0) as usize;
|
||
let r = st.doc.artboards.get(i).map(|a| a.rect).ok_or_else(|| bad("select.allOnArtboard", "no such artboard"))?;
|
||
let ids: Vec<NodeId> = selectable(&st.doc, st.isolation)
|
||
.into_iter()
|
||
.filter(|id| st.doc.node(*id).and_then(|n| n.geometric_bounds()).is_some_and(|b| b.intersect(r).area() > 0.0 || r.contains(b.center())))
|
||
.collect();
|
||
s.select(|_, sel| sel.set(ids.iter().copied()))?;
|
||
ok()
|
||
}
|
||
|
||
fn none(s: &mut Session, _: &Value) -> Result<Value> {
|
||
s.select(|_, sel| sel.clear())?;
|
||
ok()
|
||
}
|
||
|
||
fn reselect(s: &mut Session, _: &Value) -> Result<Value> {
|
||
let st = s.doc()?;
|
||
let Some((c, p, from)) = st.last_selection_cmd.clone() else { return ok() };
|
||
// After Deselect, a Same command takes its reference again from the objects that were selected
|
||
// when it ran, those still there (#903).
|
||
let from: Vec<NodeId> = from.into_iter().filter(|id| st.doc.node(*id).is_some()).collect();
|
||
let restore = st.selection.objects.is_empty() && !from.is_empty();
|
||
if restore {
|
||
s.select(|_, sel| sel.set(from))?;
|
||
}
|
||
let r = s.execute(&c, &p);
|
||
if restore && r.is_err() {
|
||
s.select(|_, sel| sel.clear())?;
|
||
}
|
||
r
|
||
}
|
||
|
||
fn inverse(s: &mut Session, _: &Value) -> Result<Value> {
|
||
let st = s.doc()?;
|
||
let ids: Vec<NodeId> = selectable(&st.doc, st.isolation).into_iter().filter(|id| !st.selection.contains(*id)).collect();
|
||
s.select(|_, sel| sel.set(ids.iter().copied()))?;
|
||
ok()
|
||
}
|
||
|
||
fn step(s: &mut Session, dir: i64) -> Result<Value> {
|
||
let st = s.doc()?;
|
||
let Some(cur) = st.selection.objects.first().copied() else { return ok() };
|
||
let Some((par, idx, n)) = st.doc.position(cur) else { return ok() };
|
||
let j = idx as i64 + dir;
|
||
if j < 0 || j >= n as i64 {
|
||
return ok();
|
||
}
|
||
let Some(id) = st.doc.children(par).and_then(|c| c.get(j as usize)).map(|n| n.id) else { return ok() };
|
||
s.select(|_, sel| sel.set([id]))?;
|
||
ok()
|
||
}
|
||
|
||
fn set(s: &mut Session, p: &Value) -> Result<Value> {
|
||
let ids = checked_ids_param(s, p, "ids", "select.set")?;
|
||
s.select(|_, sel| sel.set(ids.iter().copied()))?;
|
||
selection_result(s)
|
||
}
|
||
|
||
fn add(s: &mut Session, p: &Value) -> Result<Value> {
|
||
let ids = checked_ids_param(s, p, "ids", "select.add")?;
|
||
s.select(|_, sel| {
|
||
for i in ids {
|
||
sel.add(i);
|
||
}
|
||
})?;
|
||
selection_result(s)
|
||
}
|
||
|
||
fn toggle(s: &mut Session, p: &Value) -> Result<Value> {
|
||
let ids = if p.get("ids").is_some() {
|
||
checked_ids_param(s, p, "ids", "select.toggle")?
|
||
} else {
|
||
let value = p.get("id").ok_or_else(|| bad("select.toggle", "missing id or ids"))?;
|
||
vec![checked_id(s, value, "select.toggle")?]
|
||
};
|
||
s.select(|_, sel| {
|
||
for id in ids {
|
||
sel.toggle(id);
|
||
}
|
||
})?;
|
||
selection_result(s)
|
||
}
|
||
|
||
fn selection_result(s: &Session) -> Result<Value> {
|
||
let selection = &s.doc()?.selection;
|
||
Ok(json!({ "count": selection.len(), "ids": selection.objects }))
|
||
}
|
||
|
||
fn key(s: &mut Session, p: &Value) -> Result<Value> {
|
||
let id = id_param(p, "id");
|
||
s.select(|_, sel| sel.key = id.filter(|i| sel.contains(*i)))?;
|
||
ok()
|
||
}
|
||
|
||
/// `[[subpath, anchor]…]` anchor references of `cmd`: a malformed entry fails rather than being
|
||
/// left out, which would act on other anchors than the ones given.
|
||
pub(crate) fn checked_refs(v: Option<&Value>, cmd: &str) -> Result<Vec<(usize, usize)>> {
|
||
let refs = v.and_then(Value::as_array).ok_or_else(|| bad(cmd, "`anchors` must be an array of [subpath, anchor] pairs"))?;
|
||
refs.iter()
|
||
.map(|v| {
|
||
let Some([subpath, anchor]) = v.as_array().map(Vec::as_slice) else {
|
||
return Err(bad(cmd, format!("invalid anchor reference {v}: expected [subpath, anchor]")));
|
||
};
|
||
let coordinate = |v: &Value| {
|
||
v.as_u64()
|
||
.and_then(|n| usize::try_from(n).ok())
|
||
.ok_or_else(|| bad(cmd, format!("invalid anchor index {v}: expected a non-negative integer")))
|
||
};
|
||
Ok((coordinate(subpath)?, coordinate(anchor)?))
|
||
})
|
||
.collect()
|
||
}
|
||
|
||
fn anchors(s: &mut Session, p: &Value) -> Result<Value> {
|
||
const C: &str = "select.anchors";
|
||
let id = checked_id(s, p.get("id").ok_or_else(|| bad(C, "missing id"))?, C)?;
|
||
let refs = checked_refs(p.get("anchors"), C)?;
|
||
let mode = match str_param(p, "mode") {
|
||
None | Some("set") => "set",
|
||
Some("add") => "add",
|
||
Some("toggle") => "toggle",
|
||
Some(m) => return Err(bad(C, format!("unknown mode {m:?}"))),
|
||
};
|
||
s.select(|_, sel| {
|
||
if mode == "set" {
|
||
sel.clear();
|
||
}
|
||
sel.add(id);
|
||
let e = sel.anchors.entry(id).or_default();
|
||
for r in refs {
|
||
if mode == "toggle" && e.contains(&r) {
|
||
e.remove(&r);
|
||
} else {
|
||
e.insert(r);
|
||
}
|
||
}
|
||
})?;
|
||
ok()
|
||
}
|
||
|
||
fn anchors_many(s: &mut Session, p: &Value) -> Result<Value> {
|
||
const C: &str = "select.anchorsMany";
|
||
let raw = p.get("items").and_then(Value::as_array).ok_or_else(|| bad(C, "`items` must be an array of {id, anchors}"))?;
|
||
let items: Vec<(NodeId, BTreeSet<(usize, usize)>)> = raw
|
||
.iter()
|
||
.map(|it| {
|
||
let id = checked_id(s, it.get("id").ok_or_else(|| bad(C, "each item needs an id"))?, C)?;
|
||
let refs = checked_refs(it.get("anchors"), C)?;
|
||
Ok((id, refs.into_iter().collect()))
|
||
})
|
||
.collect::<Result<_>>()?;
|
||
// `add: true` is the older spelling of mode add.
|
||
let mode = match str_param(p, "mode") {
|
||
Some(m @ ("set" | "add" | "toggle" | "subtract")) => m,
|
||
Some(m) => return Err(bad("select.anchorsMany", format!("unknown mode {m:?}"))),
|
||
None if bool_or(p, "add", false) => "add",
|
||
None => "set",
|
||
};
|
||
s.select(|d, sel| {
|
||
if mode == "set" {
|
||
sel.clear();
|
||
}
|
||
for (id, refs) in items {
|
||
let all: BTreeSet<(usize, usize)> =
|
||
d.node(id).and_then(|n| n.path_data()).map(|p| p.anchors().map(|(s, i, _)| (s, i)).collect()).unwrap_or_default();
|
||
// Anything but a path is selected, toggled or deselected as a whole.
|
||
if all.is_empty() {
|
||
match mode {
|
||
"toggle" => sel.toggle(id),
|
||
"subtract" => sel.remove(id),
|
||
_ => sel.add(id),
|
||
}
|
||
continue;
|
||
}
|
||
// From the anchors selected now (all of them for a path selected as a whole).
|
||
let now = match sel.partial(id) {
|
||
Some(a) => a.clone(),
|
||
None if sel.contains(id) => all.clone(),
|
||
None => BTreeSet::new(),
|
||
};
|
||
let refs: BTreeSet<(usize, usize)> = refs.intersection(&all).copied().collect();
|
||
let next: BTreeSet<(usize, usize)> = match mode {
|
||
"toggle" => now.symmetric_difference(&refs).copied().collect(),
|
||
"subtract" => now.difference(&refs).copied().collect(),
|
||
_ => now.union(&refs).copied().collect(),
|
||
};
|
||
if next.is_empty() {
|
||
sel.remove(id);
|
||
continue;
|
||
}
|
||
sel.add(id);
|
||
if next.len() < all.len() {
|
||
sel.anchors.insert(id, next);
|
||
} else {
|
||
sel.anchors.remove(&id);
|
||
}
|
||
}
|
||
})?;
|
||
ok()
|
||
}
|
||
|
||
/// Select › Same › Fill/Stroke Color: is `a` the same colour as `b`? Tints of one global or spot
|
||
/// swatch are, unless `tint` (General › Select Same Tint %) asks for the same tint too.
|
||
fn same_paint(a: &Paint, b: &Paint, tint: bool) -> bool {
|
||
match (a, b) {
|
||
(Paint::Solid { swatch: Some(x), tint: ta, .. }, Paint::Solid { swatch: Some(y), tint: tb, .. }) if x == y => !tint || (ta - tb).abs() < 1e-4,
|
||
_ => a == b,
|
||
}
|
||
}
|
||
|
||
fn same(s: &mut Session, cmd: &str, eq: impl Fn(&Node, &Node) -> bool) -> Result<Value> {
|
||
let st = s.doc()?;
|
||
let refn = st.selection.objects.first().and_then(|id| st.doc.node(*id)).cloned().ok_or_else(|| bad(cmd, "nothing selected"))?;
|
||
select_where(s, cmd, &json!({}), |_, n| !n.is_container() && eq(n, &refn))
|
||
}
|
||
|
||
/// Select the visible, unlocked objects in visible, unlocked layers that `f` accepts (not looking
|
||
/// inside accepted ones); Select > Reselect repeats `cmd` with `p`.
|
||
fn select_where(s: &mut Session, cmd: &str, p: &Value, f: impl Fn(&Document, &Node) -> bool) -> Result<Value> {
|
||
fn visit(d: &Document, n: &Node, f: &impl Fn(&Document, &Node) -> bool, ids: &mut Vec<NodeId>) {
|
||
// Hidden or locked objects and sublayers (and what they hold) are out of reach.
|
||
if !n.visible || n.locked || n.is_template() {
|
||
return;
|
||
}
|
||
if !n.is_layer() && f(d, n) {
|
||
return ids.push(n.id);
|
||
}
|
||
for c in n.children().into_iter().flatten() {
|
||
visit(d, c, f, ids);
|
||
}
|
||
}
|
||
let st = s.doc()?;
|
||
let mut ids = vec![];
|
||
for l in st.doc.layers.iter().filter(|l| l.visible && !l.locked) {
|
||
visit(&st.doc, l, &f, &mut ids);
|
||
}
|
||
let from = st.selection.objects.clone();
|
||
s.select(|_, sel| sel.set(ids.iter().copied()))?;
|
||
s.doc_mut()?.last_selection_cmd = Some((cmd.to_string(), p.clone(), from));
|
||
Ok(json!({ "count": ids.len() }))
|
||
}
|
||
|
||
fn same_graphic_style(s: &mut Session, _: &Value) -> Result<Value> {
|
||
let cmd = "select.same.graphicStyle";
|
||
let st = s.doc()?;
|
||
let id = st
|
||
.selection
|
||
.objects
|
||
.first()
|
||
.and_then(|id| super::style::linked_style(&st.doc, st.doc.node(*id)?))
|
||
.map(|g| g.id)
|
||
.ok_or_else(|| bad(cmd, "the selection has no graphic style"))?;
|
||
select_where(s, cmd, &json!({}), |d, n| n.graphic_style == Some(id) && super::style::linked_style(d, n).is_some())
|
||
}
|
||
|
||
/// An appearance attribute Select > Same > Appearance Attribute matches.
|
||
enum Attribute {
|
||
Item(vectorcraft_doc::AppearanceItem),
|
||
Effect(vectorcraft_doc::Effect),
|
||
}
|
||
|
||
fn same_attribute(s: &mut Session, p: &Value) -> Result<Value> {
|
||
let cmd = "select.same.appearanceAttribute";
|
||
let st = s.doc()?;
|
||
let refn = st.selection.objects.first().and_then(|id| st.doc.node(*id)).ok_or_else(|| bad(cmd, "nothing selected"))?;
|
||
let ap = &refn.appearance;
|
||
let item = p.get("item").and_then(Value::as_u64).map(|i| i as usize).or(s.appearance_item());
|
||
let attr = match (item.and_then(|i| ap.items.get(i)), ap.effects.first()) {
|
||
(Some(it), _) => Attribute::Item(it.clone()),
|
||
(None, Some(e)) => Attribute::Effect(e.clone()),
|
||
(None, None) => {
|
||
Attribute::Item(ap.items.iter().rev().find(|i| i.is_fill()).cloned().ok_or_else(|| bad(cmd, "the object has no fill, stroke or effect"))?)
|
||
}
|
||
};
|
||
select_where(s, cmd, p, |_, n| match &attr {
|
||
Attribute::Item(it) => n.appearance.items.contains(it),
|
||
Attribute::Effect(e) => n.appearance.effects.iter().any(|x| x.id == e.id && x.params == e.params),
|
||
})
|
||
}
|
||
|
||
fn same_layers(s: &mut Session, _: &Value) -> Result<Value> {
|
||
let st = s.doc()?;
|
||
// The layers or sublayers the selected objects are on (art in their sublayers too).
|
||
let layers: BTreeSet<NodeId> = st.selection.objects.iter().filter_map(|id| st.doc.layer_containing(*id)).collect();
|
||
let ids: Vec<NodeId> = layers.iter().filter_map(|l| st.doc.node(*l)).flat_map(|l| l.layer_art(true)).collect();
|
||
s.select(|_, sel| sel.set(ids.iter().copied()))?;
|
||
ok()
|
||
}
|
||
|
||
fn by_kind(s: &mut Session, f: fn(&Node) -> bool) -> Result<Value> {
|
||
let st = s.doc()?;
|
||
let mut ids = vec![];
|
||
for l in st.doc.layers.iter().filter(|l| l.visible && !l.locked) {
|
||
l.walk(&mut |n| {
|
||
if f(n) {
|
||
ids.push(n.id)
|
||
}
|
||
});
|
||
}
|
||
s.select(|_, sel| sel.set(ids.iter().copied()))?;
|
||
Ok(json!({ "count": ids.len() }))
|
||
}
|