Merge pull request #5692 from dotCooCoo/fix/appearance-active-edits-each-target

Appearance: edits to the active row reach the matching row in each selected object
This commit is contained in:
BFlat
2026-11-05 04:09:08 +07:00
committed by GitHub
3 changed files with 79 additions and 24 deletions
+14 -12
View File
@@ -276,22 +276,24 @@ impl ItemTarget {
/// This target for each of `ids` (in their order): an aligned item (a row of the first
/// target's stack, or for the active item of the first selected object's, see
/// [`ItemTarget::anchor`]) is, in each other target, the item equal to it there (see
/// [`SharedStack`]); `None` for a target that has none, which the edit leaves alone. A row the
/// targets don't share, and any other target, stands for itself in every target.
/// [`SharedStack`]), each target matched against that stack on its own, whatever the other
/// targets have. In a target without an equal item, and for any other target, the row stands
/// for itself. Every entry is `Some`.
pub(crate) fn per_target(self, s: &Session, ids: &[NodeId]) -> Vec<Option<ItemTarget>> {
let ItemTarget::Item { index, explicit, aligned: true } = self else { return vec![Some(self); ids.len()] };
let Some(st) = s.active().filter(|_| ids.len() >= 2) else { return vec![Some(self); ids.len()] };
let k = self.anchor(s, ids);
let mut order = ids.to_vec();
order.swap(0, k);
let Some(shared) = shared_stack(&st.doc, &order) else { return vec![Some(self); ids.len()] };
if !shared.has_item(index) {
return vec![Some(self); ids.len()];
}
let mut per: Vec<Option<ItemTarget>> =
(0..order.len()).map(|j| shared.item_in(index, j).map(|index| ItemTarget::Item { index, explicit, aligned: true })).collect();
per.swap(0, k);
per
let Some(first) = ids.get(k).and_then(|id| st.doc.node(*id)) else { return vec![Some(self); ids.len()] };
ids.iter()
.enumerate()
.map(|(j, id)| match st.doc.node(*id) {
Some(n) if j != k => {
let at = SharedStack::of(&[&*first.appearance, &*n.appearance]).item_in(index, 1);
Some(at.map_or(self, |index| ItemTarget::Item { index, explicit, aligned: true }))
}
_ => Some(self),
})
.collect()
}
/// Where among `ids` (the targets, in paint order) the object whose stack this item is a row of
+12 -12
View File
@@ -8,11 +8,12 @@
use std::sync::Arc;
use serde_json::{Value, json};
use vectorcraft_doc::appearance::SharedStack;
use vectorcraft_doc::{Document, Effect, Node, NodeKind, StrokeLayer};
use vectorcraft_geom::{FillRule, PathData};
use vectorcraft_render::effects;
use super::appearance::{ItemTarget, aligned, appearance_targets, index_param, item_indices, item_target, item_target_at, shared_stack};
use super::appearance::{ItemTarget, aligned, appearance_targets, index_param, item_indices, item_target, item_target_at};
use super::expand::has_seeded;
use super::rasterfx;
use super::{CommandSpec, EngineError, NodeId, Result, Session, always, bad, cmd, fileio, has_doc, str_param};
@@ -105,24 +106,23 @@ fn ids_json(ids: &[NodeId]) -> Value {
/// Each target of an effect edit aimed at `item` and effect `index`: the object, the item whose
/// effects it edits (`None`: the object's own) and the effect's index there. With `aligned` (see
/// [`ItemTarget::per_target`]) `item` and `index` are the first target's, and each other target
/// edits its equal item, or its equal object-level effect; a target without them is left out.
/// edits its equal item, or its equal object-level effect, each target matched on its own whatever
/// the other targets have; in a target without them, `item` and `index` stand for themselves.
pub(crate) fn effect_targets(s: &Session, p: &Value, item: ItemTarget, index: usize, cmd: &str) -> Result<Vec<(NodeId, Option<usize>, usize)>> {
let roots = appearance_targets(s, p)?;
let d = &s.doc()?.doc;
let per = item.per_target(s, &roots);
let shared = if aligned(p) { shared_stack(d, &roots) } else { None };
let first = roots.first().and_then(|f| d.node(*f));
// Whether `item` names an item in the stack it is a row of (else the edit is object-level there).
let anchor = roots.get(item.anchor(s, &roots)).and_then(|a| d.node(*a));
let anchor_level = anchor.map(|a| item.effects_item(&a.appearance, cmd)).transpose()?.flatten();
let mut out = Vec::with_capacity(roots.len());
for (k, (id, item)) in roots.iter().zip(per).enumerate() {
for (id, item) in roots.iter().zip(per) {
let (Some(item), Some(n)) = (item, d.node(*id)) else { continue };
let at = item.effects_item(&n.appearance, cmd)?;
// An object-level effect the targets share is, in each, where it is there.
let first = roots.first().and_then(|f| d.node(*f));
let first_level = first.map(|f| item.effects_item(&f.appearance, cmd)).transpose()?.flatten();
let index = match (&shared, at, first_level) {
(Some(sh), None, None) if sh.has_effect(index) => match sh.effect_in(index, k) {
Some(i) => i,
None => continue,
},
// An object-level effect this target shares with the first is where it is in this target.
let index = match (first.filter(|_| aligned(p)), at, anchor_level) {
(Some(f), None, None) => SharedStack::of(&[&*f.appearance, &*n.appearance]).effect_in(index, 1).unwrap_or(index),
_ => index,
};
out.push((*id, at, index));
+53
View File
@@ -554,6 +554,49 @@ fn add_fill_copies_the_active_row_in_each_object_that_has_it() {
}
}
/// A paint edit of the active row changes the equal row of each object that has one, even when
/// another selected object has none; that object's row stands for itself, as with only it and the
/// first selected object selected.
#[test]
fn paint_edits_change_the_active_row_in_each_object_that_has_it() {
let mut s = session();
let [a, b, c] = [rect(&mut s, 50.0), rect(&mut s, 200.0), rect(&mut s, 300.0)];
run(&mut s, "select.set", json!({"ids": [a.0, b.0, c.0]}));
run(&mut s, "appearance.addFill", json!({}));
run(&mut s, "appearance.setItem", json!({"index": 0, "color": "#00ff00"}));
run(&mut s, "appearance.setItem", json!({"index": 2, "color": "#ff0000"})); // [Fill green, Stroke, Fill red]
// b gets a yellow fill below the others: [Fill yellow, Fill green, Stroke, Fill red].
run(&mut s, "appearance.addFill", json!({"ids": [b.0]}));
run(&mut s, "appearance.moveItem", json!({"ids": [b.0], "from": 3, "to": 0}));
run(&mut s, "appearance.setItem", json!({"ids": [b.0], "index": 0, "color": "#ffff00"}));
run(&mut s, "appearance.setItem", json!({"ids": [c.0], "index": 0, "color": "#0000ff"})); // [Fill blue, Stroke, Fill red]
run(&mut s, "select.set", json!({"ids": [a.0, b.0, c.0]}));
run(&mut s, "appearance.setActiveItem", json!({"index": 0}));
run(&mut s, "paint.setFill", json!({"color": "#ff00ff"}));
let (na, nb, nc) = (node(&s, a), node(&s, b), node(&s, c));
assert_eq!(fill_paint(&na, 0).color().unwrap().to_hex(), "#ff00ff");
assert_eq!(fill_paint(&nb, 1).color().unwrap().to_hex(), "#ff00ff", "{:?}", nb.appearance);
assert_eq!(fill_paint(&nb, 0).color().unwrap().to_hex(), "#ffff00", "{:?}", nb.appearance);
assert_eq!(fill_paint(&nc, 0).color().unwrap().to_hex(), "#ff00ff", "{:?}", nc.appearance);
}
/// An object-level effect is removed from each object that has an equal one, even when another
/// selected object has none.
#[test]
fn removing_an_effect_reaches_each_object_that_has_it() {
let mut s = session();
let [a, b, c] = [rect(&mut s, 50.0), rect(&mut s, 200.0), rect(&mut s, 300.0)];
run(&mut s, "effect.apply", json!({"ids": [a.0], "effect": "stylize.feather", "item": null}));
run(&mut s, "effect.apply", json!({"ids": [a.0, b.0], "effect": "distort.twist", "item": null}));
// a has [feather, twist], b [twist], c none.
run(&mut s, "select.set", json!({"ids": [a.0, b.0, c.0]}));
run(&mut s, "effect.remove", json!({"index": 1, "item": null, "aligned": true}));
let ids = |id: NodeId| node(&s, id).appearance.effects.iter().map(|e| e.id.clone()).collect::<Vec<_>>();
assert_eq!(ids(a), ["stylize.feather"]);
assert!(ids(b).is_empty(), "b loses its twist: {:?}", ids(b));
assert!(ids(c).is_empty());
}
/// b `[Fill red, Fill white, Stroke]` above a `[Fill white, Stroke]`, selected b first: b's rows 1
/// and 2 equal a's rows 0 and 1.
fn reversed_pair(s: &mut Session) -> (NodeId, NodeId) {
@@ -916,6 +959,16 @@ fn shared_effects_and_new_fills_reach_every_selected_object() {
assert!(s.execute("appearance.moveItem", &json!({"from": 1, "to": 0, "aligned": true})).is_err());
}
/// An effect applied to the first object's stroke row goes on the equal stroke of the other
/// object, which sits lower in a longer stack there.
#[test]
fn an_effect_on_a_shared_row_reaches_it_at_another_index() {
let mut s = session();
let (a, b) = mixed_circles(&mut s, true);
run(&mut s, "effect.apply", json!({"effect": "distort.twist", "item": 1, "aligned": true}));
assert_eq!((stroke(&node(&s, a), 1).effects.len(), stroke(&node(&s, b), 2).effects.len()), (1, 1));
}
/// Rows deleted together, one shared (the stroke) and one not (a's fill): each object loses its
/// own copy of the shared row, and the other object keeps the items at those indices of a's stack.
#[test]