Merge pull request #2592 from dotCooCoo/feat/lock-artboards

Artboards: the Artboards panel locks an artboard and the art on it
This commit is contained in:
BFlat
2026-10-21 23:57:52 +07:00
committed by GitHub
71 changed files with 1270 additions and 132 deletions
+28 -7
View File
@@ -139,6 +139,11 @@ pub fn objects_at(doc: &Document, p: Point, opt: HitOptions, scope: Option<NodeI
return objects_at_repeated(doc, p, opt, scope);
}
}
// The objects on a locked artboard can't be selected, as `hit_test_skipping` passes over them.
let locks = doc.locked_artboards();
if !locks.is_empty() {
tops.retain(|id| !doc.node(*id).is_some_and(|n| locks.holds(n)));
}
tops
}
@@ -280,14 +285,24 @@ fn locked_hit(n: &Node, p: Point, opt: HitOptions, symbols: &SymbolBoxes) -> boo
}
}
/// [`hit_test`] passing over the nodes `skip` names (and their contents). Symbol instances are
/// measured by their art ([`Node::geometric_bounds_with`]).
/// [`hit_test`] passing over the nodes `skip` names (and their contents), and with
/// `editable_only` over the objects of layers and sublayers on a locked artboard, and over all of
/// an isolated group on one ([`crate::LockedArtboards::holds`]). Symbol instances are measured by their art
/// ([`Node::geometric_bounds_with`]).
fn hit_test_skipping(doc: &Document, p: Point, opt: HitOptions, skip: &dyn Fn(NodeId) -> bool, editable_only: bool) -> Option<Hit> {
let finish = |mut h: Hit| {
h.contents_of = h.ancestry.iter().rev().copied().find(|a| doc.node(*a).is_some_and(edits_contents));
h.layers = h.ancestry.iter().take_while(|a| doc.node(**a).is_some_and(Node::is_layer)).count();
h
};
let locks = doc.locked_artboards();
let on_locked = |id: NodeId| {
editable_only
&& !locks.is_empty()
&& doc.parent_of(id).and_then(|l| doc.node(l)).is_some_and(Node::is_layer)
&& doc.node(id).is_some_and(|n| locks.holds(n))
};
let skip = &|id| skip(id) || on_locked(id);
let symbols = doc.symbol_boxes();
// Isolation mode: only what is inside the isolated container hits. The art around it is
// locked, and clicks pass through it (#833).
@@ -296,6 +311,10 @@ fn hit_test_skipping(doc: &Document, p: Point, opt: HitOptions, skip: &dyn Fn(No
&& let Some(isolated) = doc.node(scope)
&& let Some(mut chain) = doc.ancestry(scope)
{
// Isolated before its artboard was locked: nothing in it hits.
if locks.holds_id(scope) {
return None;
}
if isolated.is_container() {
return hit_children(isolated, p, opt, &symbols, skip, &mut chain, editable_only).map(finish);
}
@@ -584,10 +603,11 @@ pub fn compound_part_at(n: &Node, p: Point, tol: f64) -> Option<NodeId> {
parts.iter().filter(|(_, bp, _)| fill_contains(bp, *rule, p)).min_by(|a, b| size(&a.2).total_cmp(&size(&b.2))).map(|(id, ..)| *id)
}
/// Objects (children of layers, or of `scope` in isolation mode) touched by a marquee rect. Symbol
/// instances are measured by their art ([`Node::geometric_bounds_with`]). The art of a clip group
/// or of a layer with a clipping mask counts only when the marquee meets the region its clipping
/// path clips to, and then only within that region's bounds.
/// Objects (children of layers, or of `scope` in isolation mode) touched by a marquee rect, leaving
/// out those on a locked artboard ([`crate::LockedArtboards::holds_id`]). Symbol instances are measured
/// by their art ([`Node::geometric_bounds_with`]). The art of a clip group or of a layer with a
/// clipping mask counts only when the marquee meets the region its clipping path clips to, and then
/// only within that region's bounds.
pub fn marquee(doc: &Document, r: Rect, scope: Option<NodeId>, leaves: bool) -> Vec<NodeId> {
let mut out = Vec::new();
// `tops` pairs each object with the part of `r` in which the marquee can touch it.
@@ -666,8 +686,9 @@ pub fn marquee(doc: &Document, r: Rect, scope: Option<NodeId>, leaves: bool) ->
}
}
let symbols = doc.symbol_boxes();
let locks = doc.locked_artboards();
for (t, r) in tops {
if !t.visible || t.locked {
if !t.visible || t.locked || locks.holds_id(t.id) {
continue;
}
if leaves {
+280 -4
View File
@@ -438,6 +438,12 @@ pub struct Artboard {
pub name: String,
/// Artboard rectangle in document points.
pub rect: Rect,
/// Locked (the Artboards panel's lock column): the Artboard tool and the artboard commands
/// don't move, resize or delete it, Rearrange leaves it where it is, and the art on it can't be
/// selected or edited ([`Document::on_locked_artboard`]), except new art while it stays
/// selected ([`Document::new_art`]).
#[serde(default, skip_serializing_if = "skip::is_default")]
pub locked: bool,
#[serde(default)]
pub show_center_mark: bool,
#[serde(default)]
@@ -450,6 +456,96 @@ pub struct Artboard {
pub color: Option<vectorcraft_color::Color>,
}
/// A document's locked artboards ([`Document::locked_artboards`]).
pub struct LockedArtboards<'a> {
rects: Vec<Rect>,
symbols: SymbolBoxes<'a>,
/// The new art, which isn't on them ([`Document::new_art`]).
new_art: Arc<std::collections::HashSet<NodeId>>,
/// The objects of pattern editing mode's layer ([`Document::pattern_edit`]), which are on no
/// artboard.
pattern_art: std::collections::HashSet<NodeId>,
/// What [`LockedArtboards::overlaps`] found for each object it measured: the members of a
/// group are each answered with one measurement of the group.
held: std::cell::RefCell<std::collections::HashMap<NodeId, bool>>,
}
impl LockedArtboards<'_> {
/// No artboard is locked.
pub fn is_empty(&self) -> bool {
self.rects.is_empty()
}
/// Is `n`, an object of a layer or sublayer, on a locked artboard: shown, not a guide, and its
/// visual bounds (with its strokes, a symbol instance by its art) overlapping the artboard? An
/// object counts for every artboard it overlaps, as Fit to Artwork Bounds counts it. A layer or
/// sublayer is on none, nor is the art of pattern editing mode ([`Document::pattern_edit`]) or
/// new art while it stays selected ([`Document::new_art`]).
pub fn holds(&self, n: &Node) -> bool {
!self.new_art.contains(&n.id) && self.overlaps(n)
}
/// [`LockedArtboards::holds`] for new art too: is `n` on a locked artboard, whether or not it
/// is new art?
pub fn overlaps(&self, n: &Node) -> bool {
if self.rects.is_empty()
|| !n.visible
|| n.is_layer()
|| matches!(n.kind, NodeKind::Path { guide: true, .. })
|| self.pattern_art.contains(&n.id)
{
return false;
}
if let Some(held) = self.held.borrow().get(&n.id) {
return *held;
}
let held =
n.visual_bounds_with(&self.symbols).is_some_and(|b| self.rects.iter().any(|r| b.x0 < r.x1 && b.x1 > r.x0 && b.y0 < r.y1 && b.y1 > r.y0));
self.held.borrow_mut().insert(n.id, held);
held
}
/// Is node `id` on a locked artboard: is the object of a layer or sublayer that holds it (`id`
/// itself when it is one, a group counting as one object) on one ([`LockedArtboards::holds`])?
pub fn holds_id(&self, id: NodeId) -> bool {
!self.rects.is_empty() && self.symbols.document().layer_object(id).is_some_and(|n| self.holds(n))
}
}
/// New art on a locked artboard ([`Document::new_art`]). Setting it changes the document in
/// place, so a selection change that sets it doesn't replace the document.
#[derive(Default)]
pub struct NewArtIds(std::sync::RwLock<Arc<std::collections::HashSet<NodeId>>>);
impl NewArtIds {
/// The objects.
pub fn get(&self) -> Arc<std::collections::HashSet<NodeId>> {
self.0.read().map(|g| g.clone()).unwrap_or_default()
}
/// Make `ids` the objects.
pub fn set(&self, ids: Arc<std::collections::HashSet<NodeId>>) {
if let Ok(mut g) = self.0.write() {
*g = ids;
}
}
}
impl Clone for NewArtIds {
fn clone(&self) -> Self {
NewArtIds(std::sync::RwLock::new(self.get()))
}
}
/// Editing state, not document content: two documents are equal whatever new art they name.
impl PartialEq for NewArtIds {
fn eq(&self, _: &Self) -> bool {
true
}
}
impl std::fmt::Debug for NewArtIds {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_tuple("NewArtIds").field(&self.get()).finish()
}
}
/// Opacity-mask editing mode: `object`'s mask art lives on the temporary `layer` while editing.
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
pub struct MaskEdit {
@@ -801,6 +897,12 @@ pub struct Document {
/// state, never saved.
#[serde(skip)]
pub puppet: Option<Arc<PuppetPins>>,
/// New art on a locked artboard: objects of layers on a locked artboard that an edit made and
/// left selected (a group with selected members counts), kept while they stay selected and on
/// one. They and their contents can be selected and edited ([`LockedArtboards::holds`]). The
/// engine sets it from the selection's ([`Selection::new_art`]). Editing state, never saved.
#[serde(skip)]
pub new_art: NewArtIds,
/// Some object is a dimension (the Dimension tool): edits refresh dimensions only while this
/// is set, so documents without any pay nothing for them.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
@@ -888,6 +990,7 @@ impl Document {
id: 1,
name: "Artboard 1".into(),
rect: Rect::new(0.0, 0.0, width, height),
locked: false,
show_center_mark: false,
show_cross_hairs: false,
color: None,
@@ -932,6 +1035,7 @@ impl Document {
assets: vec![],
svg_scripts: vec![],
puppet: None,
new_art: Default::default(),
dimensions: false,
index: Default::default(),
};
@@ -1365,9 +1469,69 @@ impl Document {
pub fn node_count(&self) -> usize {
self.layers.iter().map(|l| l.count()).sum()
}
/// Is the node (and every ancestor) visible and unlocked?
/// Is the node (and every ancestor) visible and unlocked, and not on a locked artboard
/// ([`Document::on_locked_artboard`])?
pub fn is_editable(&self, id: NodeId) -> bool {
self.ancestry(id).is_some_and(|a| a.iter().all(|i| self.node(*i).is_some_and(|n| n.visible && !n.locked)))
self.is_editable_with(&self.locked_artboards(), id)
}
/// [`Document::is_editable`] with the locked artboards `locks` of this document, measured
/// once for many ids.
pub fn is_editable_with(&self, locks: &LockedArtboards, id: NodeId) -> bool {
self.ancestry(id).is_some_and(|a| a.iter().all(|i| self.node(*i).is_some_and(|n| n.visible && !n.locked))) && !locks.holds_id(id)
}
/// [`Document::is_editable`] for many ids: the art on the locked artboards is measured once
/// for all of them, a group once for all its members.
pub fn editable(&self) -> impl Fn(NodeId) -> bool + '_ {
let locks = self.locked_artboards();
move |id| self.is_editable_with(&locks, id)
}
/// The locked artboards, for asking which art is on them ([`LockedArtboards::holds`]).
pub fn locked_artboards(&self) -> LockedArtboards<'_> {
self.locked_artboards_except(usize::MAX)
}
/// [`Document::locked_artboards`] leaving out artboard `index`.
pub fn locked_artboards_except(&self, index: usize) -> LockedArtboards<'_> {
// Art flush with an edge from outside can overlap it by a rounding error, as in
// `art_bounds_on` (#1295).
let rects: Vec<Rect> =
self.artboards.iter().enumerate().filter(|(i, a)| a.locked && *i != index).map(|(_, a)| a.rect.inflate(-1e-6, -1e-6)).collect();
// The objects of pattern editing mode's layer, a sublayer's objects included.
fn objects(n: &Node, out: &mut std::collections::HashSet<NodeId>) {
for c in n.children().into_iter().flatten() {
if c.is_layer() {
objects(c, out);
} else {
out.insert(c.id);
}
}
}
let mut pattern_art = std::collections::HashSet::new();
if let Some(layer) = self.pattern_edit.as_ref().filter(|_| !rects.is_empty()).and_then(|e| self.layers.iter().find(|l| l.id == e.layer)) {
objects(layer, &mut pattern_art);
}
LockedArtboards { rects, symbols: self.symbol_boxes(), new_art: self.new_art.get(), pattern_art, held: Default::default() }
}
/// Is `id` on a locked artboard ([`LockedArtboards::holds_id`])? A layer or sublayer is on none.
pub fn on_locked_artboard(&self, id: NodeId) -> bool {
self.locked_artboards().holds_id(id)
}
/// The object of a layer or sublayer that holds node `id`: `id` itself when it is one, else
/// the group or other object around it right inside a layer. None for a layer or sublayer.
pub fn layer_object(&self, id: NodeId) -> Option<&Node> {
self.layer_object_at(&self.index_path(id)?)
}
/// [`Document::layer_object`] for the node at index path `path`.
pub fn layer_object_at(&self, path: &[usize]) -> Option<&Node> {
// Down the path to the first node that isn't a layer.
let mut nodes = &self.layers;
for &i in path {
let n = nodes.get(i)?;
if !n.is_layer() {
return Some(n.as_ref());
}
nodes = n.children()?;
}
None
}
pub fn is_visible(&self, id: NodeId) -> bool {
self.ancestry(id).is_some_and(|a| a.iter().all(|i| self.node(*i).is_some_and(|n| n.visible)))
@@ -1378,9 +1542,16 @@ impl Document {
a.iter().rev().copied().find(|i| self.node(*i).is_some_and(Node::is_layer))
}
/// Every art object the Selection tool can reach (Select All): the visible, unlocked objects
/// of the visible, unlocked, non-template layers, looking through sublayers (bottom first).
/// of the visible, unlocked, non-template layers, looking through sublayers (bottom first),
/// except those on a locked artboard.
pub fn selectable_art(&self) -> Vec<NodeId> {
self.layers.iter().filter(|l| l.visible && !l.locked && !l.is_template()).flat_map(|l| l.layer_art(true)).collect()
let locks = self.locked_artboards();
self.layers
.iter()
.filter(|l| l.visible && !l.locked && !l.is_template())
.flat_map(|l| l.layer_art(true))
.filter(|id| locks.is_empty() || !self.node(*id).is_some_and(|n| locks.holds(n)))
.collect()
}
/// Colour of the innermost layer or sublayer containing `id` (selection highlight colour).
pub fn layer_color(&self, id: NodeId) -> [u8; 3] {
@@ -1453,6 +1624,18 @@ impl Document {
}
art
}
/// The art that moves, scales or is cut with artboard `index`: [`Document::art_on_artboard`]
/// without the objects on another locked artboard, which stay unless `locked_and_hidden`, as
/// locked art does.
pub fn art_moving_with(&self, index: usize, locked_and_hidden: bool) -> Vec<NodeId> {
let Some(rect) = self.artboards.get(index).map(|a| a.rect) else { return vec![] };
let mut art = self.art_on_artboard(rect, locked_and_hidden);
let locks = self.locked_artboards_except(index);
if !locked_and_hidden && !locks.is_empty() {
art.retain(|id| !self.node(*id).is_some_and(|n| locks.holds(n)));
}
art
}
/// Where ruler guide `g` runs along its line (the y range of a vertical guide): across its
/// artboard for an artboard guide, None (the whole canvas) for a canvas guide or one whose
/// artboard is gone.
@@ -1939,6 +2122,99 @@ mod tests {
}
}
/// The art on a locked artboard (each object of a layer whose bounds overlap it, a group as one
/// object) can't be edited, and Select All, a click and a marquee pass over it; art flush with
/// the artboard from outside isn't on it, a layer is on no artboard, and new art isn't on one.
/// Unlocking the artboard gives the art back.
#[test]
fn art_on_a_locked_artboard_cannot_be_selected_or_edited() {
use crate::hit::{HitOptions, hit_test, marquee, objects_at};
let (mut d, a, b) = doc_with_rects();
let layer = d.layers[0].id;
d.artboards.push(Artboard {
id: 2,
name: "Artboard 2".into(),
rect: Rect::new(700.0, 0.0, 900.0, 200.0),
locked: false,
show_center_mark: false,
show_cross_hairs: false,
color: None,
});
let rect = |d: &mut Document, r: Rect, appearance: Appearance| {
let id = d.alloc_id();
d.insert(Some(layer), usize::MAX, Node::path(id, shapes::rectangle(r), appearance)).unwrap();
id
};
let c = rect(&mut d, Rect::new(710.0, 10.0, 730.0, 30.0), Appearance::default_art());
// A group with one member on the artboard and one right of it.
let (g, m1, m2) = (d.alloc_id(), d.alloc_id(), d.alloc_id());
let members = [(m1, 750.0), (m2, 950.0)]
.map(|(id, x)| Arc::new(Node::path(id, shapes::rectangle(Rect::new(x, 50.0, x + 10.0, 60.0)), Appearance::default_art())));
d.insert(Some(layer), usize::MAX, Node::group(g, members.to_vec())).unwrap();
// Unstroked and flush with the artboard's left edge, from outside.
let e = rect(&mut d, Rect::new(680.0, 10.0, 700.0, 30.0), Appearance::default());
let opt = HitOptions::default();
let top = |d: &Document, x, y| hit_test(d, Point::new(x, y), opt).map(|h| h.top_object(None));
let all = Rect::new(-10.0, -10.0, 1000.0, 800.0);
assert_eq!(top(&d, 720.0, 20.0), Some(c));
d.artboards[1].locked = true;
for id in [c, g, m1, m2] {
assert!(d.on_locked_artboard(id) && !d.is_editable(id), "{id:?} is on the locked artboard");
}
for id in [a, b, e, layer] {
assert!(!d.on_locked_artboard(id) && d.is_editable(id), "{id:?} isn't");
}
assert_eq!(d.selectable_art(), [a, b, e]);
assert_eq!(top(&d, 720.0, 20.0), None, "a click passes over c");
assert_eq!(top(&d, 955.0, 55.0), None, "and over the group's member off the artboard");
let isolated = HitOptions { scope: Some(g), ..opt };
assert!(hit_test(&d, Point::new(755.0, 55.0), isolated).is_none(), "and inside the group isolated before the lock");
assert!(objects_at(&d, Point::new(720.0, 20.0), opt, None).is_empty());
assert_eq!(marquee(&d, all, None, false), [a, b, e]);
assert_eq!(marquee(&d, all, None, true), [a, b, e], "the Direct Selection tool's marquee too");
// Deselected in one pass, with the key object and the anchors.
let mut sel = Selection::default();
sel.set([a, c]);
sel.key = Some(c);
sel.anchors.insert(c, [(0, 0)].into());
sel.deselect_on_locked_artboards(&d);
assert_eq!((sel.objects.clone(), sel.key, sel.anchors.is_empty()), (vec![a], None, true));
// A targeted layer stays targeted, with its art off the locked artboard selected.
sel.set_target(&d, layer);
sel.deselect_on_locked_artboards(&d);
assert_eq!((sel.objects.clone(), sel.target), (vec![a, b, e], Some(layer)));
// Shift-clicking the layer's target circle removes its art off the locked artboard, and
// a second Shift-click adds it again.
sel.toggle_target(&d, layer);
sel.deselect_on_locked_artboards(&d);
assert_eq!((sel.objects.clone(), sel.target), (vec![], None));
sel.toggle_target(&d, layer);
sel.deselect_on_locked_artboards(&d);
assert_eq!(sel.objects, [a, b, e]);
// A sublayer with art on both artboards: its art off the locked artboard can still be clicked.
let (sub, s1, s2) = (d.alloc_id(), d.alloc_id(), d.alloc_id());
d.insert(Some(layer), usize::MAX, Node::layer(sub, "Sub", LayerColor::Preset(2))).unwrap();
for (id, x, y) in [(s1, 300.0, 300.0), (s2, 800.0, 100.0)] {
d.insert(Some(sub), usize::MAX, Node::path(id, shapes::rectangle(Rect::new(x, y, x + 10.0, y + 10.0)), Appearance::default_art()))
.unwrap();
}
assert_eq!((top(&d, 305.0, 305.0), top(&d, 805.0, 105.0)), (Some(s1), None));
assert!(!d.on_locked_artboard(sub));
let mut both = Selection::default();
both.set([s1, s2]);
both.deselect_on_locked_artboards(&d);
assert_eq!(both.objects, [s1]);
// New art on it is editable and can be clicked while it is new art.
d.new_art.set(Arc::new([c].into()));
assert!(!d.on_locked_artboard(c) && d.is_editable(c), "new art");
assert_eq!(top(&d, 720.0, 20.0), Some(c));
d.new_art.set(Default::default());
d.artboards[1].locked = false;
assert_eq!(d.selectable_art(), [a, b, c, g, e, s1, s2]);
assert_eq!(top(&d, 955.0, 55.0), Some(g));
assert_eq!(marquee(&d, all, None, false), [a, b, c, g, e, s1, s2]);
}
#[test]
fn art_on_artboard_measures_a_symbol_instance_by_its_art() {
let mut d = Document::new(612.0, 792.0);
+38 -8
View File
@@ -1,6 +1,7 @@
//! 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};
@@ -48,6 +49,11 @@ pub struct Selection {
/// 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<usize>,
/// 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>>,
}
impl Selection {
@@ -206,16 +212,36 @@ impl Selection {
/// 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 uneditable: HashSet<NodeId> = self.objects.iter().copied().filter(|o| !doc.is_editable(*o)).collect();
if uneditable.is_empty() {
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();
self.objects.retain(|o| !uneditable.contains(o));
self.anchors.retain(|id, _| !uneditable.contains(id));
self.segments.retain(|id, _| !uneditable.contains(id));
if self.key.is_some_and(|k| uneditable.contains(&k)) {
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;
}
}
@@ -229,12 +255,16 @@ impl Selection {
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.
/// 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 ids = match doc.node(id) {
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 {
+2 -1
View File
@@ -1330,7 +1330,8 @@ fn symbol_adjust(s: &mut Session, p: &Value) -> Result<Value> {
}
}
}
v.retain(|id| d.is_editable(*id));
let editable = d.editable();
v.retain(|id| editable(*id));
v
};
let label = match tool.as_str() {
+2 -1
View File
@@ -130,7 +130,8 @@ fn cut_targets(s: &Session) -> Result<Vec<NodeId>> {
}
}
out.dedup();
out.retain(|id| doc.is_editable(*id));
let editable = doc.editable();
out.retain(|id| editable(*id));
Ok(out)
}
+3 -1
View File
@@ -165,11 +165,13 @@ fn apply_to_all(s: &mut Session, p: &Value) -> Result<Value> {
};
let o = dimension_of(&st.doc, from)?.options;
let mut ids = vec![];
let editable = st.doc.editable();
st.doc.walk(|n| {
if n.extras().dimension.is_some() && st.doc.is_editable(n.id) {
if n.extras().dimension.is_some() && editable(n.id) {
ids.push(n.id);
}
});
drop(editable);
s.edit("Apply to All", |doc, _| {
for id in &ids {
let mut dim = dimension_of(doc, *id)?;
+12 -7
View File
@@ -455,7 +455,8 @@ fn samples_of(p: &Value, key: &str) -> Option<Vec<Sample>> {
/// What a liquify stroke may reach: the paths it can edit, each with the box no dab outside
/// changes it ([`reach_bounds`]), and the objects it leaves as they are (type, symbols, images,
/// graphs, meshes, and envelopes, repeats and blends with their contents), each with its bounds
/// and what it is. Hidden and locked objects, and guides, are neither.
/// and what it is. Hidden and locked objects, the art on a locked artboard, and guides are
/// neither.
#[derive(Default)]
struct Targets {
paths: Vec<(NodeId, Rect)>,
@@ -484,19 +485,21 @@ impl Targets {
/// The targets under `roots` (the selection), or with none every visible object.
fn of(d: &Document, roots: &[NodeId]) -> Self {
let mut t = Self::default();
let locks = d.locked_artboards();
if roots.is_empty() {
d.layers.iter().for_each(|l| t.visit(l));
d.layers.iter().for_each(|l| t.visit(l, &locks));
return t;
}
let editable = |id| d.is_editable_with(&locks, id);
for r in roots {
let (Some(n), Some(chain)) = (d.node(*r), d.ancestry(*r)) else { continue };
if !d.is_editable(*r) {
if !editable(*r) {
continue;
}
// Inside a graph, an envelope, a repeat or a blend: that object is what is left alone.
match chain.iter().filter_map(|a| d.node(*a)).find_map(|a| Some((a, not_liquified(a)?))) {
Some((a, what)) => t.block(a, what),
None => t.visit(n),
None => t.visit(n, &locks),
}
}
t
@@ -510,7 +513,7 @@ impl Targets {
}
}
fn visit(&mut self, n: &Node) {
fn visit(&mut self, n: &Node, locks: &vectorcraft_doc::LockedArtboards) {
if !n.visible || n.locked {
return;
}
@@ -525,7 +528,8 @@ impl Targets {
self.paths.push((n.id, b));
}
}
_ => n.children().into_iter().flatten().for_each(|c| self.visit(c)),
// The objects of a layer on a locked artboard, members and all, are left out.
_ => n.children().into_iter().flatten().filter(|c| !(n.is_layer() && locks.holds(c))).for_each(|c| self.visit(c, locks)),
}
}
}
@@ -1144,9 +1148,10 @@ fn plane_move(s: &mut Session, p: &Value) -> Result<Value> {
};
// The objects on the plane where it was (not those moved off it along its normal).
let on_plane = |d: &Document| -> Vec<NodeId> {
let editable = d.editable();
persp::attached_roots(d)
.into_iter()
.filter(|(id, pl, depth)| *pl == plane && (depth - from).abs() < 1e-6 && d.is_editable(*id))
.filter(|(id, pl, depth)| *pl == plane && (depth - from).abs() < 1e-6 && editable(*id))
.map(|(id, ..)| id)
.collect()
};
+4 -2
View File
@@ -543,10 +543,11 @@ fn release_guides(s: &mut Session, p: &Value) -> Result<Value> {
let roots = if ids.is_some() { checked_ids_param(s, p, "ids", C)? } else { vec![] };
let d = &s.doc()?.doc;
let mut paths = vec![];
let editable = d.editable();
for r in roots {
if let Some(n) = d.node(r) {
n.walk(&mut |c| {
if matches!(c.kind, NodeKind::Path { guide: true, .. }) && d.is_editable(c.id) && !paths.contains(&c.id) {
if matches!(c.kind, NodeKind::Path { guide: true, .. }) && editable(c.id) && !paths.contains(&c.id) {
paths.push(c.id);
}
});
@@ -562,7 +563,8 @@ fn release_guides(s: &mut Session, p: &Value) -> Result<Value> {
} else {
st.selection.guides.iter().copied().filter(|i| st.doc.guides.get(*i).is_some_and(|g| st.doc.guide_editable(g))).collect()
};
if sel.is_empty() && rulers.is_empty() { (vec![], all.into_iter().filter(|id| st.doc.is_editable(*id)).collect()) } else { (rulers, sel) }
let editable = st.doc.editable();
if sel.is_empty() && rulers.is_empty() { (vec![], all.into_iter().filter(|id| editable(*id)).collect()) } else { (rulers, sel) }
};
// Find each ruler guide's line and its parent: the guide's layer, else where new art goes.
let st = s.doc()?;
+2 -1
View File
@@ -293,7 +293,8 @@ fn target_paths(doc: &Document, sel: &Selection, area: Rect) -> Vec<NodeId> {
}
}
}
out.retain(|id| doc.is_editable(*id));
let editable = doc.editable();
out.retain(|id| editable(*id));
out
}
+2
View File
@@ -399,6 +399,7 @@ fn undo(s: &mut Session, _: &Value) -> Result<Value> {
if guides_locked {
crate::deselect_guides(&st.doc, &mut st.selection);
}
st.sync_new_art();
super::maskedit::follow_history(st, editing);
Ok(json!({ "undone": label }))
}
@@ -423,6 +424,7 @@ fn redo(s: &mut Session, _: &Value) -> Result<Value> {
if guides_locked {
crate::deselect_guides(&st.doc, &mut st.selection);
}
st.sync_new_art();
super::maskedit::follow_history(st, editing);
Ok(json!({ "redone": label }))
}
+9 -1
View File
@@ -136,7 +136,15 @@ impl Builder {
.into_iter()
.enumerate()
.filter(|(_, (_, r))| r.width() > 0.0 && r.height() > 0.0)
.map(|(i, (name, rect))| Artboard { id: i as u32 + 1, name, rect, show_center_mark: false, show_cross_hairs: false, color: None })
.map(|(i, (name, rect))| Artboard {
id: i as u32 + 1,
name,
rect,
locked: false,
show_center_mark: false,
show_cross_hairs: false,
color: None,
})
.collect();
if self.doc.artboards.is_empty() {
return Err(err("the Affinity document has no page with a size"));
+1 -1
View File
@@ -340,7 +340,7 @@ pub(super) fn single_artboard(doc: &Document, rect: Rect, name: &str) -> Documen
/// `d` with one artboard, `rect`.
pub(crate) fn with_single_artboard(mut d: Document, rect: Rect, name: &str) -> Document {
d.artboards = vec![Artboard { id: 1, name: name.into(), rect, show_center_mark: false, show_cross_hairs: false, color: None }];
d.artboards = vec![Artboard { id: 1, name: name.into(), rect, locked: false, show_center_mark: false, show_cross_hairs: false, color: None }];
d
}
+2 -1
View File
@@ -473,7 +473,8 @@ fn target_roots(s: &Session, p: &Value) -> Result<Vec<NodeId>> {
let clipping = |id: NodeId| {
d.parent_of(id).and_then(|p| d.node(p)).is_some_and(|p| p.clips() && p.children().and_then(|c| c.first()).is_some_and(|c| c.id == id))
};
Ok(roots.into_iter().filter(|id| d.is_visible(*id) && d.is_editable(*id) && !clipping(*id)).collect())
let editable = d.editable();
Ok(roots.into_iter().filter(|id| d.is_visible(*id) && editable(*id) && !clipping(*id)).collect())
}
// ---------- preview ----------
+33 -13
View File
@@ -134,7 +134,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Delete Artboard",
["Window", "Artboards"],
None,
"{index | indices: [index…]} delete artboards (with their guides) in one step; one is always left",
"{index | indices: [index…]} delete artboards (with their guides) in one step; one is always left, and a locked one is an error",
has_doc,
artboard_delete
),
@@ -143,7 +143,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Artboard Options…",
["Window", "Artboards"],
None,
"{index | artboards: [{index, name?, color?, x?, y?, width?, height?}…] (several artboards in one undo step, each with its own fields; the art inside each before any of them changes goes with it, and an object inside several goes with the first listed that moves or scales its art), name?, color?: color (\"#rrggbb\", {c,m,y,k}, … as the paint commands take: the background color the canvas draws the artboard's paper in, multiplied with the Simulate Colored Paper color while that is on; exports and print leave it out) | null (no background color), x?, y?, width?, height?, moveArt?: bool (default false; a position-only change moves the contained art with its artboard), scaleArt?: bool (Scale Artwork with Artboard: resized, the artboard takes the art fully inside it (locked and hidden art only with lockedAndHidden?: bool, default prefs moveLockedWithArtboard) and its guides from its old rectangle onto the new one, each side by its own ratio; strokes/corners/patterns as object.scale), strokes?: bool, corners?: bool, patterns?: bool (the three as object.scale, for art that moves or scales with the artboard), lockedAndHidden?: bool (locked and hidden art moves along; default: the preference; recorded in the journal so a replay matches)} → null, or with scaleArt {scaled: the art scaled}",
"{index | artboards: [{index, name?, color?, locked?, x?, y?, width?, height?}…] (several artboards in one undo step, each with its own fields; the art inside each before any of them changes goes with it, and an object inside several goes with the first listed that moves or scales its art), name?, color?: color (\"#rrggbb\", {c,m,y,k}, … as the paint commands take: the background color the canvas draws the artboard's paper in, multiplied with the Simulate Colored Paper color while that is on; exports and print leave it out) | null (no background color), locked?: bool (the Artboards panel's lock column: a locked artboard keeps its rectangle (a new x, y, width or height is an error unless locked is false), can't be moved, deleted or cut, and Rearrange and Fit to Artwork Bounds leave it as it is; the art on it (each object of a layer or sublayer whose bounds with strokes overlap it, a group as one object) acts as locked art until it is unlocked: clicks, marquees, select.set, select.all and the tools pass over it (art drawn or pasted onto it stays selected and editable until a selection leaves it out or it is moved off the locked artboards), it is deselected, and when another artboard moves or scales it stays unless lockedAndHidden), x?, y?, width?, height?, moveArt?: bool (default false; a position-only change moves the contained art with its artboard), scaleArt?: bool (Scale Artwork with Artboard: resized, the artboard takes the art fully inside it (locked and hidden art only with lockedAndHidden?: bool, default prefs moveLockedWithArtboard) and its guides from its old rectangle onto the new one, each side by its own ratio; strokes/corners/patterns as object.scale), strokes?: bool, corners?: bool, patterns?: bool (the three as object.scale, for art that moves or scales with the artboard), lockedAndHidden?: bool (locked and hidden art moves along; default: the preference; recorded in the journal so a replay matches)} → null, or with scaleArt {scaled: the art scaled}",
has_doc,
artboard_set
),
@@ -152,7 +152,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Fit to Artwork Bounds",
["Object", "Artboards"],
None,
"{index?: n | indices?: [n…] (0-based; the app passes the selected artboards: the Artboard tool's, else the active one; default: the first)} each artboard fits the art on it that shows (with its strokes and effects; hidden objects and layers, guides and template layers don't count), in one undo step: an object counts for every artboard its bounds overlap, and a group or clipping layer counts as one object; an artboard with no art stays as it is, and the command fails when no artboard it names has art",
"{index?: n | indices?: [n…] (0-based; the app passes the selected artboards: the Artboard tool's, else the active one; default: the first)} each artboard fits the art on it that shows (with its strokes and effects; hidden objects and layers, guides and template layers don't count), in one undo step: an object counts for every artboard its bounds overlap, and a group or clipping layer counts as one object; an artboard with no art, or a locked one, stays as it is, and the command fails when every artboard it names is locked or none has art",
has_doc,
artboard_fit_art
),
@@ -161,7 +161,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Fit to Selected Art",
["Object", "Artboards"],
None,
"{index?: n (0-based; the app passes the active artboard; default: the first)} the artboard fits the selected art (with its strokes)",
"{index?: n (0-based; the app passes the active artboard; default: the first)} the artboard fits the selected art (with its strokes); a locked artboard stays as it is (an error when the fit would change it)",
has_selection,
artboard_fit_sel
),
@@ -823,7 +823,7 @@ fn artboard_new(s: &mut Session, p: &Value) -> Result<Value> {
let id = d.next_artboard_id();
let given = str_param(p, "name").map(str::trim).filter(|n| !n.is_empty());
let name = given.map_or_else(|| format!("Artboard {}", d.artboards.len() + 1), str::to_string);
d.artboards.push(Artboard { id, name, rect: r, show_center_mark: false, show_cross_hairs: false, color });
d.artboards.push(Artboard { id, name, rect: r, locked: false, show_center_mark: false, show_cross_hairs: false, color });
Ok(d.artboards.len() - 1)
})?;
Ok(json!({ "index": idx }))
@@ -831,6 +831,7 @@ fn artboard_new(s: &mut Session, p: &Value) -> Result<Value> {
fn artboard_delete(s: &mut Session, p: &Value) -> Result<Value> {
let indices = super::panelcmds::artboard_indices(&s.doc()?.doc, p, "artboard.delete")?;
super::panelcmds::refuse_locked(&s.doc()?.doc, &indices)?;
s.edit("Delete Artboard", |d, sel| {
if indices.len() >= d.artboards.len() {
return Err(EngineError::Other("a document needs at least one artboard".into()));
@@ -845,14 +846,15 @@ fn artboard_delete(s: &mut Session, p: &Value) -> Result<Value> {
ok()
}
/// One artboard's part of `artboard.setProps`: its index, new rectangle and name, how far it moved,
/// whether it was resized, the map its art and guides take when they go with it (`fit` when it
/// scales them), and that art.
/// One artboard's part of `artboard.setProps`: its index, new rectangle, name, color and lock, how
/// far it moved, whether it was resized, the map its art and guides take when they go with it (`fit` when
/// it scales them), and that art.
struct ArtboardEdit<'a> {
index: usize,
now: Rect,
name: Option<&'a str>,
color: Option<Option<vectorcraft_color::Color>>,
lock: Option<bool>,
delta: vectorcraft_geom::Vec2,
resized: bool,
fit: Option<Affine>,
@@ -887,6 +889,16 @@ fn artboard_set(s: &mut Session, p: &Value) -> Result<Value> {
// The background color: a color sets it, null clears it, absent leaves it as it is.
let color = artboard_color(q, "artboard.setProps")?;
let now = rect_from(q, was);
let lock = q.get("locked").and_then(Value::as_bool);
// A locked artboard keeps its rectangle unless this unlocks it; its name can change.
let reshaped = [now.x0 - was.x0, now.y0 - was.y0, now.x1 - was.x1, now.y1 - was.y1].iter().any(|d| d.abs() > 1e-6);
if reshaped && lock != Some(false) {
super::panelcmds::refuse_locked(d, &[index])?;
}
// Values within 1e-6 pt of a locked artboard's (as Artboard Options passes them) leave its
// rectangle exactly as it is.
let held = lock != Some(false) && d.artboards.get(index).is_some_and(|a| a.locked);
let now = if held { was } else { now };
let resized = (now.width() - was.width()).abs() > 1e-6 || (now.height() - was.height()).abs() > 1e-6;
let fit = if scale_art && resized { rect_map(was, now) } else { None };
// Position fields honor Move Artwork independently of Scale Artwork. A resize still
@@ -894,8 +906,8 @@ fn artboard_set(s: &mut Session, p: &Value) -> Result<Value> {
let delta = now.origin() - was.origin();
let moving = !resized && bool_or(p, "moveArt", false) && delta != vectorcraft_geom::Vec2::ZERO;
let transform = fit.or_else(|| moving.then(|| Affine::translate(delta)));
let art = if transform.is_some() { d.art_on_artboard(was, all).into_iter().filter(|id| taken.insert(*id)).collect() } else { vec![] };
edits.push(ArtboardEdit { index, now, name: str_param(q, "name").map(str::trim), color, delta, resized, fit, transform, art });
let art = if transform.is_some() { d.art_moving_with(index, all).into_iter().filter(|id| taken.insert(*id)).collect() } else { vec![] };
edits.push(ArtboardEdit { index, now, name: str_param(q, "name").map(str::trim), color, lock, delta, resized, fit, transform, art });
}
// Noted in the journal when art goes along, so a replay transforms the same objects whatever
// the preference is then.
@@ -917,6 +929,9 @@ fn artboard_set(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(c) = e.color {
a.color = c;
}
if let Some(l) = e.lock {
a.locked = l;
}
let id = a.id;
if let Some(xf) = e.transform {
let mut sc = if e.fit.is_some_and(|xf| Scaling::factor(xf).is_some()) { scaling } else { Scaling::default() };
@@ -954,14 +969,19 @@ fn rect_map(from: Rect, to: Rect) -> Option<Affine> {
/// Each artboard of `index` or `indices` (default: the first) fits the art on it that exports
/// draw ([`vectorcraft_render::encode::art_bounds_on`]): hidden objects and layers, guides and
/// template layers don't count. An artboard with no art on it stays as it is, and the command
/// fails when none has art. The fits are one undo step named Artboard Options.
/// template layers don't count. An artboard with no art on it, or a locked one, stays as it is,
/// and the command fails when every one it names is locked or none has art. The fits are one undo
/// step named Artboard Options.
fn artboard_fit_art(s: &mut Session, p: &Value) -> Result<Value> {
let d = &s.doc()?.doc;
let indices =
if p.get("index").is_none() && p.get("indices").is_none() { vec![0] } else { super::panelcmds::artboard_indices(d, p, "artboard.fitToArt")? };
let unlocked: Vec<usize> = indices.iter().copied().filter(|i| d.artboards.get(*i).is_some_and(|a| !a.locked)).collect();
if unlocked.is_empty() {
super::panelcmds::refuse_locked(d, &indices)?;
}
let mut fits = vec![];
for i in indices {
for i in unlocked {
let was = d.artboards.get(i).map(|a| a.rect).ok_or_else(|| EngineError::Other("no such artboard".into()))?;
if let Some(b) = vectorcraft_render::encode::art_bounds_on(d, was) {
// At least 1 pt a side, as `rect_from` makes Artboard Options' sizes.
+21 -10
View File
@@ -157,7 +157,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Rearrange All Artboards…",
["Object", "Artboards"],
None,
"{layout?: gridByRow|gridByColumn|row|column (gridByRow; byColumn?: true is gridByColumn), order?: leftToRight|rightToLeft (leftToRight: right-to-left mirrors the order along the rows), columns?: n (2; the rows for gridByColumn; row and column ignore it), spacing?: pt (20), moveArtwork?: true (locked and hidden art only with prefs moveLockedWithArtboard), patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles), byColumn?: bool (an older way to ask for layout gridByColumn)} lay the artboards out in their order, the grid's top-left at the first artboard's → {artboards, rows, columns}",
"{layout?: gridByRow|gridByColumn|row|column (gridByRow; byColumn?: true is gridByColumn), order?: leftToRight|rightToLeft (leftToRight: right-to-left mirrors the order along the rows), columns?: n (2; the rows for gridByColumn; row and column ignore it), spacing?: pt (20), moveArtwork?: true (each object of a layer or sublayer moves with the artboard its center is on; locked and hidden art, and art on a locked artboard, only with prefs moveLockedWithArtboard), patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles), byColumn?: bool (an older way to ask for layout gridByColumn)} lay the artboards out in their order, the grid's top-left at the first artboard's; a locked artboard stays where it is → {artboards, rows, columns}",
has_doc,
rearrange_artboards
),
@@ -964,6 +964,7 @@ fn convert_to_artboards(s: &mut Session, _: &Value) -> Result<Value> {
id: aid,
name: format!("Artboard {aid}"),
rect: b,
locked: false,
show_center_mark: false,
show_cross_hairs: false,
color: None,
@@ -1015,22 +1016,32 @@ fn rearrange_artboards(s: &mut Session, p: &Value) -> Result<Value> {
let sizes: Vec<(f64, f64)> = rects.iter().map(|r| (r.width(), r.height())).collect();
let first = rects.first().map_or(Point::ZERO, |r| r.origin());
let origins = super::newdoc::grid_origins_rc(&sizes, first, (rows, cols), spacing, layout == L::GridByColumn, rtl);
let deltas: Vec<Vec2> = rects.iter().zip(origins).map(|(r, o)| o - r.origin()).collect();
// A locked artboard stays where it is, and keeps its place in the grid.
let deltas: Vec<Vec2> =
rects.iter().zip(origins).zip(&d.artboards).map(|((r, o), a)| if a.locked { Vec2::ZERO } else { o - r.origin() }).collect();
// When no artboard moves more than 1e-9 pt on either axis, or there is none, the document and
// its undo history stay as they are.
if deltas.iter().all(|dl| dl.x.abs() <= 1e-9 && dl.y.abs() <= 1e-9) {
return Ok(json!({ "artboards": count, "rows": rows, "columns": cols }));
}
// With moveArtwork, each top-level object moves with the artboard its center is on, with symbol
// instances measured by their art as `Document::art_on_artboard` measures them; locked and
// hidden art moves only when prefs moveLockedWithArtboard is on.
// With moveArtwork, each top-level object (a child of a layer or sublayer) moves with the
// artboard its center is on, with symbol instances measured by their art as
// `Document::art_on_artboard` measures them; locked and hidden art, and art on a locked
// artboard, moves only when prefs moveLockedWithArtboard is on.
fn objects<'a>(n: &'a Node, all: bool, out: &mut Vec<&'a Node>) {
for c in n.children().into_iter().flatten().filter(|c| c.rides_with_artboard(all)) {
if c.is_layer() { objects(c, all, out) } else { out.push(c) }
}
}
let locks = d.locked_artboards();
let symbols = d.symbol_boxes();
let art: Vec<(NodeId, Vec2)> = if move_art {
d.layers
.iter()
.filter(|l| l.rides_with_artboard(locked_and_hidden))
.flat_map(|l| l.children().into_iter().flatten())
.filter(|n| n.rides_with_artboard(locked_and_hidden))
let mut tops = vec![];
for l in d.layers.iter().filter(|l| l.rides_with_artboard(locked_and_hidden)) {
objects(l, locked_and_hidden, &mut tops);
}
tops.into_iter()
.filter(|n| locked_and_hidden || !locks.holds(n))
.filter_map(|n| {
let c = n.geometric_bounds_with(&symbols)?.center();
let dl = *d.artboard_holding(c, false).and_then(|i| deltas.get(i))?;
+1
View File
@@ -338,6 +338,7 @@ impl DocSettings {
id: i as u32 + 1,
name: format!("Artboard {}", i + 1),
rect,
locked: false,
show_center_mark: false,
show_cross_hairs: false,
color: None,
+4
View File
@@ -1087,6 +1087,8 @@ fn isolate(s: &mut Session, p: &Value) -> Result<Value> {
}
st.isolation = Some(isolation);
st.selection.clear();
// New art on a locked artboard, now deselected, is protected.
st.sync_new_art();
st.revision += 1;
ok()
}
@@ -1102,6 +1104,8 @@ fn exit_isolation(s: &mut Session, _: &Value) -> Result<Value> {
if guides_locked {
crate::deselect_guides(&st.doc, &mut st.selection);
}
// The art on a locked artboard can't be selected.
st.protect_locked_artboards(&[], &Default::default(), None);
}
st.revision += 1;
ok()
+30 -10
View File
@@ -34,7 +34,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Cut Artboard",
[],
None,
"{index? | indices?, art?} artboard.copy, then delete the artboards and that art in one undo step (one artboard is always left: the cut fails when it names every artboard); edit.cut runs this while the Artboard tool is chosen → {copied: objects}",
"{index? | indices?, art?} artboard.copy, then delete the artboards and that art in one undo step (one artboard is always left: the cut fails when it names every artboard, or a locked one; art also on another locked artboard stays, and is left off the clipboard, unless prefs moveLockedWithArtboard); edit.cut runs this while the Artboard tool is chosen → {copied: objects}",
has_doc,
|s, p| artboard_copy(s, p, true)
),
@@ -114,21 +114,37 @@ pub(crate) fn artboard_indices(d: &vectorcraft_doc::Document, p: &Value, cmd: &s
Ok(indices)
}
/// An error when one of artboards `indices` of `d` is locked: a locked artboard isn't moved,
/// resized or deleted.
pub(crate) fn refuse_locked(d: &vectorcraft_doc::Document, indices: &[usize]) -> Result<()> {
match indices.iter().any(|i| d.artboards.get(*i).is_some_and(|a| a.locked)) {
true => Err(EngineError::Other("the artboard is locked".into())),
false => Ok(()),
}
}
/// The art that goes with artboard `i` when `art`: the objects fully inside it (locked and hidden
/// ones only with prefs moveLockedWithArtboard), in paint order.
pub(crate) fn artboard_art(s: &Session, i: usize, art: bool) -> Result<Vec<NodeId>> {
/// ones only with prefs moveLockedWithArtboard), in paint order. With `moving` (a move or a cut),
/// the art also on another locked artboard stays unless prefs moveLockedWithArtboard
/// ([`vectorcraft_doc::Document::art_moving_with`]); a copy takes it.
pub(crate) fn artboard_art(s: &Session, i: usize, art: bool, moving: bool) -> Result<Vec<NodeId>> {
let d = &s.doc()?.doc;
let rect = d.artboards.get(i).map(|a| a.rect).ok_or_else(|| EngineError::Other("no such artboard".into()))?;
Ok(if art { d.art_on_artboard(rect, s.prefs.move_locked_with_artboard) } else { vec![] })
let all = s.prefs.move_locked_with_artboard;
Ok(match (art, moving) {
(false, _) => vec![],
(true, false) => d.art_on_artboard(rect, all),
(true, true) => d.art_moving_with(i, all),
})
}
/// Each of `indices` with the art that goes with it when `art` ([`artboard_art`]), an object
/// counted once, with the first of them it is on (artboards can overlap).
pub(crate) fn artboards_art(s: &Session, indices: &[usize], art: bool) -> Result<Vec<(usize, Vec<NodeId>)>> {
pub(crate) fn artboards_art(s: &Session, indices: &[usize], art: bool, moving: bool) -> Result<Vec<(usize, Vec<NodeId>)>> {
let mut seen = std::collections::HashSet::new();
let mut arts = vec![];
for &i in indices {
let art = artboard_art(s, i, art)?.into_iter().filter(|id| seen.insert(*id)).collect();
let art = artboard_art(s, i, art, moving)?.into_iter().filter(|id| seen.insert(*id)).collect();
arts.push((i, art));
}
Ok(arts)
@@ -144,7 +160,7 @@ pub(crate) fn beside_artboards(d: &vectorcraft_doc::Document, rect: Rect) -> Vec
/// their places relative to each other, together right of the last artboard.
fn artboard_duplicate(s: &mut Session, p: &Value) -> Result<Value> {
let (indices, art) = boards_and_art(s, p, "artboard.duplicate")?;
let arts = artboards_art(s, &indices, art)?;
let arts = artboards_art(s, &indices, art, false)?;
let all: Vec<NodeId> = arts.iter().flat_map(|(_, a)| a.iter().copied()).collect();
let sc = Scaling { patterns: super::object::transform_patterns(s, p, &all)?, ..Scaling::default() };
let (index, ids) = s.edit("Duplicate Artboard", |d, _| {
@@ -170,11 +186,14 @@ fn artboard_duplicate(s: &mut Session, p: &Value) -> Result<Value> {
/// a cut then deletes them.
pub(crate) fn artboard_copy(s: &mut Session, p: &Value, cut: bool) -> Result<Value> {
let (indices, art) = boards_and_art(s, p, if cut { "artboard.cut" } else { "artboard.copy" })?;
let arts = artboards_art(s, &indices, art)?;
let arts = artboards_art(s, &indices, art, cut)?;
let st = s.doc()?;
if cut && st.doc.artboards.len() <= indices.len() {
return Err(EngineError::Other("a document needs at least one artboard".into()));
}
if cut {
refuse_locked(&st.doc, &indices)?;
}
let art = st.doc.paint_order(arts.into_iter().flat_map(|(_, a)| a));
let boards: Vec<_> = indices.iter().filter_map(|i| st.doc.artboards.get(*i).cloned()).collect();
let clip = Clipboard { source_artboard: boards.first().map(|a| a.rect), artboards: boards, ..Clipboard::copy(st, &art) };
@@ -215,11 +234,12 @@ pub(crate) fn copy_artboard(d: &mut vectorcraft_doc::Document, i: usize, dv: Vec
Ok((index, copies))
}
/// Add a copy of artboard `src` at `rect`, with an id of its own and its name, or `<name> copy`
/// (`<name> copy 2`…) when that is taken → its index.
/// Add a copy of artboard `src` at `rect`, unlocked, with an id of its own and its name, or
/// `<name> copy` (`<name> copy 2`…) when that is taken → its index.
pub(crate) fn push_artboard_copy(d: &mut vectorcraft_doc::Document, src: &vectorcraft_doc::Artboard, rect: Rect) -> usize {
let mut a = src.clone();
a.id = d.next_artboard_id();
a.locked = false;
let taken = |name: &str| d.artboards.iter().any(|a| a.name == name);
a.name = [src.name.clone(), format!("{} copy", src.name)]
.into_iter()
+5 -2
View File
@@ -946,7 +946,8 @@ fn clean_up(s: &mut Session, p: &Value) -> Result<Value> {
for l in &d.layers {
visit(l, false, false, false, &mut v, (stray, unpainted, empty_text));
}
v.retain(|id| d.is_editable(*id));
let editable = d.editable();
v.retain(|id| editable(*id));
v
};
if found.is_empty() {
@@ -991,13 +992,15 @@ fn divide_objects_below(s: &mut Session, _: &Value) -> Result<Value> {
}
// Compound children are handled via their compound.
targets.retain(|id| !matches!(d.parent_of(*id).and_then(|p| d.node(p)).map(|p| &p.kind), Some(NodeKind::Compound { .. })));
let editable = d.editable();
targets.retain(|id| {
*id != cutter_id
&& d.is_editable(*id)
&& editable(*id)
&& d.is_visible(*id)
&& d.index_path(*id).is_some_and(|ip| ip < cutter_pos)
&& d.node(*id).and_then(Node::geometric_bounds).is_some_and(|b| b.intersect(cb).area() > 0.0)
});
drop(editable);
let mut out = vec![];
let mut cut = Beside::replacing(d, &[targets.as_slice(), &[cutter_id]].concat());
for t in targets {
+4 -2
View File
@@ -511,10 +511,11 @@ fn candidates(s: &Session, p: &Value, points: &[Point], tolerance: f64) -> Resul
let supplied = p.get("ids").map(|_| checked_ids_param(s, p, "ids", "shaper.scribble")).transpose()?;
let expand = supplied.is_none() && st.selection.objects.len() < 2;
let ids = supplied.unwrap_or_else(|| if st.selection.objects.len() > 1 { st.selection.objects.clone() } else { st.doc.selectable_art() });
let editable = st.doc.editable();
let eligible: Vec<NodeId> = ids
.into_iter()
.filter(|id| {
st.doc.is_editable(*id)
editable(*id)
&& st.doc.node(*id).is_some_and(|n| {
!matches!(n.kind, NodeKind::Path { guide: true, .. } | NodeKind::Path { clipping: true, .. })
&& (n.extras().shaper.is_some() || b::node_outline(n).is_some())
@@ -691,10 +692,11 @@ fn scribble(s: &mut Session, p: &Value) -> Result<Value> {
/// When `within` is given, only that group is considered.
fn group_at(d: &Document, point: Point, construction: bool, within: Option<NodeId>) -> Option<NodeId> {
let mut groups = vec![];
let editable = d.editable();
d.walk(|n| {
if n.extras().shaper.is_some()
&& within.is_none_or(|g| g == n.id)
&& d.is_editable(n.id)
&& editable(n.id)
&& (if construction { source_group(n).and_then(Node::geometric_bounds) } else { n.geometric_bounds() })
.is_some_and(|b| b.inflate(6.0, 6.0).contains(point))
{
+2 -1
View File
@@ -646,9 +646,10 @@ pub(crate) fn find_all(hay: &str, needle: &str, match_case: bool, whole_word: bo
fn all_texts(d: &Document) -> Vec<NodeId> {
let mut v = vec![];
let locks = d.locked_artboards();
for l in d.layers.iter().filter(|l| l.visible && !l.locked) {
l.walk(&mut |n| {
if matches!(n.kind, NodeKind::Text(_)) && n.visible && !n.locked {
if matches!(n.kind, NodeKind::Text(_)) && n.visible && !n.locked && !locks.holds_id(n.id) {
v.push(n.id);
}
});
+2 -1
View File
@@ -141,9 +141,10 @@ fn paintable(doc: &Document) -> Vec<NodeId> {
out.push(n.id);
}
});
let editable = doc.editable();
out.retain(|id| {
!doc.parent_of(*id).is_some_and(|p| compounds.contains(&p))
&& doc.is_editable(*id)
&& editable(*id)
&& doc.is_visible(*id)
&& doc.node(*id).is_some_and(|n| n.visible)
});
+6 -3
View File
@@ -45,7 +45,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Move Artboard",
[],
None,
"{index | indices: [index…], dx, dy, moveArt?: bool, copy?: bool, patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles)} move artboards (and the art fully inside them; locked and hidden art only with prefs moveLockedWithArtboard), several in one step; `copy` (Alt-drag) leaves them and moves copies → {index (the first), indices: the artboards moved or the copies, moved: the art moved or the copies}",
"{index | indices: [index…], dx, dy, moveArt?: bool, copy?: bool, patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles)} move artboards (and the art fully inside them; locked and hidden art, and art also on another locked artboard, only with prefs moveLockedWithArtboard), several in one step (an error when one is locked); `copy` (Alt-drag) leaves them and moves copies, which start unlocked → {index (the first), indices: the artboards moved or the copies, moved: the art moved or the copies}",
has_doc,
artboard_move
),
@@ -370,16 +370,19 @@ fn artboard_move(s: &mut Session, p: &Value) -> Result<Value> {
let indices = super::panelcmds::artboard_indices(&s.doc()?.doc, p, "artboard.move")?;
let dv = Vec2::new(f64_or(p, "dx", 0.0), f64_or(p, "dy", 0.0));
let move_art = bool_or(p, "moveArt", false);
let copy = bool_or(p, "copy", false);
if !copy {
super::panelcmds::refuse_locked(&s.doc()?.doc, &indices)?;
}
// Each artboard's art, an object counted once (artboards can overlap).
let mut arts: Vec<(usize, Vec<NodeId>)> = vec![];
for &i in &indices {
let art = super::panelcmds::artboard_art(s, i, move_art)?;
let art = super::panelcmds::artboard_art(s, i, move_art, !copy)?;
let art = art.into_iter().filter(|id| !arts.iter().any(|(_, a)| a.contains(id))).collect();
arts.push((i, art));
}
let ids: Vec<NodeId> = arts.iter().flat_map(|(_, a)| a.iter().copied()).collect();
let sc = Scaling { patterns: super::object::transform_patterns(s, p, &ids)?, ..Scaling::default() };
let copy = bool_or(p, "copy", false);
let (index, moved) = s.edit(if copy { "Duplicate Artboard" } else { "Move Artboard" }, |d, _| {
let (mut index, mut moved) = (vec![], vec![]);
for (i, art) in &arts {
+5 -1
View File
@@ -128,12 +128,16 @@ pub fn document(s: &Session) -> Value {
document_opts(s, SummaryOpts::default())
}
/// An artboard's name and rectangle, and its background color (`#rrggbb`) when it has one.
/// An artboard's name and rectangle, its background color (`#rrggbb`) when it has one, and
/// `locked: true` when it is locked.
fn artboard_summary(a: &vectorcraft_doc::Artboard) -> Value {
let mut v = json!({"name": a.name, "x": a.rect.x0, "y": a.rect.y0, "width": a.rect.width(), "height": a.rect.height()});
if let Some(c) = a.color {
v["color"] = json!(c.to_hex());
}
if a.locked {
v["locked"] = json!(true);
}
v
}
+70 -1
View File
@@ -641,6 +641,7 @@ impl DocState {
if let Some(it) = self.interaction.take() {
self.doc = it.doc;
self.selection = Arc::unwrap_or_clone(it.selection);
self.sync_new_art();
self.active_layer = it.active_layer;
self.layer_rows = it.layer_rows;
self.variables_highlight = it.variables_highlight;
@@ -652,6 +653,60 @@ impl DocState {
pub fn mark_unsaved(&mut self) {
self.saved_doc = Arc::new(Document::new(1.0, 1.0));
}
/// Deselect the art on a locked artboard, except new art ([`Document::new_art`]). New art
/// becomes the objects of layers on a locked artboard that hold selected objects (a group for
/// its selected members) and that an edit made (those not in `before`, the document before
/// it), or that held objects selected in `was` that were new art (`old`, the new art of that
/// selection), or were inside new art in `before` (the members of an ungrouped group). New art
/// that a selection leaves out, or that leaves the locked artboards, is protected from then
/// on. The selection and the document get the new art; the document's changes in place, so a
/// selection change doesn't replace the document.
fn protect_locked_artboards(&mut self, was: &[NodeId], old: &std::collections::HashSet<NodeId>, before: Option<&Document>) {
let new_art: std::collections::HashSet<NodeId> = {
let (doc, locks) = (&self.doc, self.doc.locked_artboards());
if locks.is_empty() {
Default::default()
} else {
// `was` and `old` name the objects of the document they were selected in.
let then = before.unwrap_or(doc);
let was: std::collections::HashSet<NodeId> =
was.iter().filter_map(|o| then.layer_object(*o)).map(|n| n.id).filter(|o| old.contains(o)).collect();
let selected: Vec<(NodeId, &vectorcraft_doc::Node)> =
self.selection.objects.iter().filter_map(|o| doc.layer_object(*o).map(|n| (*o, n))).collect();
// One walk of the document before the edit finds where the selected objects were;
// the objects the edit made aren't in it.
let paths = before.filter(|_| !selected.is_empty()).map(|b| b.index_paths(selected.iter().flat_map(|(o, n)| [*o, n.id])));
let new = |o: NodeId, object: NodeId| {
was.contains(&object)
|| before.zip(paths.as_ref()).is_some_and(|(b, p)| {
!p.contains_key(&object) || p.get(&o).and_then(|p| b.layer_object_at(p)).is_some_and(|n| was.contains(&n.id))
})
};
selected.into_iter().filter(|(o, n)| new(*o, n.id) && locks.overlaps(n)).map(|(_, n)| n.id).collect()
}
};
if new_art != *self.selection.new_art {
self.selection.new_art = Arc::new(new_art);
}
self.sync_new_art();
self.selection.deselect_on_locked_artboards(&self.doc);
}
/// Give the document the selection's new art ([`Selection::new_art`]), less the objects the
/// selection no longer holds: after Undo, Redo or a failed batch brings back a document and
/// a selection, and after a command that changed the selection directly.
pub(crate) fn sync_new_art(&mut self) {
if !self.selection.new_art.is_empty() {
let held: std::collections::HashSet<NodeId> =
self.selection.objects.iter().filter_map(|o| self.doc.layer_object(*o)).map(|n| n.id).collect();
if !self.selection.new_art.is_subset(&held) {
self.selection.new_art = Arc::new(self.selection.new_art.intersection(&held).copied().collect());
}
}
let doc = self.doc.new_art.get();
if !Arc::ptr_eq(&doc, &self.selection.new_art) && *doc != *self.selection.new_art {
self.doc.new_art.set(self.selection.new_art.clone());
}
}
pub fn title(&self) -> String {
let name = self
.path
@@ -1699,7 +1754,7 @@ impl Session {
fn run_guarded(&mut self, id: &str, f: impl FnOnce(&mut Self) -> Result<Value>) -> Result<Value> {
let snapshot = self.active().map(|d| (d.uid, d.doc.clone(), d.selection.clone(), d.interaction.clone()));
let active = self.active;
match guard::catch_panic(|| self.run_nested(f)) {
let r = match guard::catch_panic(|| self.run_nested(f)) {
Ok(r) => r,
Err(msg) => {
self.depth = 0;
@@ -1717,7 +1772,13 @@ impl Session {
}
Err(EngineError::Internal { cmd: id.to_string(), msg })
}
};
// Undo, Redo and a failed batch bring back documents with their selections: each document
// gets its selection's new art on a locked artboard.
for st in &mut self.docs {
st.sync_new_art();
}
r
}
/// Run `f` on a mutable copy of the active document. Outside an interaction this records one
@@ -1783,6 +1844,9 @@ impl Session {
if guides_locked {
deselect_guides(&st.doc, &mut st.selection);
}
// The art on a locked artboard can't stay selected, however it got there, except
// the new art this edit made and new art still selected.
st.protect_locked_artboards(&before_sel.objects, &before_sel.new_art, Some(&*before));
st.revision += 1;
if st.interaction.is_none() {
let selection = match st.history.undo.last() {
@@ -1834,12 +1898,16 @@ impl Session {
let guides_locked = self.menu.guides_locked;
let st = self.doc_mut()?;
let before = st.selection.objects.clone();
let new_art = st.selection.new_art.clone();
f(&st.doc, &mut st.selection);
st.selection.prune(&st.doc);
// Lock Guides locks the paths made into guides too (#1534), whatever selects them.
if guides_locked {
deselect_guides(&st.doc, &mut st.selection);
}
// The art on a locked artboard can't be selected, whatever selects it, except new art
// that was selected and still is.
st.protect_locked_artboards(&before, &new_art, None);
// Selecting art makes its layer (or sublayer) the current one, as in the Layers panel of
// the reference app: new art then goes beside it.
if st.selection.objects != before
@@ -2052,6 +2120,7 @@ impl Session {
if self.menu.guides_locked {
deselect_guides(&st.doc, &mut st.selection);
}
st.sync_new_art();
st.revision += 1;
// The journal drops only the canceled groups' own entries and keeps the ones made in other documents meanwhile.
journal.extend(g.journal);
+16
View File
@@ -217,6 +217,22 @@ fn liquify_without_selection_hits_paths_under_the_brush() {
assert!(path(&s, b).bounds().unwrap().width() < 100.0);
}
/// With nothing selected, a stroke leaves the art on a locked artboard as it is.
#[test]
fn liquify_without_selection_leaves_the_art_on_a_locked_artboard() {
let mut s = session();
let b = rect(&mut s, 500.0, 400.0, 100.0, 100.0);
s.execute("artboard.new", &json!({"x": 450, "y": 350, "width": 300, "height": 300})).unwrap();
s.execute("artboard.setProps", &json!({"index": 1, "locked": true})).unwrap();
s.execute("select.set", &json!({"ids": []})).unwrap();
let pucker = json!({"tool": "pucker", "points": [[550, 450]], "diameter": 150, "intensity": 1});
let r = s.execute("object.liquify", &pucker);
assert_eq!(path(&s, b).bounds().unwrap(), Rect::new(500.0, 400.0, 600.0, 500.0), "{r:?}");
s.execute("artboard.setProps", &json!({"index": 1, "locked": false})).unwrap();
s.execute("object.liquify", &pucker).unwrap();
assert!(path(&s, b).bounds().unwrap().width() < 100.0, "unlocked");
}
#[test]
fn liquify_tool_replays_from_the_journal() {
let mut s = session();
+12
View File
@@ -1044,6 +1044,18 @@ fn find_and_replace_options() {
assert!(s.execute("edit.findReplace", &json!({"find": ""})).is_err());
}
/// Find and Replace with no ids leaves the type on a locked artboard as it is.
#[test]
fn find_and_replace_leaves_type_on_a_locked_artboard() {
let mut s = session();
let a = text(&mut s, "cat");
let b = id_of(&s.execute("text.create", &json!({"x": 2050, "y": 50, "text": "cat"})).unwrap());
s.execute("artboard.new", &json!({"x": 2000, "y": 0, "width": 400, "height": 400})).unwrap();
s.execute("artboard.setProps", &json!({"index": 1, "locked": true})).unwrap();
let r = s.execute("edit.findReplace", &json!({"find": "cat", "replace": "dog"})).unwrap();
assert_eq!((r["count"].as_u64(), plain(&s, a), plain(&s, b)), (Some(1), "dog".to_string(), "cat".to_string()));
}
/// Find and Replace that replaces each match with the text it is (`CAT` found in `cat`, ignoring
/// case, and replaced by `cat`) records no undo step: the step to redo stays and the document stays
/// unmodified. It still replies with the count. A replacement that changes the text is one undo step.
+300
View File
@@ -424,6 +424,306 @@ fn copied_artboards_take_a_free_id_and_keep_their_own_guides() {
);
}
/// `artboard.setProps {locked}` locks an artboard in one undo step and deselects the art on it.
/// Select All, `select.set` and Select › Inverse then pass over that art, a command that takes the
/// selection leaves it where it is, and the document summary reports `locked: true`. Unlocking
/// gives the art back. The art of pattern editing mode is on no artboard.
#[test]
fn a_locked_artboard_protects_the_art_on_it() {
let mut s = session();
let a = rect(&mut s);
s.execute("artboard.new", &json!({"x": 1000, "y": 0, "width": 200, "height": 200})).unwrap();
let b = NodeId(s.execute("shape.rectangle", &json!({"x": 1010, "y": 10, "width": 20, "height": 20})).unwrap()["id"].as_u64().unwrap());
let selected = |s: &Session| s.doc().unwrap().selection.objects.clone();
let locked = |s: &Session| s.doc().unwrap().doc.artboards[1].locked;
s.execute("select.all", &json!({})).unwrap();
s.execute("artboard.setProps", &json!({"index": 1, "locked": true})).unwrap();
assert_eq!(selected(&s), [a], "locking deselects b");
let doc = crate::inspect::document(&s);
assert_eq!(doc["artboards"][1]["locked"], true);
assert!(doc["artboards"][0].get("locked").is_none(), "{doc}");
for (cmd, p) in [("select.all", json!({})), ("select.set", json!({"ids": [a.0, b.0]}))] {
s.execute(cmd, &p).unwrap();
assert_eq!(selected(&s), [a], "{cmd}");
}
s.execute("select.none", &json!({})).unwrap();
s.execute("select.inverse", &json!({})).unwrap();
assert_eq!(selected(&s), [a]);
s.execute("select.all", &json!({})).unwrap();
s.execute("object.move", &json!({"dx": 5, "dy": 0})).unwrap();
assert_eq!((left(&s, a.0), left(&s, b.0)), (15.0, 1010.0), "b stays");
// Art drawn on it stays selected until the selection leaves it; art moved onto it is
// deselected by the edit.
let drawn = NodeId(s.execute("shape.rectangle", &json!({"x": 1100, "y": 100, "width": 20, "height": 20})).unwrap()["id"].as_u64().unwrap());
assert_eq!(selected(&s), [drawn], "drawn on it");
s.execute("select.set", &json!({"ids": [a.0]})).unwrap();
s.execute("object.move", &json!({"dx": 1000, "dy": 0})).unwrap();
assert!(selected(&s).is_empty(), "moved onto it");
s.execute("select.set", &json!({"ids": [drawn.0]})).unwrap();
assert!(selected(&s).is_empty(), "drawn on it, then left out of the selection");
s.execute("artboard.setProps", &json!({"index": 1, "locked": false})).unwrap();
s.execute("select.all", &json!({})).unwrap();
assert_eq!(selected(&s), [a, b, drawn]);
for was in [true, true, true, true, false] {
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(locked(&s), was);
}
// The art of pattern editing mode is on no artboard: Select All selects it on a locked one.
s.execute("select.set", &json!({"ids": [a.0]})).unwrap();
s.execute("object.pattern.make", &json!({"name": "P", "edit": false})).unwrap();
s.execute("artboard.setProps", &json!({"index": 0, "locked": true})).unwrap();
s.execute("object.pattern.edit", &json!({"name": "P"})).unwrap();
s.execute("select.none", &json!({})).unwrap();
s.execute("select.all", &json!({})).unwrap();
let tile = selected(&s);
assert!(!tile.is_empty() && !tile.contains(&a), "{tile:?}");
}
/// New art on a locked artboard stays selected and editable until the selection leaves it: a
/// second Pen click goes on the path the first one started, and pasted art moves. Once another
/// object is clicked, a click, a marquee, `select.set` and Select All pass over that art. Undo, in
/// a batch too, and a failed batch bring back the selection the step had, and the new art with
/// it. Isolation mode starts with nothing selected, so the art is protected in it, and leaving it
/// doesn't select that art. Art drawn on an unlocked artboard, or
/// new art that leaves the locked artboard, is deselected when it is moved onto one. Selection
/// changes leave the document as it is, and once one has left the new art out, an unlocked
/// artboard over it moves without it. A member of a new group can be selected, and ungrouping it
/// keeps the members selected.
#[test]
fn new_art_on_a_locked_artboard_stays_editable_until_the_selection_leaves_it() {
use vectorcraft_tools::{PointerEvent, PointerKind};
let mut s = session();
let a = rect(&mut s);
s.execute("artboard.new", &json!({"x": 1000, "y": 0, "width": 400, "height": 400})).unwrap();
s.execute("artboard.setProps", &json!({"index": 1, "locked": true})).unwrap();
// An unlocked artboard over part of the locked one.
s.execute("artboard.new", &json!({"x": 1100, "y": 200, "width": 200, "height": 200})).unwrap();
let selected = |s: &Session| s.doc().unwrap().selection.objects.iter().map(|id| id.0).collect::<Vec<_>>();
let v = ViewInfo { zoom: 1.0, smart_guides: false, snap_to_grid: false, snap_to_pixel: false, snap_to_point: false, ..Default::default() };
let click = |s: &mut Session, x, y| {
for kind in [PointerKind::Down, PointerKind::Up] {
s.pointer(&PointerEvent::new(kind, x, y), v).unwrap();
}
};
// Art drawn on the unlocked artboard and moved onto the locked one isn't new art there.
let off = s.execute("shape.rectangle", &json!({"x": 300, "y": 300, "width": 20, "height": 20})).unwrap()["id"].as_u64().unwrap();
assert_eq!(selected(&s), [off]);
s.execute("object.move", &json!({"dx": 1000, "dy": 0})).unwrap();
assert!(selected(&s).is_empty(), "moved onto it");
s.select_tool("pen", v).unwrap();
click(&mut s, 1050.0, 50.0);
click(&mut s, 1150.0, 50.0);
let [path] = selected(&s)[..] else { panic!("one path selected: {:?}", selected(&s)) };
assert_eq!(node(&s, NodeId(path)).path_data().unwrap().anchor_count(), 2, "the second click goes on the same path");
s.execute("select.set", &json!({"ids": [a.0]})).unwrap();
s.execute("edit.copy", &json!({})).unwrap();
let pasted = ids(&s.execute("edit.paste", &json!({"center": [1200, 300]})).unwrap()["ids"]);
assert_eq!(selected(&s), pasted);
s.execute("object.move", &json!({"dx": 10, "dy": 0})).unwrap();
assert_eq!((selected(&s), left(&s, pasted[0])), (pasted.clone(), 1160.0), "pasted art moves");
// Moved off the locked artboard and back, it is no longer new art.
s.execute("object.move", &json!({"dx": -1000, "dy": 0})).unwrap();
assert_eq!(selected(&s), pasted);
s.execute("object.move", &json!({"dx": 1000, "dy": 0})).unwrap();
assert!(selected(&s).is_empty(), "moved off and back");
s.execute("edit.undo", &json!({})).unwrap();
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(selected(&s), pasted, "undone twice");
s.select_tool("selection", v).unwrap();
let doc = s.doc().unwrap().doc.clone();
click(&mut s, 10.0, 30.0);
assert_eq!(selected(&s), [a.0], "a click on other art");
assert!(Arc::ptr_eq(&doc, &s.doc().unwrap().doc), "a selection change leaves the document as it is");
let moved = s.execute("artboard.move", &json!({"index": 2, "dx": 0, "dy": 1000, "moveArt": true})).unwrap();
assert!(!ids(&moved["moved"]).contains(&pasted[0]), "the artboard over it moves without it");
s.execute("edit.undo", &json!({})).unwrap();
click(&mut s, 1160.0, 300.0);
assert!(selected(&s).is_empty(), "a click on the pasted art's edge");
s.pointer(&PointerEvent::new(PointerKind::Down, 1010.0, 10.0), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Drag, 1390.0, 390.0), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Up, 1390.0, 390.0), v).unwrap();
assert!(selected(&s).is_empty(), "a marquee");
s.execute("select.set", &json!({"ids": [path, pasted[0]]})).unwrap();
assert!(selected(&s).is_empty(), "select.set");
s.execute("select.all", &json!({})).unwrap();
assert_eq!(selected(&s), [a.0], "Select All");
// Undoing the move brings back the pasted art selected, as it was before the move.
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(selected(&s), pasted);
s.execute("object.move", &json!({"dx": 10, "dy": 0})).unwrap();
assert_eq!(left(&s, pasted[0]), 1160.0);
let editable = |s: &Session| s.doc().unwrap().doc.is_editable(NodeId(pasted[0]));
// A batch that deselects it and fails brings it back selected and editable, and so does
// undoing a batch that deselects it and draws.
let failed = json!({"commands": [{"command": "select.none"}, {"command": "no.such.command"}]});
assert!(s.execute("command.batch", &failed).is_err());
assert_eq!((selected(&s), editable(&s)), (pasted.clone(), true), "after a failed batch");
let drawn =
json!({"commands": [{"command": "select.none"}, {"command": "shape.rectangle", "params": {"x": 300, "y": 300, "width": 20, "height": 20}}]});
s.execute("command.batch", &drawn).unwrap();
assert!(!editable(&s), "deselected by the batch");
// Undo inside a later batch gives the document the new art of the selection it brings back:
// the unlocked artboard over that art then moves it along.
let up = json!({"command": "artboard.move", "params": {"index": 2, "dx": 0, "dy": 1000, "moveArt": true}});
let r = s.execute("command.batch", &json!({"commands": [{"command": "edit.undo"}, up.clone()]})).unwrap();
assert!(ids(&r["results"][1]["moved"]).contains(&pasted[0]), "undone inside a batch: {r}");
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!((selected(&s), editable(&s)), (pasted.clone(), true), "the batch undone");
// Isolation mode inside a batch protects it before the next step.
let isolated = json!({"commands": [{"command": "object.isolate", "params": {"id": pasted[0]}}, up]});
let r = s.execute("command.batch", &isolated).unwrap();
assert!(!ids(&r["results"][1]["moved"]).contains(&pasted[0]), "isolated inside a batch: {r}");
s.execute("edit.undo", &json!({})).unwrap();
s.execute("object.exitIsolation", &json!({})).unwrap();
// Isolation mode starts with nothing selected, so leaving it doesn't select the art again,
// and the art, protected again, isn't isolated, as locked art isn't.
assert!(selected(&s).is_empty(), "after isolation mode");
assert!(!editable(&s), "after isolation mode");
assert!(s.execute("object.isolate", &json!({"id": pasted[0]})).is_err(), "isolating it again");
// A member of a new group can be selected while the group is new art.
let copy = ids(&s.execute("edit.paste", &json!({"center": [1200, 150]})).unwrap()["ids"]);
s.execute("object.group", &json!({})).unwrap();
let group = selected(&s);
s.execute("select.set", &json!({"ids": copy})).unwrap();
assert_eq!(selected(&s), copy, "a member of a new group");
s.execute("select.set", &json!({"ids": group})).unwrap();
assert_eq!(selected(&s), group, "from the member back to the group");
s.execute("object.ungroup", &json!({})).unwrap();
assert_eq!(selected(&s), copy, "ungrouped");
s.execute("object.move", &json!({"dx": 10, "dy": 0})).unwrap();
assert_eq!(selected(&s), copy, "the ungrouped art moves");
}
/// When an artboard moves, scales or is cut with its art, the art inside it that is also on a
/// locked artboard stays where it is, unless prefs moveLockedWithArtboard is on, as locked art
/// does; a copy of the artboard copies that art.
#[test]
fn art_also_on_a_locked_artboard_stays_when_another_artboard_moves() {
let mut s = session();
// A locked artboard over the right edge of artboard 1, and `held` inside artboard 1 and on it.
s.execute("artboard.new", &json!({"x": 700, "y": 0, "width": 300, "height": 300})).unwrap();
let free = rect(&mut s);
let held = NodeId(s.execute("shape.rectangle", &json!({"x": 710, "y": 10, "width": 50, "height": 20})).unwrap()["id"].as_u64().unwrap());
s.execute("artboard.setProps", &json!({"index": 1, "locked": true})).unwrap();
let undo = |s: &mut Session| s.execute("edit.undo", &json!({})).unwrap();
let moved = s.execute("artboard.move", &json!({"index": 0, "dx": 0, "dy": 1000, "moveArt": true})).unwrap();
assert_eq!(ids(&moved["moved"]), [free.0]);
undo(&mut s);
let scaled = s.execute("artboard.setProps", &json!({"index": 0, "width": 1600, "scaleArt": true})).unwrap();
assert_eq!(ids(&scaled["scaled"]), [free.0]);
undo(&mut s);
s.execute("artboard.cut", &json!({"index": 0, "art": true})).unwrap();
let d = &s.doc().unwrap().doc;
assert!(d.node(held).is_some() && d.node(free).is_none(), "the cut leaves it");
undo(&mut s);
assert_eq!(ids(&s.execute("artboard.duplicate", &json!({"index": 0, "art": true})).unwrap()["ids"]).len(), 2, "a copy takes it");
undo(&mut s);
s.prefs.move_locked_with_artboard = true;
let moved = s.execute("artboard.move", &json!({"index": 0, "dx": 0, "dy": 1000, "moveArt": true})).unwrap();
assert_eq!(ids(&moved["moved"]), [free.0, held.0], "with the preference on, it moves");
}
/// A locked artboard keeps its place and size: `artboard.setProps` refuses a new rectangle but
/// still renames it, `artboard.move`, `artboard.delete`, `artboard.cut` and `artboard.fitToArt`
/// refuse it, and Rearrange leaves it and the art on it where they are. A copy of it starts
/// unlocked, and unlocked it moves again.
#[test]
fn a_locked_artboard_keeps_its_place_and_size() {
let mut s = session();
// Three artboards in a row, 800 pt wide: at 0, 820 and 1640.
for _ in 0..2 {
s.execute("artboard.new", &json!({})).unwrap();
}
// On the second and third artboards, its center on the third.
let shape = |s: &mut Session, x: u32, y: u32| {
s.execute("shape.rectangle", &json!({"x": x, "y": y, "width": 80, "height": 20})).unwrap()["id"].as_u64().unwrap()
};
let c = shape(&mut s, 1610, 10);
// In a sublayer, one like it and one on the third artboard alone.
s.execute("layer.newSublayer", &json!({"name": "Sub"})).unwrap();
let (c2, d2) = (shape(&mut s, 1610, 100), shape(&mut s, 2000, 100));
s.execute("artboard.setProps", &json!({"index": 1, "locked": true})).unwrap();
let rects = |s: &Session| s.doc().unwrap().doc.artboards.iter().map(|a| a.rect).collect::<Vec<_>>();
let was = rects(&s);
let refused = |r: Result<serde_json::Value>| matches!(r, Err(EngineError::Other(m)) if m == "the artboard is locked");
assert!(refused(s.execute("artboard.setProps", &json!({"index": 1, "width": 300}))));
assert!(refused(s.execute("artboard.move", &json!({"index": 1, "dx": 5, "dy": 0}))));
assert!(refused(s.execute("artboard.delete", &json!({"index": 1}))));
assert!(refused(s.execute("artboard.cut", &json!({"index": 1}))));
assert!(refused(s.execute("artboard.fitToArt", &json!({"index": 1}))));
assert_eq!(rects(&s), was);
s.execute("artboard.setProps", &json!({"index": 1, "name": "Cover", "x": 820, "y": 0, "width": 800, "height": 600})).unwrap();
assert_eq!(boards(&s)[1], (820.0, "Cover".into()), "Artboard Options renames it");
// Its slot in the row is at 850; the third artboard's is at 1700.
s.execute("artboard.rearrange", &json!({"layout": "row", "spacing": 50})).unwrap();
assert_eq!(rects(&s)[1], was[1]);
assert_eq!(rects(&s)[2].x0, 1700.0, "the third artboard moves");
assert_eq!((left(&s, c), left(&s, c2)), (1610.0, 1610.0), "the art on the locked artboard stays, in a sublayer too");
assert_eq!(left(&s, d2), 2060.0, "the sublayer's other art moves with the third artboard");
let copy = s.execute("artboard.duplicate", &json!({"index": 1, "art": false})).unwrap()["index"].as_u64().unwrap() as usize;
assert!(!s.doc().unwrap().doc.artboards[copy].locked, "a copy starts unlocked");
s.execute("artboard.setProps", &json!({"index": 1, "locked": false, "x": 5})).unwrap();
assert_eq!(rects(&s)[1].x0, 5.0, "unlocking it lets the same change move it");
}
/// A locked artboard keeps its exact rectangle when `artboard.setProps` passes values within
/// 1e-6 pt of it, as Artboard Options does when it renames the artboard: repeated calls don't
/// move or resize it by small steps.
#[test]
fn a_locked_artboard_keeps_its_exact_rectangle() {
let mut s = session();
s.execute("artboard.setProps", &json!({"index": 0, "locked": true})).unwrap();
let rect = |s: &Session| s.doc().unwrap().doc.artboards[0].rect;
let was = rect(&s);
for i in 0..10 {
let r = rect(&s);
let p =
json!({"index": 0, "name": format!("Cover {i}"), "x": r.x0 + 5e-7, "y": r.y0 - 9e-7, "width": r.width() + 4e-7, "height": r.height()});
s.execute("artboard.setProps", &p).unwrap();
}
assert_eq!(rect(&s), was);
assert_eq!(boards(&s)[0].1, "Cover 9", "the name still changes");
}
/// `artboard.setProps {artboards}` takes `locked` from each entry, and a list that gives a locked
/// artboard a new rectangle is refused and changes no artboard.
#[test]
fn a_list_of_artboards_takes_each_entrys_lock() {
let mut s = session();
s.execute("artboard.new", &json!({})).unwrap();
s.execute("artboard.setProps", &json!({"artboards": [{"index": 0, "locked": true}, {"index": 1, "name": "Back"}]})).unwrap();
let locks = |s: &Session| s.doc().unwrap().doc.artboards.iter().map(|a| a.locked).collect::<Vec<_>>();
let widths = |s: &Session| s.doc().unwrap().doc.artboards.iter().map(|a| a.rect.width()).collect::<Vec<_>>();
assert_eq!(locks(&s), [true, false]);
let was = widths(&s);
let r = s.execute("artboard.setProps", &json!({"artboards": [{"index": 1, "width": 300}, {"index": 0, "width": 300}]}));
assert!(matches!(r, Err(EngineError::Other(m)) if m == "the artboard is locked"));
assert_eq!(widths(&s), was, "neither artboard changes");
s.execute("artboard.setProps", &json!({"artboards": [{"index": 1, "width": 300}, {"index": 0, "width": 300, "locked": false}]})).unwrap();
assert_eq!((locks(&s), widths(&s)), (vec![false, false], vec![300.0, 300.0]));
}
/// The native format writes an artboard's lock and reads it back. An unlocked artboard is written
/// as before, with no `locked` key: unlocked again, the document saves to the bytes it had before.
#[test]
fn artboard_lock_round_trips_through_the_native_format() {
let mut s = session();
s.execute("artboard.new", &json!({})).unwrap();
let save = |s: &Session| vectorcraft_format::save(&s.doc().unwrap().doc, false);
let before = save(&s);
s.execute("artboard.setProps", &json!({"index": 1, "locked": true})).unwrap();
let bytes = save(&s);
let saved: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
let keys = saved["document"]["artboards"][0].as_object().unwrap().keys().cloned().collect::<Vec<_>>();
assert_eq!(keys, ["id", "name", "rect", "show_center_mark", "show_cross_hairs"]);
assert_eq!(saved["document"]["artboards"][1]["locked"], true);
let back = vectorcraft_format::load(&bytes).unwrap();
assert!(back.artboards[1].locked && !back.artboards[0].locked);
assert_eq!(back.artboards, s.doc().unwrap().doc.artboards);
s.execute("artboard.setProps", &json!({"index": 1, "locked": false})).unwrap();
assert_eq!(save(&s), before);
}
#[test]
fn swatch_groups_duplicate_sort() {
let mut s = session();
+10 -1
View File
@@ -2326,6 +2326,7 @@ impl<'a> Reader<'a> {
id: i as u32 + 1,
name,
rect: to_doc.transform_rect_bbox(r),
locked: false,
show_center_mark: false,
show_cross_hairs: false,
color: None,
@@ -2333,7 +2334,15 @@ impl<'a> Reader<'a> {
.collect();
if doc.artboards.is_empty() {
let rect = doc.art_bounds().filter(|r| sane(*r) && r.area() > 0.0).unwrap_or(Rect::new(0.0, 0.0, 612.0, 792.0));
doc.artboards = vec![Artboard { id: 1, name: "Artboard 1".into(), rect, show_center_mark: false, show_cross_hairs: false, color: None }];
doc.artboards = vec![Artboard {
id: 1,
name: "Artboard 1".into(),
rect,
locked: false,
show_center_mark: false,
show_cross_hairs: false,
color: None,
}];
}
if self.color_model.unwrap_or(self.cmyk_colors > self.rgb_colors) {
doc.color_mode = ColorMode::Cmyk;
+1
View File
@@ -389,6 +389,7 @@ fn read_with_reader_options(bytes: &[u8], opts: &ImportOptions, reader: &craft_p
id: i as u32 + 1,
name: format!("Artboard {}", i + 1),
rect: ab,
locked: false,
show_center_mark: false,
show_cross_hairs: false,
color: None,
+4
View File
@@ -79,6 +79,7 @@ fn one_page_per_artboard_with_sizes() {
id: 2,
name: "B".into(),
rect: Rect::new(300.0, 0.0, 400.0, 400.0),
locked: false,
show_center_mark: false,
show_cross_hairs: false,
color: None,
@@ -87,6 +88,7 @@ fn one_page_per_artboard_with_sizes() {
id: 3,
name: "C".into(),
rect: Rect::new(0.0, 500.0, 612.0, 1292.0),
locked: false,
show_center_mark: false,
show_cross_hairs: false,
color: None,
@@ -104,6 +106,7 @@ fn artboard_range_and_bad_index() {
id: 2,
name: "B".into(),
rect: Rect::new(300.0, 0.0, 400.0, 400.0),
locked: false,
show_center_mark: false,
show_cross_hairs: false,
color: None,
@@ -726,6 +729,7 @@ fn import_max_pages_and_multi_page() {
id: 2,
name: "B".into(),
rect: Rect::new(300.0, 0.0, 400.0, 400.0),
locked: false,
show_center_mark: false,
show_cross_hairs: false,
color: None,
+1
View File
@@ -27,6 +27,7 @@ fn artboard(id: u32, x: f64) -> Artboard {
id,
name: format!("Artboard {id}"),
rect: Rect::new(x, 0.0, x + 100.0, 100.0),
locked: false,
show_center_mark: false,
show_cross_hairs: false,
color: None,
+1
View File
@@ -25,6 +25,7 @@ fn doc(n: usize, image: bool, on_first: bool) -> Document {
id: i as u32 + 1,
name: format!("A{i}"),
rect: Rect::new(x, 0.0, x + 100.0, 100.0),
locked: false,
show_center_mark: false,
show_cross_hairs: false,
color: None,
+1
View File
@@ -332,6 +332,7 @@ fn inkscape_pages(xml: &roxmltree::Document, user: Affine) -> Option<Vec<vectorc
id: i as u32 + 1,
name: p.attribute((INKSCAPE, "label")).filter(|l| !l.trim().is_empty()).map_or_else(|| format!("Artboard {}", i + 1), str::to_string),
rect: r,
locked: false,
show_center_mark: false,
show_cross_hairs: false,
color: None,
+6 -3
View File
@@ -54,9 +54,10 @@ struct PathWidgets {
impl PathWidgets {
/// The widgets of path `id` at `zoom` (screen px per document point), if it has any: any path
/// with Direct Selection (`any_path`), live rectangles and polygons with Selection.
/// with Direct Selection (`any_path`), live rectangles and polygons with Selection. `id` is
/// editable ([`Document::is_editable`]).
fn of(doc: &Document, selection: &Selection, id: NodeId, zoom: f64, any_path: bool) -> Option<Self> {
if !doc.is_editable(id) || selection.segments.contains_key(&id) {
if selection.segments.contains_key(&id) {
return None;
}
let NodeKind::Path { path, live, .. } = &doc.node(id)?.kind else { return None };
@@ -103,7 +104,9 @@ impl CornerWidgets {
/// The widgets at `zoom` (screen px per document point), if the selection has them: on any
/// path with Direct Selection (`any_path`), on live rectangles and polygons with Selection.
pub fn of(doc: &Document, selection: &Selection, zoom: f64, any_path: bool) -> Option<Self> {
let paths: Vec<_> = selection.objects.iter().filter_map(|id| PathWidgets::of(doc, selection, *id, zoom, any_path)).collect();
let editable = doc.editable();
let paths: Vec<_> =
selection.objects.iter().filter(|id| editable(**id)).filter_map(|id| PathWidgets::of(doc, selection, *id, zoom, any_path)).collect();
(!paths.is_empty()).then_some(Self { paths })
}
+8 -4
View File
@@ -231,7 +231,8 @@ pub(crate) fn spines(cx: &ToolContext) -> Vec<(NodeId, PathData)> {
}
});
v.reverse();
v.retain(|(id, _)| cx.doc.is_editable(*id));
let editable = cx.doc.editable();
v.retain(|(id, _)| editable(*id));
v
}
@@ -288,7 +289,8 @@ fn anchor_owners(cx: &ToolContext, f: fn(&Node) -> bool) -> Vec<NodeId> {
}
});
v.reverse();
v.retain(|id| cx.doc.is_editable(*id));
let editable = cx.doc.editable();
v.retain(|id| editable(*id));
v
}
@@ -811,7 +813,8 @@ impl Tool for DirectSelectionTool {
// (members of groups too), each whole, as a click selects one.
if enclosed {
let mut touched = vectorcraft_doc::hit::marquee(cx.doc, r, cx.isolation, true);
touched.retain(|id| cx.doc.is_editable(*id));
let editable = cx.doc.editable();
touched.retain(|id| editable(*id));
let ids: Vec<Value> = crate::select::inside(cx, r, touched).into_iter().map(|id| json!(id.0)).collect();
let art = Action::Exec(if toggle { "select.toggle" } else { "select.set" }.into(), json!({ "ids": ids }));
return std::iter::once(art).chain(crate::rulerguide::marquee_select(cx, r, true)).collect();
@@ -834,7 +837,8 @@ impl Tool for DirectSelectionTool {
sel.push((id, vec![]));
}
}
sel.retain(|(id, _)| cx.doc.is_editable(*id));
let editable = cx.doc.editable();
sel.retain(|(id, _)| editable(*id));
let items: Vec<Value> = sel.iter().map(|(id, v)| json!({"id": id.0, "anchors": anchors_json(v)})).collect();
let art = Action::Exec("select.anchorsMany".into(), json!({"items": items, "mode": if toggle { "toggle" } else { "set" }}));
std::iter::once(art).chain(crate::rulerguide::marquee_select(cx, r, false)).collect()
+2 -1
View File
@@ -137,8 +137,9 @@ pub fn sides_at(st: &StrokeLayer, t: f64) -> (f64, f64) {
impl WidthTool {
/// The stroked path nearest to `p` within reach (stroke half-width + a few pixels).
fn spot_at(cx: &ToolContext, p: Point) -> Option<Spot> {
let editable = cx.doc.editable();
let consider = |id: NodeId| -> Option<(f64, Spot)> {
if !cx.doc.is_editable(id) {
if !editable(id) {
return None;
}
let n = cx.doc.node(id)?;
+6 -3
View File
@@ -243,17 +243,19 @@ fn candidate_paths(cx: &ToolContext) -> Vec<NodeId> {
v.push(id);
}
}
v.retain(|id| cx.doc.is_editable(*id));
let editable = cx.doc.editable();
v.retain(|id| editable(*id));
v
}
/// The selected paths the Pen and the Curvature tool edit (not guides, nor locked or hidden ones).
pub(crate) fn editable_paths<'a>(cx: &'a ToolContext) -> impl Iterator<Item = NodeId> + 'a {
let editable = cx.doc.editable();
cx.selection
.objects
.iter()
.copied()
.filter(|&id| cx.doc.is_editable(id) && cx.doc.node(id).is_some_and(|n| matches!(n.kind, NodeKind::Path { guide: false, .. })))
.filter(move |&id| editable(id) && cx.doc.node(id).is_some_and(|n| matches!(n.kind, NodeKind::Path { guide: false, .. })))
}
/// The paths whose anchors show with the Pen: the selected ones, and those inside selected groups
@@ -275,7 +277,8 @@ pub(crate) fn shown_paths(cx: &ToolContext) -> Vec<NodeId> {
for n in cx.selection.objects.iter().filter_map(|id| cx.doc.node(*id)) {
inside(n, &mut v);
}
v.retain(|id| cx.doc.is_editable(*id));
let editable = cx.doc.editable();
v.retain(|id| editable(*id));
v
}
+2 -1
View File
@@ -182,9 +182,10 @@ impl GestureTool {
return None;
}
let tol = cx.tol(self.edit_within);
let editable = cx.doc.editable();
for id in &cx.selection.objects {
let Some(pd) = cx.doc.node(*id).and_then(|n| n.path_data()) else { continue };
if !cx.doc.is_editable(*id) {
if !editable(*id) {
continue;
}
let Some(sp) = pd.subpaths.iter().rev().find(|s| !s.closed && !s.anchors.is_empty()) else { continue };
+2 -1
View File
@@ -248,7 +248,8 @@ impl ReshapeTool {
/// The selected path under `p` (Reshape works on selected paths only, like Illustrator).
fn hit(cx: &ToolContext, p: Point) -> Option<(vectorcraft_doc::NodeId, Point)> {
let tol = cx.tol(4.0);
cx.selection.objects.iter().copied().filter(|id| cx.doc.is_editable(*id)).find_map(|id| {
let editable = cx.doc.editable();
cx.selection.objects.iter().copied().filter(|id| editable(*id)).find_map(|id| {
let pd = cx.doc.node(id)?.path_data()?;
let (_, _, _, q, d) = pd.nearest(p)?;
(d <= tol).then_some((id, q))
+2 -1
View File
@@ -162,7 +162,8 @@ impl MeshTool {
v.push(id);
}
}
v.retain(|id| cx.doc.is_editable(*id));
let editable = cx.doc.editable();
v.retain(|id| editable(*id));
v
}
+2 -1
View File
@@ -65,7 +65,8 @@ impl MeshEdit {
{
return Some(MeshGrab { id, index, handle: Some(h) });
}
ids.iter().filter(|id| cx.doc.is_editable(**id)).find_map(|id| {
let editable = cx.doc.editable();
ids.iter().filter(|id| editable(**id)).find_map(|id| {
let index = grid_of(cx.doc.node(*id)?)?.point_near(p, tol)?;
Some(MeshGrab { id: *id, index, handle: None })
})
+2 -1
View File
@@ -98,7 +98,8 @@ impl PieWidget {
if !selection.anchors.is_empty() || !zoom.is_finite() || zoom <= 0.0 {
return vec![];
}
let shown = |id: &&NodeId| doc.is_editable(**id) && !selection.segments.contains_key(*id);
let editable = doc.editable();
let shown = |id: &&NodeId| editable(**id) && !selection.segments.contains_key(*id);
let mut out = vec![];
for id in selection.objects.iter().filter(shown) {
let Some((e, (start, end))) = Ellipse::of(doc, *id) else { continue };
+2
View File
@@ -599,6 +599,7 @@ mod tests {
id,
name: "B".into(),
rect: vectorcraft_geom::Rect::new(600.0, 0.0, 900.0, 300.0),
locked: false,
show_center_mark: false,
show_cross_hairs: false,
color: None,
@@ -912,6 +913,7 @@ mod tests {
id: 2,
name: "Artboard 2".into(),
rect: Rect::new(150.0, 200.0, 350.0, 400.0),
locked: false,
show_center_mark: false,
show_cross_hairs: false,
color: None,
+110 -16
View File
@@ -16,7 +16,9 @@
//! the Selection tool (during a drag it only cancels the drag). Moving and resizing snap like drawing
//! does: to whole pixels, to the grid, or with Smart Guides to other artboards, their bleed and
//! objects (never to the dragged artboard or the art moving or scaling with it); the artboard's own bleed edges
//! snap too. A new artboard's corners snap as drawn points do ([`DrawSnap`]).
//! snap too. A new artboard's corners snap as drawn points do ([`DrawSnap`]). A locked artboard (the
//! Artboards panel's lock column) is selected by a click, but a drag doesn't move it or copy it,
//! it shows no handles, and Delete leaves it.
use serde_json::{Value, json};
use vectorcraft_geom::{Affine, Point, Rect, Vec2};
@@ -116,6 +118,21 @@ impl ArtboardTool {
cx.doc.artboards.get(self.active).map(|a| a.rect)
}
/// The active artboard's rectangle when it can be resized: it isn't locked.
fn resizable_rect(&self, cx: &ToolContext) -> Option<Rect> {
cx.doc.artboards.get(self.active).filter(|a| !a.locked).map(|a| a.rect)
}
/// The artboards a drag from artboard `index` moves: with `many`, the selected ones that
/// aren't locked, else `index`; none when `index` is locked.
fn movable(&self, cx: &ToolContext, index: usize, many: bool) -> Vec<usize> {
let unlocked = |i: &usize| cx.doc.artboards.get(*i).is_some_and(|a| !a.locked);
if !unlocked(&index) {
return vec![];
}
if many { self.selected(cx).into_iter().filter(unlocked).collect() } else { vec![index] }
}
/// The selected artboards that are still in the document: the active one and the others,
/// in order.
fn selected(&self, cx: &ToolContext) -> Vec<usize> {
@@ -170,10 +187,7 @@ impl ArtboardTool {
fn begin_snapping(&mut self, cx: &ToolContext, index: usize, art_moves: bool) {
self.guides.clear();
self.targets = cx.smart_guides.then(|| {
let art = match cx.doc.artboards.get(index) {
Some(a) if art_moves => cx.doc.art_on_artboard(a.rect, cx.move_locked_with_artboard),
_ => vec![],
};
let art = if art_moves { cx.doc.art_moving_with(index, cx.move_locked_with_artboard) } else { vec![] };
Targets::for_artboard(cx.doc, index, &art).styled(cx)
});
// Nothing to skip: no artboard has this index.
@@ -289,7 +303,7 @@ impl Tool for ArtboardTool {
match (ev.kind, self.drag) {
(PointerKind::Down, _) => {
self.click_art = None;
if let Some(r) = self.active_rect(cx)
if let Some(r) = self.resizable_rect(cx)
&& let Some(handle) = hit_handle(r, p, cx.tol(5.0))
{
self.drag = Some(Drag::Resize { index: self.active, handle, rect: r, grab: handle.pos(r) - p, began: false });
@@ -334,8 +348,10 @@ impl Tool for ArtboardTool {
(PointerKind::Drag, Some(Drag::Move { index, many, start, rect, began, copy })) => {
let mut out = vec![];
let label = |copy: bool| if copy { "Duplicate Artboard" } else { "Move Artboard" };
let moving = self.movable(cx, index, many);
if !began {
if p.distance(start) < cx.tol(3.0) {
// A locked artboard doesn't move, and nor is it copied by an Alt-drag.
if p.distance(start) < cx.tol(3.0) || moving.is_empty() {
return out;
}
out.push(Action::Begin(label(m.alt).into()));
@@ -350,7 +366,7 @@ impl Tool for ArtboardTool {
self.preview = Some(rect + d);
let mut v = json!({ "index": index, "dx": d.x, "dy": d.y, "moveArt": self.move_art, "copy": m.alt });
if many {
v["indices"] = json!(self.selected(cx));
v["indices"] = json!(moving);
}
out.push(Action::Preview("artboard.move".into(), v));
out
@@ -396,12 +412,12 @@ impl Tool for ArtboardTool {
return create_artboard(r);
}
match d {
Drag::Move { began: true, copy, many, .. } => {
Drag::Move { index, began: true, copy, many, .. } => {
// The copies, last in the previewed document, become the selection (the
// last one active).
if copy {
let n = cx.doc.artboards.len();
let k = if many { self.selected(cx).len().min(n) } else { 1 };
let k = if many { self.movable(cx, index, many).len().min(n) } else { 1 };
self.active = n.saturating_sub(1);
self.others = (n.saturating_sub(k)..n.saturating_sub(1)).collect();
}
@@ -461,8 +477,9 @@ impl Tool for ArtboardTool {
fn key(&mut self, cx: &ToolContext, key: ToolKey, _mods: Mods) -> Vec<Action> {
match key {
ToolKey::Delete | ToolKey::Backspace => {
let mut sel = self.selected(cx);
if self.drag.is_some() || cx.doc.artboards.len() <= 1 || self.active >= cx.doc.artboards.len() {
// A locked artboard isn't deleted.
let mut sel: Vec<usize> = self.selected(cx).into_iter().filter(|i| cx.doc.artboards.get(*i).is_some_and(|a| !a.locked)).collect();
if self.drag.is_some() || cx.doc.artboards.len() <= 1 || self.active >= cx.doc.artboards.len() || sel.is_empty() {
return vec![];
}
// With every artboard selected, keep the first one in the current order.
@@ -523,8 +540,11 @@ impl Tool for ArtboardTool {
let Some(ab) = cx.doc.artboards.get(self.active) else { return o };
let r = self.preview.unwrap_or(ab.rect);
o.push(Overlay::Path { path: polygon(&rect_corners(r), true), color: BLUE, width: 1.0, dashed: true });
for h in Handle::ALL {
o.push(Overlay::Anchor { p: h.pos(r), color: BLUE, filled: false, size: 7.0 });
// A locked artboard has no handles: it can't be resized.
if !ab.locked {
for h in Handle::ALL {
o.push(Overlay::Anchor { p: h.pos(r), color: BLUE, filled: false, size: 7.0 });
}
}
// Where the canvas names the other artboards: on the top edge, flush with the left one
// (labels are drawn 8 px right of and 14 px above their point).
@@ -543,7 +563,7 @@ impl Tool for ArtboardTool {
}
fn cursor(&self, cx: &ToolContext, p: Point, m: Mods) -> Cursor {
if let Some(r) = self.active_rect(cx)
if let Some(r) = self.resizable_rect(cx)
&& let Some(h) = hit_handle(r, p, cx.tol(5.0))
{
return match h {
@@ -555,7 +575,16 @@ impl Tool for ArtboardTool {
.turned(cx.view_angle());
}
// With Shift a drag draws or selects, never moves.
if !m.shift && cx.doc.artboard_at(p).is_some() { Cursor::Move } else { Cursor::Crosshair }
if m.shift {
return Cursor::Crosshair;
}
// The artboard a press would move (the topmost under the pointer, as `artboard_at` finds
// it): a locked one shows the arrow.
match cx.doc.artboard_at(p).and_then(|i| cx.doc.artboards.get(i)) {
Some(a) if a.locked => Cursor::Arrow,
Some(_) => Cursor::Move,
None => Cursor::Crosshair,
}
}
fn options(&self) -> Value {
@@ -685,6 +714,26 @@ mod tests {
assert_eq!(drag_board2(&c, &mut t, 52.0, 0.0)["dx"], 50.0);
}
/// Art that is also on a locked artboard stays behind when its artboard moves, so the move
/// snaps to it, as to any art that stays.
#[test]
fn moving_snaps_to_the_art_a_locked_artboard_keeps() {
let mut d = two_boards();
let mut locked = d.artboards[0].clone();
locked.rect = Rect::new(640.0, 40.0, 700.0, 70.0);
locked.locked = true;
d.artboards.push(locked);
let l = d.layers[0].id;
let id = d.alloc_id();
let art = vectorcraft_geom::shapes::rectangle(Rect::new(650.0, 50.0, 660.0, 60.0));
d.insert(Some(l), 0, vectorcraft_doc::Node::path(id, art, vectorcraft_doc::Appearance::default_art())).unwrap();
let (s, p) = (Selection::default(), paint());
let c = cx(&d, &s, &p);
let mut t = ArtboardTool::default();
// Artboard 2's left edge, 600 + 52, snaps to the rectangle's at 650.
assert_eq!(drag_board2(&c, &mut t, 52.0, 0.0)["dx"], 50.0);
}
#[test]
fn moving_snaps_to_grid_and_pixels() {
let d = two_boards();
@@ -774,6 +823,51 @@ mod tests {
assert_eq!(t.options()["selected"], json!([2]));
}
/// A click selects a locked artboard, but a drag neither moves it nor copies it (Alt), a press
/// on its edge doesn't resize it, it shows no handles, the pointer over it is the arrow, and
/// Delete leaves it. With an unlocked artboard selected too, a drag and Delete act on that one.
/// With Shift the pointer over it is the crosshair, a Shift-click toggles it, and a Shift-drag
/// inside it draws a new artboard, as over any artboard.
#[test]
fn a_locked_artboard_is_not_moved_resized_or_deleted() {
let mut d = two_boards();
d.artboards[1].locked = true;
let (s, p) = (Selection::default(), paint());
let mut c = cx(&d, &s, &p);
c.smart_guides = false;
let mut t = ArtboardTool::default();
for m in [Mods::default(), Mods { alt: true, ..Default::default() }] {
t.pointer(&c, &ev(PointerKind::Down, 700.0, 100.0).with_mods(m));
assert!(t.pointer(&c, &ev(PointerKind::Drag, 750.0, 100.0).with_mods(m)).is_empty());
assert!(t.pointer(&c, &ev(PointerKind::Up, 750.0, 100.0).with_mods(m)).is_empty());
}
assert_eq!(t.options()["selected"], json!([1]), "the click selects it");
assert!(!t.overlays(&c).iter().any(|o| matches!(o, Overlay::Anchor { .. })), "no handles");
// A press on its left edge, where a handle would be.
t.pointer(&c, &ev(PointerKind::Down, 600.0, 100.0));
assert!(t.pointer(&c, &ev(PointerKind::Drag, 550.0, 100.0)).is_empty());
t.pointer(&c, &ev(PointerKind::Up, 550.0, 100.0));
for x in [600.0, 700.0] {
assert_eq!(t.cursor(&c, Point::new(x, 100.0), Mods::default()), Cursor::Arrow);
}
assert!(t.key(&c, ToolKey::Delete, Mods::default()).is_empty());
t.set_option("selected", &json!([0, 1]));
t.pointer(&c, &ev(PointerKind::Down, 450.0, 450.0));
assert_eq!(preview_params(&t.pointer(&c, &ev(PointerKind::Drag, 470.0, 450.0)))["indices"], json!([0]));
t.pointer(&c, &ev(PointerKind::Up, 470.0, 450.0));
t.set_option("selected", &json!([0, 1]));
assert_eq!(t.key(&c, ToolKey::Delete, Mods::default()), vec![Action::Exec("artboard.delete".into(), json!({"index": 0}))]);
let shift = Mods { shift: true, ..Default::default() };
assert_eq!(t.cursor(&c, Point::new(700.0, 100.0), shift), Cursor::Crosshair);
t.set_option("selected", &json!([0, 1]));
t.pointer(&c, &ev(PointerKind::Down, 700.0, 100.0).with_mods(shift));
assert!(t.pointer(&c, &ev(PointerKind::Up, 700.0, 100.0).with_mods(shift)).is_empty());
assert_eq!(t.options()["selected"], json!([0]), "the Shift-click takes it out");
t.pointer(&c, &ev(PointerKind::Down, 650.0, 50.0).with_mods(shift));
assert!(t.pointer(&c, &ev(PointerKind::Drag, 690.0, 80.0).with_mods(shift)).is_empty(), "no move preview");
assert_eq!(t.pointer(&c, &ev(PointerKind::Up, 690.0, 80.0).with_mods(shift)), create_artboard(Rect::new(650.0, 50.0, 690.0, 80.0)));
}
/// A Shift-click toggles its artboard when the button comes up, so the press alone leaves the
/// selection as it was.
#[test]
+2 -1
View File
@@ -114,7 +114,8 @@ impl Tool for LassoTool {
}
}
});
hits.retain(|(id, _)| cx.doc.is_editable(*id) && cx.doc.is_visible(*id));
let editable = cx.doc.editable();
hits.retain(|(id, _)| editable(*id) && cx.doc.is_visible(*id));
let items: Vec<Value> = hits.iter().map(|(id, v)| json!({ "id": id.0, "anchors": anchors_json(v) })).collect();
let mode = if sub {
"subtract"
+34 -1
View File
@@ -2849,8 +2849,10 @@ fn select_named_artboard(app: &mut VectorcraftApp, index: usize) {
}
}
/// Start editing artboard `index`'s name on the canvas, unless the artboard is locked (the
/// Artboards panel still renames it).
fn begin_artboard_name_edit(ui: &Ui, app: &VectorcraftApp, index: usize) {
let Some(name) = app.session.active().and_then(|st| st.doc.artboards.get(index).map(|a| a.name.clone())) else { return };
let Some(name) = app.session.active().and_then(|st| st.doc.artboards.get(index).filter(|a| !a.locked).map(|a| a.name.clone())) else { return };
ui.data_mut(|d| d.insert_temp(name_edit_id(), ArtboardNameEdit { index, text: name, fresh: true }));
}
@@ -4053,6 +4055,37 @@ mod tests {
assert_eq!(app.session.active().unwrap().doc.artboards[0].name, "Poster", "an empty name is left as it is");
}
/// Double-clicking the name drawn above a locked artboard doesn't edit it (the Artboards panel
/// still renames the artboard), fit an artboard or open Artboard Options.
#[test]
fn double_clicking_a_locked_artboards_name_leaves_it() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
app.session.execute("shape.rectangle", &json!({"x": 40, "y": 40, "width": 30, "height": 20})).unwrap();
app.session.execute("artboard.setProps", &json!({"index": 0, "locked": true})).unwrap();
app.select_tool("artboard");
let ctx = egui::Context::default();
frame(&mut app, &ctx, vec![]);
let rect = app.canvas_rect.unwrap();
let xf = Xf::new(rect, app.view().unwrap());
let mut target = None;
let _ =
ctx.run_ui(egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))), ..Default::default() }, |ui| {
let ab = &app.session.active().unwrap().doc.artboards[0];
target = Some(tool_name_rect(ui.painter(), &xf, ab.rect, 0, &ab.name).center());
});
let at = target.unwrap();
let button = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
for _ in 0..2 {
frame(&mut app, &ctx, vec![egui::Event::PointerMoved(at)]);
frame(&mut app, &ctx, vec![button(true)]);
frame(&mut app, &ctx, vec![button(false)]);
}
assert!(ctx.data(|d| d.get_temp::<ArtboardNameEdit>(name_edit_id()).is_none()), "the name isn't edited");
assert_eq!(app.session.active().unwrap().doc.artboards.len(), 1);
assert!(app.ui.dialog.is_none(), "Artboard Options stays closed");
}
/// #949: an artboard's name stays within the artboard's width on screen, ending in "…" when
/// longer, and an artboard too narrow shows none.
#[test]
+1
View File
@@ -3989,6 +3989,7 @@ axis Both И двете
@msg no such action няма такова действие
@msg no such anchor няма такава опорна точка
@msg no such artboard няма такава работна област
@msg the artboard is locked работната област е заключена
@msg no such document няма такъв документ
@msg no such layer or object няма такъв слой или обект
@msg no such object {0} няма такъв обект {0}
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -4379,6 +4379,7 @@ imageTrace Outline Obrys
@msg no such action taková akce neexistuje
@msg no such anchor takový kotevní bod neexistuje
@msg no such artboard taková pracovní plocha neexistuje
@msg the artboard is locked pracovní plocha je zamčená
@msg no such document takový dokument neexistuje
@msg no such layer or object taková vrstva ani objekt neexistuje
@msg no such object {0} objekt {0} neexistuje
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -4009,6 +4009,7 @@ theme Appearance Erscheinungsbild
@msg no such action keine solche Aktion
@msg no such anchor kein solcher Ankerpunkt
@msg no such artboard keine solche Zeichenfläche
@msg the artboard is locked die Zeichenfläche ist gesperrt
@msg no such document kein solches Dokument
@msg no such layer or object keine solche Ebene und kein solches Objekt
@msg no such object {0} kein solches Objekt {0}
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -4045,6 +4045,7 @@ theme Appearance Εμφάνιση
@msg no such action δεν υπάρχει τέτοια ενέργεια
@msg no such anchor δεν υπάρχει τέτοιο σημείο αγκύρωσης
@msg no such artboard δεν υπάρχει τέτοιος πίνακας σχεδίασης
@msg the artboard is locked ο πίνακας σχεδίασης είναι κλειδωμένος
@msg no such document δεν υπάρχει τέτοιο έγγραφο
@msg no such layer or object δεν υπάρχει τέτοιο επίπεδο ή αντικείμενο
@msg no such object {0} δεν υπάρχει τέτοιο αντικείμενο {0}
Can't render this file because it contains an unexpected character in line 2029 and column 19.
+1
View File
@@ -4077,6 +4077,7 @@ axis Both Ambos
@msg no such action no existe esa acción
@msg no such anchor no existe ese punto de ancla
@msg no such artboard no existe esa mesa de trabajo
@msg the artboard is locked la mesa de trabajo está bloqueada
@msg no such document no existe ese documento
@msg no such layer or object no existe esa capa u objeto
@msg no such object {0} no existe el objeto {0}
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -4294,6 +4294,7 @@ imageTrace Outline Ääriviiva
@msg no such action toimintoa ei ole
@msg no such anchor ankkuria ei ole
@msg no such artboard piirtoalustaa ei ole
@msg the artboard is locked piirtoalusta on lukittu
@msg no such document asiakirjaa ei ole
@msg no such layer or object tasoa tai objektia ei ole
@msg no such object {0} objektia {0} ei ole
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -4074,6 +4074,7 @@ axis Both Les deux
@msg no such action script inexistant
@msg no such anchor point d’ancrage inexistant
@msg no such artboard plan de travail inexistant
@msg the artboard is locked le plan de travail est verrouillé
@msg no such document document inexistant
@msg no such layer or object calque ou objet inexistant
@msg no such object {0} objet {0} inexistant
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -4722,6 +4722,7 @@ imageTrace Outline Körvonal
@msg no such action nincs ilyen művelet
@msg no such anchor nincs ilyen horgonypont
@msg no such artboard nincs ilyen rajztábla
@msg the artboard is locked a rajztábla zárolt
@msg no such document nincs ilyen dokumentum
@msg no such layer or object nincs ilyen réteg vagy objektum
@msg no such object {0} nincs ilyen objektum: {0}
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -4117,6 +4117,7 @@ axis Both Entrambi
@msg no such action azione inesistente
@msg no such anchor punto di ancoraggio inesistente
@msg no such artboard tavola da disegno inesistente
@msg the artboard is locked la tavola da disegno è bloccata
@msg no such document documento inesistente
@msg no such layer or object livello o oggetto inesistente
@msg no such object {0} oggetto {0} inesistente
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -3925,6 +3925,7 @@ swatch Bright Violet ビビッドバイオレット
@msg no saved selection recalled 保存した選択範囲を呼び出していません
@msg no saved selections 保存した選択範囲はありません
@msg no such artboard 指定されたアートボードが見つかりません
@msg the artboard is locked アートボードがロックされています
@msg cannot split an open path at its end point オープンパスは端点で分割できません
@msg closing needs three anchors 閉じるには 3 つのアンカーポイントが必要です
@msg empty brush stroke ブラシストロークが空です
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -3857,6 +3857,7 @@ effect Regular ჩვეულებრივი
@msg no such action ასეთი მოქმედება არ არის
@msg no such anchor ასეთი საყრდენი წერტილი არ არის
@msg no such artboard ასეთი სამუშაო დაფა არ არის
@msg the artboard is locked სამუშაო დაფა ჩაკეტილია
@msg no such document ასეთი დოკუმენტი არ არსებობს
@msg no such layer or object ასეთი შრე ან ობიექტი არ არის
@msg no such object {0} ასეთი ობიექტი {0} არ არის
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -3857,6 +3857,7 @@ theme Appearance 외관
@msg no saved selection recalled 불러온 저장된 선택 영역이 없습니다
@msg no saved selections 저장된 선택 영역이 없습니다
@msg no such artboard 해당 아트보드가 없습니다
@msg the artboard is locked 아트보드가 잠겨 있습니다
@msg cannot split an open path at its end point 열린 패스는 끝점에서 나눌 수 없습니다
@msg closing needs three anchors 닫으려면 앵커가 세 개 필요합니다
@msg empty brush stroke 빈 브러시 선입니다
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -4201,6 +4201,7 @@ imageTrace Outline Kontur
@msg no such action brak takiej operacji
@msg no such anchor brak takiego punktu
@msg no such artboard brak takiego obszaru kompozycji
@msg the artboard is locked obszar kompozycji jest zablokowany
@msg no such document brak takiego dokumentu
@msg no such layer or object brak takiej warstwy lub obiektu
@msg no such object {0} brak obiektu {0}
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -3657,6 +3657,7 @@ axis Both Ambos
@msg no such action ação inexistente
@msg no such anchor âncora inexistente
@msg no such artboard prancheta inexistente
@msg the artboard is locked a prancheta está bloqueada
@msg no such document documento inexistente
@msg no such layer or object camada ou objeto inexistente
@msg no such object {0} objeto inexistente {0}
Can't render this file because it contains an unexpected character in line 6 and column 47.
+1
View File
@@ -4113,6 +4113,7 @@ axis Both Оба
@msg no such action такого действия нет
@msg no such anchor такой опорной точки нет
@msg no such artboard такой монтажной области нет
@msg the artboard is locked монтажная область заблокирована
@msg no such document такого документа нет
@msg no such layer or object такого слоя или объекта нет
@msg no such object {0} объект {0} не существует
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -3994,6 +3994,7 @@ axis Both Båda
@msg no such action åtgärden finns inte
@msg no such anchor ankaret finns inte
@msg no such artboard ritytan finns inte
@msg the artboard is locked ritytan är låst
@msg no such document dokumentet finns inte
@msg no such layer or object lagret eller objektet finns inte
@msg no such object {0} objektet {0} finns inte
Can't render this file because it contains an unexpected character in line 5 and column 25.
+1
View File
@@ -4101,6 +4101,7 @@ axis Both Обидві
@msg no such action такої дії немає
@msg no such anchor такої опорної точки немає
@msg no such artboard такої монтажної області немає
@msg the artboard is locked монтажна область заблокована
@msg no such document такого документа немає
@msg no such layer or object такого шару чи об’єкта немає
@msg no such object {0} об’єкт {0} не існує
Can't render this file because it contains an unexpected character in line 726 and column 40.
+1
View File
@@ -4370,6 +4370,7 @@ theme Appearance Giao diện
@msg no such action không có hành động như vậy
@msg no such anchor không có điểm neo như vậy
@msg no such artboard không có bảng vẽ như vậy
@msg the artboard is locked bảng vẽ bị khóa
@msg no such document không có tài liệu như vậy
@msg no such layer or object không có lớp hoặc đối tượng như vậy
@msg no such object {0} không có đối tượng như vậy: {0}
Can't render this file because it contains an unexpected character in line 3997 and column 10.
+1
View File
@@ -3672,6 +3672,7 @@ imageTrace Outline 轮廓
@msg no saved selection recalled 未调出任何已保存的选区
@msg no saved selections 没有已保存的选区
@msg no such artboard 没有此画板
@msg the artboard is locked 该画板已锁定
@msg cannot split an open path at its end point 无法在开放路径的端点处拆分该路径
@msg closing needs three anchors 闭合需要三个锚点
@msg empty brush stroke 画笔描边为空
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -1666,6 +1666,7 @@ file-filter Templates 範本
@msg no saved selection recalled 未叫出任何已儲存的選取範圍
@msg no saved selections 沒有已儲存的選取範圍
@msg no such artboard 沒有這個工作區域
@msg the artboard is locked 工作區域已鎖定
@msg cannot split an open path at its end point 無法在開放路徑的端點處分割路徑
@msg closing needs three anchors 封閉需要三個錨點
@msg empty brush stroke 筆刷筆畫是空的
Can't render this file because it contains an unexpected character in line 6 and column 47.
+122 -11
View File
@@ -6,7 +6,9 @@
//! its art when the Artboard tool's Move/Copy Artwork with Artboard is on). The highlighted row is the active artboard, the one the status bar's navigator shows and
//! Fit Artboard in Window fits: clicking a row makes it active, double-clicking its number also
//! fits it in the window. The options icon at a row's right end opens Artboard Options: a click on
//! the active artboard's icon, or a double-click on another row's icon.
//! the active artboard's icon, or a double-click on another row's icon. The lock column left of it
//! shows a lock on a locked artboard, and a click there locks or unlocks the artboard
//! (`artboard.setProps` with `locked`); Delete leaves locked artboards.
use egui::{Sense, Ui, pos2, vec2};
use serde_json::{Value, json};
@@ -176,13 +178,26 @@ pub(crate) fn picked(app: &VectorcraftApp, n: usize) -> Vec<usize> {
all
}
/// The artboards a new size for artboard `i`, or a name typed in its row's name field, goes to:
/// the selected artboards ([`picked`]) when `i` is one of them, else `i` alone.
pub(crate) fn resized_with(app: &VectorcraftApp, i: usize) -> Vec<usize> {
/// The picked artboards that can be deleted: those that aren't locked.
pub(crate) fn deletable(app: &VectorcraftApp, n: usize) -> Vec<usize> {
let locked = |i: &usize| app.session.active().and_then(|d| d.doc.artboards.get(*i)).is_some_and(|a| a.locked);
picked(app, n).into_iter().filter(|i| !locked(i)).collect()
}
/// The artboards a name typed in artboard `i`'s row's name field goes to: the selected artboards
/// ([`picked`]) when `i` is one of them, else `i` alone. Locked artboards are renamed too.
pub(crate) fn renamed_with(app: &VectorcraftApp, i: usize) -> Vec<usize> {
let all = picked(app, app.session.active().map_or(0, |d| d.doc.artboards.len()));
if all.contains(&i) { all } else { vec![i] }
}
/// The artboards a new size for artboard `i` goes to: those [`renamed_with`] gives. Another
/// selected artboard that is locked keeps its size.
pub(crate) fn resized_with(app: &VectorcraftApp, i: usize) -> Vec<usize> {
let locked = |j: &usize| app.session.active().and_then(|d| d.doc.artboards.get(*j)).is_some_and(|a| a.locked);
renamed_with(app, i).into_iter().filter(|j| *j == i || !locked(j)).collect()
}
/// The `artboard.setProps` params that set `entries` (each an artboard's `index` and its fields)
/// with `common` (an object: `scaleArt`, `moveArt`), in one undo step: the one entry's fields
/// beside `common`, or an `artboards` list of several beside it; None for no entry.
@@ -240,16 +255,19 @@ fn rename(app: &mut VectorcraftApp, indices: &[usize], name: &str) -> Result<Val
}
/// Open the name field on artboard `i`'s row. The name typed there goes to the artboards
/// [`resized_with`] gives for `i` now, numbered when there are several ([`rename`]).
/// [`renamed_with`] gives for `i` now, numbered when there are several ([`rename`]).
fn start_rename(ctx: &egui::Context, app: &VectorcraftApp, i: usize) {
set_pstate(ctx, "ab-edit", Some(i));
set_pstate(ctx, "ab-edit-all", resized_with(app, i));
set_pstate(ctx, "ab-edit-all", renamed_with(app, i));
}
/// Delete the picked artboards and select a remaining one.
/// Delete the picked artboards that aren't locked and select a remaining one.
pub(crate) fn delete_selected(app: &mut VectorcraftApp) {
let n = app.session.active().map_or(0, |d| d.doc.artboards.len());
let mut all = picked(app, n);
let mut all = deletable(app, n);
if all.is_empty() {
return;
}
if n > 1 && all.len() == n {
// Deleting all artboards keeps artboard 1 (the public Delete artboards guide).
all.retain(|i| *i != 0);
@@ -366,6 +384,7 @@ pub(crate) fn duplicate_picked(app: &mut VectorcraftApp) {
pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
let t = Tokens::get(ui.ctx());
let abs: Vec<String> = app.session.active().map(|d| d.doc.artboards.iter().map(|a| a.name.clone()).collect()).unwrap_or_default();
let locked: Vec<bool> = app.session.active().map(|d| d.doc.artboards.iter().map(|a| a.locked).collect()).unwrap_or_default();
if abs.is_empty() {
super::empty_state(ui, "dc-artboards", tl!("No document"), tl!("Open a document to see its artboards."));
return;
@@ -395,7 +414,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
egui::FontId::proportional(12.0),
t.text_dim,
);
let name_rect = egui::Rect::from_min_max(pos2(r.left() + 32.0, r.top() + 2.0), pos2(r.right() - 28.0, r.bottom() - 2.0));
let name_rect = egui::Rect::from_min_max(pos2(r.left() + 32.0, r.top() + 2.0), pos2(r.right() - 46.0, r.bottom() - 2.0));
if editing == Some(i) {
// The artboards the field renames: the artboards selected when it opened, when
// there were several and this row's artboard is one of them, else this row's
@@ -426,6 +445,19 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
} else {
ui.painter().text(name_rect.left_center(), egui::Align2::LEFT_CENTER, name, egui::FontId::proportional(12.5), t.text);
}
// The lock column, right of the name: the lock shows while the artboard is locked
// (dimmed under the pointer while it isn't), and a click locks or unlocks it.
let is_locked = locked.get(i).copied().unwrap_or(false);
let lock = egui::Rect::from_center_size(r.right_center() - vec2(36.0, 0.0), vec2(14.0, 14.0));
let lresp = ui.interact(lock, ui.id().with(("ab-lock", i)), Sense::click());
if is_locked {
icons::paint(ui, "lock", egui::Rect::from_center_size(lock.center(), vec2(12.0, 12.0)), t.icon);
} else if lresp.hovered() {
icons::paint(ui, "lock", egui::Rect::from_center_size(lock.center(), vec2(12.0, 12.0)), t.text_disabled);
}
if lresp.clicked() {
app.run("artboard.setProps", json!({"index": i, "locked": !is_locked})).ok();
}
let opt = egui::Rect::from_center_size(r.right_center() - vec2(14.0, 0.0), vec2(14.0, 14.0));
let oresp = ui.interact(opt, ui.id().with(("ab-opt", i)), Sense::click());
icons::paint(ui, "dc-artboard-options", opt, if oresp.hovered() { t.text_strong } else { t.icon });
@@ -497,7 +529,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
} else if new.clicked() && app.run("artboard.new", json!({})).is_ok() {
select(app, n);
}
if widgets::icon_button_enabled(ui, "trash-2", tl!("Delete Artboard"), false, n > 1, 24.0).clicked() {
if widgets::icon_button_enabled(ui, "trash-2", tl!("Delete Artboard"), false, n > 1 && !deletable(app, n).is_empty(), 24.0).clicked() {
delete_selected(app);
}
});
@@ -517,7 +549,7 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) {
if menu_item(ui, tl!("Duplicate Artboards"), n > 0, false) {
duplicate_picked(app);
}
if menu_item(ui, tl!("Delete Artboards"), n > 1, false) {
if menu_item(ui, tl!("Delete Artboards"), n > 1 && !deletable(app, n).is_empty(), false) {
delete_selected(app);
}
if menu_item(ui, tl!("Rename"), n > 0, false) {
@@ -1410,6 +1442,85 @@ mod tests {
assert_eq!(app.view().unwrap().artboard, 1);
}
/// A click in a row's lock column locks its artboard and shows the lock next to the name, a
/// second click unlocks it, and each click is one undo step. Delete Artboard leaves a locked
/// artboard.
#[test]
fn the_lock_column_locks_and_unlocks_an_artboard() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.session.execute("file.new", &json!({"width": 200, "height": 150})).unwrap();
app.session.execute("artboard.new", &json!({"x": 400, "y": 0, "width": 200, "height": 150})).unwrap();
select(&mut app, 1);
let ctx = egui::Context::default();
let screen = Some(egui::Rect::from_min_size(egui::Pos2::ZERO, vec2(240.0, 400.0)));
let frame = |app: &mut VectorcraftApp, time: f64, events: Vec<egui::Event>| {
let mut out = ctx.run_ui(egui::RawInput { events, time: Some(time), screen_rect: screen, ..Default::default() }, |ui| show(app, ui));
out.textures_delta.clear();
out.shapes
};
// The highlighted row (Artboard 2's): its lock column is 36 points in from its right end.
let shapes = frame(&mut app, 0.0, vec![]);
let row = shapes
.iter()
.find_map(|c| match &c.shape {
egui::Shape::Rect(r) if r.fill == Tokens::get(&ctx).row_selected => Some(r.rect),
_ => None,
})
.unwrap();
let lock = egui::pos2(row.right() - 36.0, row.center().y);
let click = |at: egui::Pos2| {
let b = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
vec![egui::Event::PointerMoved(at), b(true), b(false)]
};
let away = vec![egui::Event::PointerMoved(egui::pos2(-50.0, -50.0))];
let locked = |app: &VectorcraftApp| app.session.active().unwrap().doc.artboards.iter().map(|a| a.locked).collect::<Vec<_>>();
// Whether something is drawn in the lock column of Artboard 2's row.
let cell = egui::Rect::from_center_size(lock, vec2(14.0, 14.0));
let lock_drawn = |shapes: Vec<egui::epaint::ClippedShape>| {
shapes.iter().any(|c| {
let b = c.shape.visual_bounding_rect();
b.is_positive() && cell.contains_rect(b)
})
};
assert!(!lock_drawn(frame(&mut app, 1.0, away.clone())));
frame(&mut app, 2.0, click(lock));
assert_eq!(locked(&app), [false, true]);
assert!(lock_drawn(frame(&mut app, 3.0, away.clone())), "the lock shows");
assert!(deletable(&app, 2).is_empty(), "Delete Artboard leaves it");
delete_selected(&mut app);
assert_eq!(app.session.active().unwrap().doc.artboards.len(), 2);
frame(&mut app, 5.0, click(lock));
assert_eq!(locked(&app), [false, false]);
assert!(!lock_drawn(frame(&mut app, 6.0, away)));
app.session.execute("edit.undo", &json!({})).unwrap();
assert_eq!(locked(&app), [false, true], "one undo step");
}
/// A new size for the selected artboards leaves a locked one among them as it is. The
/// artboard the field belongs to stays listed when it is locked, so `artboard.setProps`
/// refuses the change. A name typed for them goes to the locked one too.
#[test]
fn a_new_size_for_the_selected_artboards_leaves_a_locked_one() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.session.execute("file.new", &json!({"width": 200, "height": 150})).unwrap();
for x in [400, 800] {
app.session.execute("artboard.new", &json!({"x": x, "y": 0, "width": 200, "height": 150})).unwrap();
}
app.select_tool("artboard");
app.session.set_tool_option("selected", &json!([0, 1, 2]));
app.session.execute("artboard.setProps", &json!({"index": 1, "locked": true})).unwrap();
assert_eq!(resized_with(&app, 0), [0, 2]);
assert_eq!(resized_with(&app, 1), [0, 1, 2], "the field's own artboard stays listed");
crate::panels::transform::set_artboard(&mut app, 0, 0, [None, None, Some(300.0), None], false);
let widths = app.session.active().unwrap().doc.artboards.iter().map(|a| a.rect.width()).collect::<Vec<_>>();
assert_eq!(widths, [300.0, 200.0, 300.0]);
let all = renamed_with(&app, 0);
assert_eq!(all, [0, 1, 2]);
rename(&mut app, &all, "abc").unwrap();
let names = app.session.active().unwrap().doc.artboards.iter().map(|a| a.name.clone()).collect::<Vec<_>>();
assert_eq!(names, ["abc-1", "abc-2", "abc-3"]);
}
/// Alt-click on New Artboard opens Artboard Options for the new artboard and adds nothing yet:
/// the fields are those a plain click's artboard gets. Cancel adds nothing; OK adds the artboard
/// with the fields in one undo step and makes it the active one.
+2 -1
View File
@@ -263,7 +263,8 @@ fn artboard_sections(app: &mut VectorcraftApp, ui: &mut Ui) {
if widgets::flat_button(ui, tl!("New Artboard"), w).clicked() && app.run("artboard.new", json!({})).is_ok() {
super::artboards::select(app, n);
}
if ui.add_enabled_ui(n > 1, |ui| widgets::flat_button(ui, tl!("Delete Artboard"), w)).inner.clicked() {
let deletable = n > 1 && !super::artboards::deletable(app, n).is_empty();
if ui.add_enabled_ui(deletable, |ui| widgets::flat_button(ui, tl!("Delete Artboard"), w)).inner.clicked() {
super::artboards::delete_selected(app);
}
});
+31 -6
View File
@@ -2346,8 +2346,9 @@ tool (`tool.select {tool: "artboard"}`) starts it on the active artboard: its `a
`artboard.fitToArt {index? | indices?}` (Object › Artboards › Fit to Artwork Bounds) fits each artboard it names to
the art on that artboard, in one undo step. The art is what an export draws, measured with its strokes and with the
effects on paths, compound paths and symbol instances. An object counts for every artboard its bounds overlap, also
when it reaches past the artboard's edge, and a group or a clipping layer counts whole. An artboard with no art on it
keeps its size and place, and the call fails with `no artwork` when none of its artboards has art.
when it reaches past the artboard's edge, and a group or a clipping layer counts whole. An artboard with no art on it,
or a locked one, keeps its size and place. The call fails with `the artboard is locked` when every artboard it names is
locked, and with `no artwork` when none of the others has art.
`artboard.fitToSelection {index?}` (Object › Artboards › Fit to Selected Art) fits artboard `index` to the selected
art. Both commands take 0-based indexes and resize the first artboard when the call names none. The desktop app adds
the selected artboards (those the Artboard tool has selected, else the active one) as `indices` to the
@@ -2412,8 +2413,8 @@ The Selection & Anchor Display and General preferences apply to `pointer_gesture
point a selection is dragged by, a drawn point and a transform tool's reference point land on an anchor or a ruler
guide that near.
- `moveLockedWithArtboard`: `artboard.move {moveArt: true}`, the Artboard tool and `artboard.rearrange` move locked and
hidden art with the artboard too, and `artboard.setProps {scaleArt: true}` scales it too; off (the default) it stays
where it is.
hidden art, and art that is also on a locked artboard, with the artboard too, and `artboard.setProps {scaleArt: true}`
scales it too; off (the default) it stays where it is.
- `penRubberBand`, `curvatureRubberBand` (on by default): off, the Pen and Curvature tools draw no preview segment to
the pointer.
- `showHandlesMultipleAnchors` (on by default): off, Direct Selection (and the Anchor Point tool, and the Pen with Alt)
@@ -2792,7 +2793,7 @@ value: true}`; the Control bar's and Properties' Scale Artwork with Artboard che
Artboard) makes its handle drags proportional and passes `scaleArt`, as do Properties' and the Transform panel's
artboard size fields while the tool is in use.
`artboard.setProps {artboards: [{index, name?, color?, x?, y?, width?, height?}…], scaleArt?, moveArt?, …}` sets several
`artboard.setProps {artboards: [{index, name?, color?, locked?, x?, y?, width?, height?}…], scaleArt?, moveArt?, …}` sets several
artboards in one undo step, each with its own fields; the other params apply to all of them. The art that moves or
scales with each artboard is the art inside it before any of them changes, and an object inside several of them goes
with the first one listed that moves or scales its art. An empty list and a list that names an artboard twice are
@@ -2806,12 +2807,35 @@ linked they keep each artboard's own proportions. Artboard Options resizes each
form used for the new Width or Height. Properties' W and H and the preset and orientation buttons keep each artboard's
top-left corner. A preset sizes each artboard in its own orientation, and an orientation
swaps the width and height of each artboard that is not square and not already in it. A new X or Y moves the active
artboard only, and Artboard Options' Name renames only the artboard it shows.
artboard only, and Artboard Options' Name renames only the artboard it shows. A locked artboard among the other
selected artboards keeps its size.
```json
{"name":"run_command","arguments":{"command":"artboard.setProps","params":{"artboards":[{"index":0,"width":300},{"index":2,"width":300}],"scaleArt":true}}}
```
Locked artboards: `artboard.setProps {index, locked: true}` locks an artboard and `{index, locked: false}` unlocks it,
each in one undo step. The Artboards panel's lock column, left of a row's options icon, shows a lock on a locked
artboard, and a click there runs this command. A locked artboard keeps its place and size. `artboard.setProps` with a
new `x`, `y`, `width` or `height` fails with `the artboard is locked` unless the same call unlocks it, and
`artboard.move` without `copy`, `artboard.delete`, `artboard.cut` and `artboard.fitToSelection` fail the same way.
`artboard.fitToArt` and `artboard.rearrange` leave a locked artboard where it is, and a copy of one
(`artboard.duplicate`, `artboard.move {copy}`, Paste) starts unlocked. The art on a locked artboard is each object of
a layer or sublayer whose bounds, with its strokes, overlap the artboard; a group counts as one object, and a hidden
object, a guide or the art of pattern editing mode is on no artboard. That art acts as locked art: clicks, marquees, `select.set`, `select.all` and the
tools pass over it, and commands that act on the selection leave it. Locking the artboard deselects it, and so does an
edit that moves art onto a locked artboard. Art that an edit draws or pastes onto a locked artboard stays selected and
editable until a selection leaves it out or an edit moves it off the locked artboards, and is then protected like the
other art on it. `edit.undo`, `edit.redo` and a failed `command.batch` bring back the selection a step had, with that
art selected and editable when it was. When another artboard moves, scales or is cut (`artboard.move`,
`artboard.setProps`, `artboard.cut`, `artboard.rearrange`), art that is also on a locked artboard stays where it is
unless prefs `moveLockedWithArtboard` is on (`lockedAndHidden` for `artboard.setProps`); a copy of an artboard copies
it. With the Artboard tool, a click
selects a locked artboard, but a drag doesn't move or copy it, it shows no handles, Delete leaves it, and a
double-click on its name above the canvas doesn't edit the name (`artboard.setProps {name}` and the Artboards panel
still rename it). A Shift-click or Shift-drag on a locked artboard selects or draws as on any other artboard. The
document summary (`document.inspect`) adds `locked: true` to a locked artboard.
An artboard's background color (Properties › Artboards › Fill, Artboard Options' Background Color): `artboard.setProps
{index, color}` sets it, with a color as the paint commands take it (`"#rrggbb"`, `{c, m, y, k}`, …), and
`artboard.setProps {index, color: null}` removes it, each in one undo step; `artboard.new {color}` makes an artboard
@@ -2825,6 +2849,7 @@ lists it as the artboard's `color` (`#rrggbb`), and `ui.colorPicker {artboard: i
{"name":"run_command","arguments":{"command":"artboard.copy","params":{"index":0}}}
{"name":"run_command","arguments":{"command":"edit.paste","params":{}}}
{"name":"run_command","arguments":{"command":"artboard.duplicate","params":{"index":0,"art":true}}}
{"name":"run_command","arguments":{"command":"artboard.setProps","params":{"index":1,"locked":true}}}
{"name":"run_command","arguments":{"command":"clipboard.conflicts","params":{}}}
{"name":"run_command","arguments":{"command":"edit.paste","params":{"center":[300,200],"swatchConflict":{"Brand":"add"}}}}
{"name":"run_command","arguments":{"command":"layer.pasteRemembersLayers","params":{"on":true}}}
+1
View File
@@ -441,6 +441,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
- **Artboard Escape routing:** Escape leaves Artboard editing for Selection; during a drag it first cancels the gesture. Headless egui key events cover idle Escape, active-drag cancellation and a subsequent Escape in `tests_distortkeys.rs`.
- **Shift with the Artboard tool:** a Shift-click adds an artboard to the selection or takes it out when the button comes up. A Shift-drag inside an artboard draws a new artboard there, and a Shift-drag on the pasteboard is a marquee that toggles the artboards it touches, as the [default keyboard shortcuts](https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html) ("Create artboard inside another artboard") and [Using multiple artboards](https://web.archive.org/web/20210925094818/https://helpx.adobe.com/illustrator/using/using-multiple-artboards.html) (archived copy) describe. Shift no longer keeps an artboard move at 45° steps or a drawn artboard square. Escape during a marquee or while a new artboard is being drawn cancels it and keeps the Artboard tool. Pointer and key tests in `crates/tools/src/xform/artboard.rs` cover the four gestures and Escape.
- **Artboard deletion keys:** Delete/Backspace delete selected boards in one Undo step, retaining the original first board in the current order when all are selected, as in the public [Delete artboards guide](https://helpx.adobe.com/illustrator/desktop/create-manage-artboards/add-edit-artboards/delete-artboards.html). The last board and active drags stay protected; neither key clears selected artwork. Headless shortcut regressions in `tests_distortkeys.rs` cover reordered boards, artwork, saved state and Undo/Redo.
- **Locked artboards:** a click in the Artboards panel's lock column locks or unlocks an artboard in one undo step (`artboard.setProps {index, locked}`), and the native format saves the lock. The Artboard tool doesn't move, copy, resize or delete a locked artboard, Rearrange and Fit to Artwork Bounds leave it as it is, and its name isn't edited on the canvas. The art on it (each object whose bounds overlap it, a group as one object) acts as locked art until it is unlocked: clicks, marquees, `select.set`, `select.all` and the tools pass over it, and commands that act on the selection leave it; art drawn or pasted onto it stays selected and editable until the selection leaves it out. Tests in `crates/doc/src/lib.rs`, `tests_panelcmds.rs`, `xform/artboard.rs`, `panels/artboards.rs` and `canvas.rs` cover them. Public references: [Lock artboards](https://helpx.adobe.com/illustrator/desktop/create-manage-artboards/organize-manage-artboards/lock-artboards.html) and [Managing Artboards in Illustrator](https://creativepro.com/managing-artboards-in-illustrator/).
- **Linux RPM packaging:** 64-bit runtime capabilities replace Fedora-specific required package names (#1275); glibc 2.35, libm and libgcc_s are declared alongside dynamically loaded xkbcommon and Vulkan/EGL. Generated x86_64/aarch64 metadata and isolated synthetic-provider resolution checked; real-distro install/start-up QA remains pending.
- **Linux file associations:** the desktop entry lists the MIME type of every format File › Open reads but PSD, PSB, `.ps` PostScript and Targa, `.ai`, `.ait` and EPS included, and the MIME package declares the types shared-mime-info lacks (VectorCraft documents and templates, `.ait`, Affinity), so file managers list VectorCraft under Open With for them, as Finder does on macOS. `the_linux_desktop_entry_opens_every_readable_format` in `apps/vectorcraft/src/main.rs` checks both files against `OPEN_EXTS`. On Debian 13, Ubuntu 24.04 and Fedora 42, `desktop-file-validate` accepts the desktop entry, `update-mime-database` accepts the MIME package, and `gio` registers the app for every one of these types.