mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-12 21:56:24 +00:00
Ruler guides were a list kept beside the layers. Each guide is now an object of its layer or sublayer, always directly in it and above the layer's art. These behave as on main: - Guides draw on screen only. Exports (SVG, PDF, PNG, PSD), Fit All, Select All, the art bounds, clicks and marquees, and a layer's Pathfinder and Transform effects leave them out. The Layers panel lists them as guide rows of their layer, the highest guide.list index first. - Deleting a layer deletes its guides, and merging layers keeps their guides in the merged layer. - The guide commands number guides as guide.list does, in the order they were made. New: each guide takes an id from the document's object ids. An object command given a guide's id fails with an error, and node.move cannot put a guide into a group or out of its layers. A guide made while an opacity mask, a symbol or a pattern is edited moves to the top visible, unlocked layer when editing ends. File format: native files still save the guides in the document's guides list, in every file version, so older builds read them. Each entry may now also carry the guide's id, name, locked and hidden flags; older builds ignore those fields. Opening a file puts each saved guide on its layer (on the top visible, unlocked layer when the file names none or a layer it does not have), and each saved guide counts against the object limit for opening a file. A ruler guide node that a file holds below a layer's art moves up to the layer's guides, and one anywhere else (in a group or another object, beside the layers, or in a symbol) is removed. Ruler guides read from an .ai file become guide objects on the layer they were on, numbered in the order the file lists them.
345 lines
16 KiB
Rust
345 lines
16 KiB
Rust
//! Selection state (objects and, for direct selection, anchors and segments; ruler guides).
|
|
|
|
use std::collections::{BTreeMap, BTreeSet, HashSet};
|
|
use std::sync::Arc;
|
|
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
use crate::{Document, NodeId, NodeKind};
|
|
|
|
/// (subpath index, anchor index) inside a path.
|
|
pub type AnchorRef = (usize, usize);
|
|
/// (subpath index, segment index) inside a path.
|
|
pub type SegmentRef = (usize, usize);
|
|
|
|
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
|
pub struct Selection {
|
|
/// Selected objects in selection order.
|
|
pub objects: Vec<NodeId>,
|
|
/// Direct-selected anchors, or a selected segment's endpoints used for movement. A path in
|
|
/// `objects` with no entry here is fully selected; `segments` distinguishes segment picks.
|
|
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
|
pub anchors: BTreeMap<NodeId, BTreeSet<AnchorRef>>,
|
|
/// Explicitly selected segments. Their endpoints in `anchors` move with them, but are not
|
|
/// selected anchor points: Delete cuts connections and their anchor squares stay hollow.
|
|
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
|
pub segments: BTreeMap<NodeId, BTreeSet<SegmentRef>>,
|
|
/// Key object for Align, one object of a selection of two or more. [`Selection::prune`] clears
|
|
/// it when every selected object is the key or lies inside it, as when one object is left
|
|
/// selected or when the key is a group selected with only its own members.
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub key: Option<NodeId>,
|
|
/// The object, group or layer targeted through its target circle in the Layers panel, which
|
|
/// appearance, transparency and opacity-mask commands then act on (a targeted layer has its
|
|
/// art selected). Any other selection change clears it.
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub target: Option<NodeId>,
|
|
/// Slices selected with the Slice Selection tool: user slice ids and the ids of objects whose
|
|
/// object slice is selected (see [`crate::slices`]). Any change to the object selection
|
|
/// clears them.
|
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
|
pub slices: Vec<NodeId>,
|
|
/// Ruler guides selected with a selection tool, by node id ([`Document::ruler_guides`]). A new
|
|
/// selection ([`Self::set`]) clears them; Shift-adding or -removing objects keeps them, so
|
|
/// guides and objects can be selected together, in either order.
|
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
|
pub guides: Vec<NodeId>,
|
|
/// A selected ruler guide that is the key object for Align instead of [`Self::key`] (one of
|
|
/// [`Self::guides`]): the objects line up on it. Never set with `key`; [`Self::prune`] clears
|
|
/// it when no object is selected to align to it.
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub key_guide: Option<NodeId>,
|
|
/// The new art on a locked artboard that this selection keeps editable
|
|
/// ([`Document::new_art`]). Undo, Redo and a failed batch restore it with the selection, and
|
|
/// the engine then sets the document's from it. Editing state, never saved.
|
|
#[serde(skip)]
|
|
pub new_art: Arc<HashSet<NodeId>>,
|
|
/// An object of a dynamic symbol instance's art, selected with Direct Selection: the instance
|
|
/// is the one selected object, and solid fill and stroke changes go to its own appearance for
|
|
/// that object ([`crate::symbol_overrides`]). Any other selection change clears it.
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub symbol_part: Option<SymbolPart>,
|
|
}
|
|
|
|
/// See [`Selection::symbol_part`].
|
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub struct SymbolPart {
|
|
/// The symbol instance.
|
|
pub instance: NodeId,
|
|
/// The object in the symbol's art, as [`crate::symbol_overrides::part`] names it.
|
|
pub path: Vec<u32>,
|
|
}
|
|
|
|
impl Selection {
|
|
pub fn is_empty(&self) -> bool {
|
|
self.objects.is_empty()
|
|
}
|
|
/// Is anything selected that Delete and the arrow keys act on: objects or ruler guides?
|
|
pub fn has_objects_or_guides(&self) -> bool {
|
|
!self.objects.is_empty() || !self.guides.is_empty()
|
|
}
|
|
pub fn len(&self) -> usize {
|
|
self.objects.len()
|
|
}
|
|
pub fn contains(&self, id: NodeId) -> bool {
|
|
self.objects.contains(&id)
|
|
}
|
|
pub fn clear(&mut self) {
|
|
self.objects.clear();
|
|
self.anchors.clear();
|
|
self.segments.clear();
|
|
self.key = None;
|
|
self.target = None;
|
|
self.slices.clear();
|
|
self.guides.clear();
|
|
self.key_guide = None;
|
|
self.symbol_part = None;
|
|
}
|
|
/// Select only instance `instance`, with the object at `path` of its symbol's art (see
|
|
/// [`Selection::symbol_part`]).
|
|
pub fn set_symbol_part(&mut self, instance: NodeId, path: Vec<u32>) {
|
|
self.set([instance]);
|
|
self.symbol_part = Some(SymbolPart { instance, path });
|
|
}
|
|
/// Is there a key for Align: an object or a guide?
|
|
pub fn has_key(&self) -> bool {
|
|
self.key.is_some() || self.key_guide.is_some()
|
|
}
|
|
pub fn set(&mut self, ids: impl IntoIterator<Item = NodeId>) {
|
|
self.clear();
|
|
// What `add` per id does after a clear, without its linear duplicate check per id.
|
|
let mut seen = std::collections::HashSet::new();
|
|
self.objects.extend(ids.into_iter().filter(|id| seen.insert(*id)));
|
|
}
|
|
pub fn add(&mut self, id: NodeId) {
|
|
self.target = None;
|
|
self.slices.clear();
|
|
self.symbol_part = None;
|
|
if !self.objects.contains(&id) {
|
|
self.objects.push(id);
|
|
}
|
|
}
|
|
pub fn remove(&mut self, id: NodeId) {
|
|
self.target = None;
|
|
self.slices.clear();
|
|
self.symbol_part = None;
|
|
self.objects.retain(|x| *x != id);
|
|
self.anchors.remove(&id);
|
|
self.segments.remove(&id);
|
|
if self.key == Some(id) {
|
|
self.key = None;
|
|
}
|
|
}
|
|
/// Select slices `ids` (see [`Selection::slices`]) and nothing else.
|
|
pub fn set_slices(&mut self, ids: impl IntoIterator<Item = NodeId>) {
|
|
self.clear();
|
|
for id in ids {
|
|
if !self.slices.contains(&id) {
|
|
self.slices.push(id);
|
|
}
|
|
}
|
|
}
|
|
/// Select ruler guides `ids` (see [`Selection::guides`]) and nothing else.
|
|
pub fn set_guides(&mut self, ids: impl IntoIterator<Item = NodeId>) {
|
|
self.clear();
|
|
for i in ids {
|
|
if !self.guides.contains(&i) {
|
|
self.guides.push(i);
|
|
}
|
|
}
|
|
}
|
|
/// Add ruler guide `i` to the selection, or take it out, keeping the selected objects (a
|
|
/// Shift-click on a guide).
|
|
pub fn toggle_guide(&mut self, i: NodeId) {
|
|
self.symbol_part = None;
|
|
if let Some(k) = self.guides.iter().position(|g| *g == i) {
|
|
self.guides.remove(k);
|
|
if self.key_guide == Some(i) {
|
|
self.key_guide = None;
|
|
}
|
|
} else {
|
|
self.guides.push(i);
|
|
}
|
|
}
|
|
pub fn toggle(&mut self, id: NodeId) {
|
|
if self.contains(id) { self.remove(id) } else { self.add(id) }
|
|
}
|
|
/// Are some (not all) anchors of `id` direct-selected?
|
|
pub fn partial(&self, id: NodeId) -> Option<&BTreeSet<AnchorRef>> {
|
|
self.anchors.get(&id)
|
|
}
|
|
/// The objects selected whole: those with no direct-selected anchors.
|
|
pub fn whole(&self) -> Vec<NodeId> {
|
|
self.objects.iter().copied().filter(|id| !self.anchors.contains_key(id)).collect()
|
|
}
|
|
/// With direct-selected anchors, what a transform of them measures from: the box around those
|
|
/// anchors and the objects selected whole (`visual`: with their strokes). `None` without any.
|
|
pub fn anchor_bounds(&self, doc: &Document, visual: bool) -> Option<vectorcraft_geom::Rect> {
|
|
if self.anchors.is_empty() {
|
|
return None;
|
|
}
|
|
vectorcraft_geom::union_opt(doc.bounds_of(&self.whole(), visual), self.anchors_box(doc))
|
|
}
|
|
/// The box around the anchors selected with Direct Selection. `None` without any.
|
|
pub fn anchors_box(&self, doc: &Document) -> Option<vectorcraft_geom::Rect> {
|
|
let mut acc = None;
|
|
for (id, set) in &self.anchors {
|
|
let Some(NodeKind::Path { path, .. }) = doc.node(*id).map(|n| &n.kind) else { continue };
|
|
for a in set.iter().filter_map(|&(si, ai)| path.subpaths.get(si)?.anchors.get(ai)) {
|
|
acc = vectorcraft_geom::union_opt(acc, Some(vectorcraft_geom::Rect::from_points(a.p, a.p)));
|
|
}
|
|
}
|
|
acc
|
|
}
|
|
/// Which direction handles belong to selected segments; both for an anchor selection.
|
|
pub fn handle_sides(&self, id: NodeId, si: usize, ai: usize, sp: &vectorcraft_geom::SubPath) -> (bool, bool) {
|
|
let Some(segments) = self.segments.get(&id) else { return (true, true) };
|
|
let previous = ai.checked_sub(1).or_else(|| sp.closed.then_some(sp.segment_count()).and_then(|n| n.checked_sub(1)));
|
|
(previous.is_some_and(|i| segments.contains(&(si, i))), segments.contains(&(si, ai)))
|
|
}
|
|
/// Drop ids that no longer exist or are no longer editable.
|
|
pub fn prune(&mut self, doc: &Document) {
|
|
// Every id this looks up, found in one walk of the tree.
|
|
let ids = self.objects.iter().chain(self.anchors.keys()).chain(self.segments.keys()).copied().chain(self.key).chain(self.target);
|
|
let mut found = doc.index_paths(ids);
|
|
// A ruler guide is selected in `guides` only, never as an object, an anchor or the key.
|
|
found.retain(|_, p| !doc.node_at(p).is_some_and(|n| n.is_ruler_guide()));
|
|
self.objects.retain(|id| found.contains_key(id));
|
|
self.anchors.retain(|id, _| found.contains_key(id));
|
|
self.segments.retain(|id, refs| {
|
|
let Some(path) = found.get(id).and_then(|p| doc.node_at(p)).and_then(|n| n.path_data()) else { return false };
|
|
refs.retain(|&(si, seg)| path.subpaths.get(si).is_some_and(|sp| seg < sp.segment_count()));
|
|
!refs.is_empty() && self.objects.contains(id) && self.anchors.contains_key(id)
|
|
});
|
|
// Guides that are gone, or hidden or locked (themselves or by their layer), can't stay
|
|
// selected.
|
|
if !self.guides.is_empty() {
|
|
let editable = doc.editable();
|
|
self.guides.retain(|id| doc.node(*id).is_some_and(|n| n.is_ruler_guide()) && editable(*id));
|
|
}
|
|
// The key object stays while another selected object lies outside it, or while ruler
|
|
// guides are selected for it to line up (#2143).
|
|
let keep = self.key.and_then(|k| found.get(&k)).and_then(|p| doc.node_at(p)).is_some_and(|n| {
|
|
let mut inside = BTreeSet::new();
|
|
n.walk(&mut |c| {
|
|
inside.insert(c.id);
|
|
});
|
|
self.objects.iter().any(|o| !inside.contains(o)) || (self.objects.contains(&n.id) && !self.guides.is_empty())
|
|
});
|
|
if !keep {
|
|
self.key = None;
|
|
}
|
|
if self.target.is_some_and(|t| !found.contains_key(&t)) {
|
|
self.target = None;
|
|
}
|
|
self.slices.retain(|id| doc.is_slice(*id));
|
|
// A key guide needs objects to line up on it, and stays selected.
|
|
if self.key.is_some() || self.objects.is_empty() || self.key_guide.is_some_and(|k| !self.guides.contains(&k)) {
|
|
self.key_guide = None;
|
|
}
|
|
// A selected part needs its instance selected alone, with no guides, its symbol dynamic
|
|
// and the object still in the symbol's art as a path, compound path or type, where neither
|
|
// it nor a group or layer around it is hidden, locked or a template.
|
|
let editable_part = |art: &crate::Node, path: &[u32]| {
|
|
crate::symbol_overrides::part(art, path).is_some_and(crate::symbol_overrides::takes_part_paint)
|
|
&& (0..=path.len())
|
|
.all(|k| crate::symbol_overrides::part(art, &path[..k]).is_some_and(|n| n.visible && !n.locked && !n.is_template()))
|
|
};
|
|
let part_kept = self.symbol_part.as_ref().is_some_and(|sp| {
|
|
self.objects == [sp.instance]
|
|
&& self.guides.is_empty()
|
|
&& found.get(&sp.instance).and_then(|p| doc.node_at(p)).is_some_and(|n| match &n.kind {
|
|
NodeKind::SymbolInstance { symbol, .. } => {
|
|
doc.symbols.iter().find(|s| s.name == *symbol).is_some_and(|s| s.dynamic && editable_part(&s.art, &sp.path))
|
|
}
|
|
_ => false,
|
|
})
|
|
});
|
|
if !part_kept {
|
|
self.symbol_part = None;
|
|
}
|
|
}
|
|
/// Deselect the objects that are hidden or locked, themselves or through a group or layer
|
|
/// around them (and so the key object when it is one): hidden or locked art can't stay
|
|
/// selected.
|
|
pub fn deselect_uneditable(&mut self, doc: &Document) {
|
|
let locks = doc.locked_artboards();
|
|
let uneditable: HashSet<NodeId> = self.objects.iter().copied().filter(|o| !doc.is_editable_with(&locks, *o)).collect();
|
|
self.deselect_all(&uneditable);
|
|
}
|
|
/// Deselect the objects on a locked artboard ([`crate::LockedArtboards::holds_id`]), whatever
|
|
/// selected them or moved them there (and so the key object when it is one): they act as
|
|
/// locked art. A targeted layer stays targeted, as it does with locked objects in it.
|
|
pub fn deselect_on_locked_artboards(&mut self, doc: &Document) {
|
|
let locks = doc.locked_artboards();
|
|
if locks.is_empty() {
|
|
return;
|
|
}
|
|
let locked: HashSet<NodeId> = self.objects.iter().copied().filter(|o| locks.holds_id(*o)).collect();
|
|
let layer = self.target.filter(|t| doc.node(*t).is_some_and(crate::Node::is_layer));
|
|
self.deselect_all(&locked);
|
|
if layer.is_some() {
|
|
self.target = layer;
|
|
}
|
|
}
|
|
/// Deselect the objects `gone` in one pass, with their anchors, segments and key.
|
|
fn deselect_all(&mut self, gone: &HashSet<NodeId>) {
|
|
if gone.is_empty() {
|
|
return;
|
|
}
|
|
self.target = None;
|
|
self.slices.clear();
|
|
if self.symbol_part.as_ref().is_some_and(|sp| gone.contains(&sp.instance)) {
|
|
self.symbol_part = None;
|
|
}
|
|
self.objects.retain(|o| !gone.contains(o));
|
|
self.anchors.retain(|id, _| !gone.contains(id));
|
|
self.segments.retain(|id, _| !gone.contains(id));
|
|
if self.key.is_some_and(|k| gone.contains(&k)) {
|
|
self.key = None;
|
|
}
|
|
}
|
|
/// Target `id` (see [`Selection::target`]): a layer gets its visible, unlocked art selected
|
|
/// (the art of its sublayers too), anything else is selected itself.
|
|
pub fn set_target(&mut self, doc: &Document, id: NodeId) {
|
|
match doc.node(id) {
|
|
Some(n) if n.is_layer() => self.set(n.layer_art(true)),
|
|
_ => self.set([id]),
|
|
}
|
|
self.target = Some(id);
|
|
}
|
|
/// Shift-clicking `id`'s target circle: it (a layer: its art) joins the selection, or leaves it
|
|
/// when it is all selected already. Several objects are then selected, none targeted. Art on a
|
|
/// locked artboard is neither counted nor added, since
|
|
/// [`Selection::deselect_on_locked_artboards`] deselects it.
|
|
pub fn toggle_target(&mut self, doc: &Document, id: NodeId) {
|
|
let mut ids = match doc.node(id) {
|
|
Some(n) if n.is_layer() => n.layer_art(true),
|
|
_ => vec![id],
|
|
};
|
|
let locks = doc.locked_artboards();
|
|
ids.retain(|i| !locks.holds_id(*i));
|
|
if ids.iter().all(|i| self.contains(*i)) {
|
|
ids.iter().for_each(|i| self.remove(*i));
|
|
} else {
|
|
ids.into_iter().for_each(|i| self.add(i));
|
|
}
|
|
self.target = None;
|
|
}
|
|
/// What appearance and transparency edits act on: the targeted object, group or layer, else
|
|
/// the selected objects.
|
|
pub fn subjects(&self) -> &[NodeId] {
|
|
match &self.target {
|
|
Some(t) => std::slice::from_ref(t),
|
|
None => &self.objects,
|
|
}
|
|
}
|
|
/// Top-level ordering: selected ids sorted by paint order (bottom first).
|
|
pub fn in_paint_order(&self, doc: &Document) -> Vec<NodeId> {
|
|
doc.paint_order(self.objects.iter().copied())
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests;
|