mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-09 11:54:03 +00:00
Select All, All on Active Artboard and Inverse select from selectable: the visible, unlocked objects on visible, unlocked, non-template layers and sublayers, or in isolation mode the visible, unlocked objects in the isolated group or layer and in its visible, unlocked, non-template sublayers. Other Select menu commands did not use it: - Clipping Masks, All Text Objects, Stray Points and Open Paths selected hidden and locked objects and the objects in hidden or locked groups and sublayers. - Point Text Objects, Area Text Objects, Brush Strokes, Bristle Brush Strokes and the Same commands for symbols, fonts and text colors checked an object's own flags and its layer's, but not those of the groups and sublayers between them. - Next Object Above and Below selected the object beside the selected one even when it was hidden, locked or a sublayer. - A saved selection picked from the Select menu selected again the objects hidden or locked since it was saved. - Direction Handles direct-selected the hidden and locked paths in a selected group. - In isolation mode, the commands in the first two bullets, the other Same commands, All on Same Layers and a recalled saved selection selected art outside the isolated group or layer. Next Object Above and Below and Direction Handles did so only when the selection already held something that was not inside the isolated group or layer. In isolation mode selectable checked the flags of the isolated group's or layer's objects and sublayers, but not those of the group or layer itself or of the groups and layers it is in. Select All, All on Active Artboard and Inverse still selected the isolated group's or layer's objects after it, or a group or layer it is in, was hidden or locked. In isolation mode selectable now returns nothing while the isolated group or layer, or a group or layer it is in, is hidden, locked or a template layer. walk_selectable visits the objects selectable returns and the objects inside them, leaving out hidden and locked objects with what they hold. It visits nothing in a hidden, locked or template layer, sublayer or group. The commands in the bullets above now select from it. All on Same Layers takes the objects on the selected objects' layers that selectable returns. Next Object Above and Below pass over the objects walk_selectable leaves out, and leave the selection as it is when none is left in that direction. The params texts of Direction Handles and Recall Selection, docs/mcp.md (Object selection, Saved selections) and one line in the evidence list of docs/target-app-parity.md describe the change.
606 lines
25 KiB
Rust
606 lines
25 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",
|
||
"Cancel Key Object",
|
||
[],
|
||
None,
|
||
"{id?: a selected top-level object (not a layer, a compound-path member or an object inside another selected object), one of two or more (the Selection tool: a click on one object of a selection of several; on the key again: no id)} the key object Align aligns to and Distribute Spacing spaces from; without `id` it cancels the key, as does a selection that holds only the key and objects inside it (one object, or a group key and its own members); any other id is an error that leaves the key as it was → {key} (the resulting key object, or null)",
|
||
has_doc,
|
||
key
|
||
),
|
||
cmd!("select.anchors", "Select Anchors", [], None, "{id, anchors: [[subpath, anchor]…], mode: \"set\"|\"add\"|\"toggle\"}", has_doc, anchors),
|
||
cmd!(
|
||
"select.segments",
|
||
"Select Segments",
|
||
[],
|
||
None,
|
||
"{id, segments: [[subpath, segment]…]} select path segments; their endpoints move with them, Delete removes only their connections",
|
||
has_doc,
|
||
segments
|
||
),
|
||
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). In isolation mode there are none while the isolated
|
||
/// container, or a group or layer it is in, is hidden, locked or a template layer.
|
||
fn selectable(d: &Document, iso: Option<NodeId>) -> Vec<NodeId> {
|
||
match iso.and_then(|i| d.node(i)) {
|
||
Some(c) if d.ancestry(c.id).is_some_and(|a| a.iter().all(|i| d.node(*i).is_some_and(|n| n.visible && !n.locked && !n.is_template()))) => {
|
||
c.layer_art(true)
|
||
}
|
||
Some(_) => vec![],
|
||
None => d.selectable_art(),
|
||
}
|
||
}
|
||
|
||
/// Visit, in paint order, the objects Select All takes ([`selectable`]) and the objects inside
|
||
/// them, leaving out every hidden or locked object with what it holds. `f` returns whether to
|
||
/// visit the objects inside the one it is given.
|
||
pub(crate) fn walk_selectable(d: &Document, iso: Option<NodeId>, mut f: impl FnMut(&Node) -> bool) {
|
||
fn inside(n: &Node, f: &mut impl FnMut(&Node) -> bool) {
|
||
if n.visible && !n.locked && f(n) {
|
||
for c in n.children().into_iter().flatten() {
|
||
inside(c, f);
|
||
}
|
||
}
|
||
}
|
||
fn find(n: &Node, tops: &BTreeSet<NodeId>, f: &mut impl FnMut(&Node) -> bool) {
|
||
if tops.contains(&n.id) {
|
||
return inside(n, f);
|
||
}
|
||
for c in n.children().into_iter().flatten() {
|
||
find(c, tops, f);
|
||
}
|
||
}
|
||
let tops: BTreeSet<NodeId> = selectable(d, iso).into_iter().collect();
|
||
for l in &d.layers {
|
||
find(l, &tops, &mut f);
|
||
}
|
||
}
|
||
|
||
/// The objects [`walk_selectable`] visits.
|
||
pub(crate) fn selectable_ids(d: &Document, iso: Option<NodeId>) -> BTreeSet<NodeId> {
|
||
let mut ids = BTreeSet::new();
|
||
walk_selectable(d, iso, |n| {
|
||
ids.insert(n.id);
|
||
true
|
||
});
|
||
ids
|
||
}
|
||
|
||
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, _)) = st.doc.position(cur) else { return ok() };
|
||
let Some(siblings) = st.doc.children(par) else { return ok() };
|
||
// The nearest sibling in that direction that `walk_selectable` visits: hidden and locked
|
||
// objects and sublayers are passed over.
|
||
let can = selectable_ids(&st.doc, st.isolation);
|
||
let next = if dir > 0 {
|
||
siblings.iter().skip(idx + 1).find(|n| can.contains(&n.id))
|
||
} else {
|
||
siblings.iter().take(idx).rev().find(|n| can.contains(&n.id))
|
||
};
|
||
let Some(id) = next.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 = p.get("id").map(|value| checked_id(s, value, "select.key")).transpose()?;
|
||
if let Some(id) = id {
|
||
let st = s.doc()?;
|
||
// A selected top-level object is the only top-level object among the selected ones of
|
||
// itself and its ancestors.
|
||
let selected = st.doc.ancestry(id).unwrap_or_default().into_iter().filter(|a| st.selection.contains(*a)).collect();
|
||
if super::edit::roots_of(&st.doc, selected) != [id] {
|
||
return Err(bad("select.key", format!("object {id} is not a selected top-level object")));
|
||
}
|
||
}
|
||
s.select(|_, sel| sel.key = id)?;
|
||
Ok(json!({ "key": s.doc()?.selection.key }))
|
||
}
|
||
|
||
/// `[[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 segments(s: &mut Session, p: &Value) -> Result<Value> {
|
||
const C: &str = "select.segments";
|
||
let id = checked_id(s, p.get("id").ok_or_else(|| bad(C, "missing id"))?, C)?;
|
||
let refs: BTreeSet<_> = checked_refs(p.get("segments"), C)?.into_iter().collect();
|
||
let path = s.doc()?.doc.node(id).and_then(|n| n.path_data()).ok_or_else(|| bad(C, "object is not a path"))?;
|
||
let mut endpoints = BTreeSet::new();
|
||
for &(si, seg) in &refs {
|
||
let sp = path.subpaths.get(si).filter(|sp| seg < sp.segment_count()).ok_or_else(|| bad(C, "segment does not exist"))?;
|
||
endpoints.extend([(si, seg), (si, (seg + 1) % sp.anchors.len())]);
|
||
}
|
||
s.select(|_, sel| {
|
||
sel.clear();
|
||
if !refs.is_empty() {
|
||
sel.add(id);
|
||
sel.anchors.insert(id, endpoints);
|
||
sel.segments.insert(id, refs);
|
||
}
|
||
})?;
|
||
ok()
|
||
}
|
||
|
||
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(|d, sel| {
|
||
if mode == "set" {
|
||
sel.clear();
|
||
}
|
||
// A path selected as a whole has every anchor selected: toggle and add start from them all.
|
||
let whole = sel.contains(id) && sel.partial(id).is_none();
|
||
sel.add(id);
|
||
// A segment pick's endpoints aren't selected anchors: start from none of them.
|
||
if sel.segments.remove(&id).is_some() {
|
||
sel.anchors.remove(&id);
|
||
}
|
||
let e = sel.anchors.entry(id).or_default();
|
||
if whole && let Some(path) = d.node(id).and_then(|n| n.path_data()) {
|
||
e.extend(path.anchors().map(|(s, i, _)| (s, i)));
|
||
}
|
||
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 {
|
||
// A segment pick's endpoints aren't selected anchors: start from none of them.
|
||
let segment_pick = sel.segments.remove(&id).is_some();
|
||
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) {
|
||
_ if segment_pick => BTreeSet::new(),
|
||
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 objects [`walk_selectable`] visits 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> {
|
||
let st = s.doc()?;
|
||
let mut ids = vec![];
|
||
walk_selectable(&st.doc, st.isolation, |n| {
|
||
if f(&st.doc, n) {
|
||
ids.push(n.id);
|
||
return false;
|
||
}
|
||
true
|
||
});
|
||
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 mut on = BTreeSet::new();
|
||
for l in layers.iter().filter_map(|l| st.doc.node(*l)) {
|
||
l.walk(&mut |n| {
|
||
on.insert(n.id);
|
||
});
|
||
}
|
||
// The objects Select All takes there (in isolation mode, those in the isolated group or layer).
|
||
let ids: Vec<NodeId> = selectable(&st.doc, st.isolation).into_iter().filter(|id| on.contains(id)).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![];
|
||
walk_selectable(&st.doc, st.isolation, |n| {
|
||
if f(n) {
|
||
ids.push(n.id);
|
||
}
|
||
true
|
||
});
|
||
s.select(|_, sel| sel.set(ids.iter().copied()))?;
|
||
Ok(json!({ "count": ids.len() }))
|
||
}
|