diff --git a/crates/engine/src/cmd/appearance.rs b/crates/engine/src/cmd/appearance.rs index 3da1cb56c..33379e752 100644 --- a/crates/engine/src/cmd/appearance.rs +++ b/crates/engine/src/cmd/appearance.rs @@ -27,7 +27,7 @@ use vectorcraft_doc::appearance::SharedStack; use vectorcraft_doc::{Appearance, AppearanceItem, CharStyle, FillLayer, Node, NodeKind, ParaStyle, StrokeLayer}; use vectorcraft_geom::Rect; -use super::edit::{outermost, selected_roots}; +use super::edit::{outermost, roots_of, selected_roots}; use super::opacitymask::percent; use super::paint::paint_from; use super::{AppearanceAttrs, EyedropperAttrs, FillAttrs, StrokeAttrs}; @@ -255,17 +255,36 @@ impl ItemTarget { if fits == Some(true) { self } else { ItemTarget::Top } } - /// This target for each of `ids` in `d` (in their order): an aligned item (a row of the first - /// target's stack) is, in each other target, the item equal to it there (see + /// 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. - pub(crate) fn per_target(self, d: &vectorcraft_doc::Document, ids: &[NodeId]) -> Vec> { + pub(crate) fn per_target(self, s: &Session, ids: &[NodeId]) -> Vec> { let ItemTarget::Item { index, explicit, aligned: true } = self else { return vec![Some(self); ids.len()] }; - let Some(shared) = shared_stack(d, ids) 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()]; } - (0..ids.len()).map(|k| shared.item_in(index, k).map(|index| ItemTarget::Item { index, explicit, aligned: true })).collect() + let mut per: Vec> = + (0..order.len()).map(|j| shared.item_in(index, j).map(|index| ItemTarget::Item { index, explicit, aligned: true })).collect(); + per.swap(0, k); + per + } + + /// Where among `ids` (the targets, in paint order) the object whose stack this item is a row of + /// is: the first selected object (or the compound path holding it) for the active item, which + /// need not paint first; the first target otherwise, or when that object is not among them. + pub(crate) fn anchor(self, s: &Session, ids: &[NodeId]) -> usize { + let ItemTarget::Item { explicit: false, .. } = self else { return 0 }; + let Some(st) = s.active() else { return 0 }; + let Some(first) = st.selection.subjects().first() else { return 0 }; + let root = roots_of(&st.doc, vec![*first]).first().copied().unwrap_or(*first); + ids.iter().position(|id| *id == root).unwrap_or(0) } /// The item of `ap` a fill (`fill`) or stroke edit changes; `None` = the topmost one. @@ -407,7 +426,7 @@ pub(crate) fn edit_items_then( return Ok(()); } let before = s.doc()?.doc.clone(); - let per = item.per_target(&before, ids); + let per = item.per_target(s, ids); super::overprint::edit_counted(s, label, |d| { let mut edited = Vec::new(); for (id, item) in ids.iter().zip(&per) { @@ -429,7 +448,7 @@ pub(crate) fn count_changed(d: &vectorcraft_doc::Document, before: &vectorcraft_ } /// Whether an edit aimed at `item` changes a stroke: the `stroke` param, else the kind of the -/// targeted item (on the first target object), else `default`. A `stroke` other than true, false or +/// targeted item (on the object whose row it is, see [`ItemTarget::anchor`]), else `default`. A `stroke` other than true, false or /// null is an error of command `cmd`. pub(crate) fn edits_stroke(s: &Session, p: &Value, item: ItemTarget, default: bool, cmd: &str) -> Result { check_switches(p, &["stroke"], cmd)?; @@ -439,7 +458,7 @@ pub(crate) fn edits_stroke(s: &Session, p: &Value, item: ItemTarget, default: bo let ItemTarget::Item { index, .. } = item else { return Ok(default) }; let ids = appearance_targets(s, p)?; let d = &s.doc()?.doc; - Ok(ids.first().and_then(|id| d.node(*id)?.appearance.items.get(index)).map_or(default, |it| !it.is_fill())) + Ok(ids.get(item.anchor(s, &ids)).and_then(|id| d.node(*id)?.appearance.items.get(index)).map_or(default, |it| !it.is_fill())) } /// The whole number of 0 or more that `key` holds, read with [`index`]. diff --git a/crates/engine/src/cmd/effectcmd.rs b/crates/engine/src/cmd/effectcmd.rs index ec659abe8..25a5ff004 100644 --- a/crates/engine/src/cmd/effectcmd.rs +++ b/crates/engine/src/cmd/effectcmd.rs @@ -108,7 +108,7 @@ fn ids_json(ids: &[NodeId]) -> Value { pub(crate) fn effect_targets(s: &Session, p: &Value, item: ItemTarget, index: usize, cmd: &str) -> Result, usize)>> { let roots = appearance_targets(s, p)?; let d = &s.doc()?.doc; - let per = item.per_target(d, &roots); + let per = item.per_target(s, &roots); let shared = if aligned(p) { shared_stack(d, &roots) } else { None }; let mut out = Vec::with_capacity(roots.len()); for (k, (id, item)) in roots.iter().zip(per).enumerate() { @@ -219,8 +219,9 @@ pub(crate) fn apply(s: &mut Session, p: &Value) -> Result { index = fx.len() - 1; true })?; - // The item the effect landed on in the first object (the active item applies where it fits). - let first = ids.first().and_then(|id| s.doc().ok()?.doc.node(*id)); + // The item the effect landed on in the object whose row the item is (the first selected object + // for the active item, which applies where it fits). + let first = ids.get(item.anchor(s, &ids)).and_then(|id| s.doc().ok()?.doc.node(*id)); let landed = first.and_then(|n| item.effects_item(&n.appearance, C).ok().flatten()); Ok(json!({ "ids": ids_json(&ids), "index": index, "item": landed })) } diff --git a/crates/engine/src/tests/appearance.rs b/crates/engine/src/tests/appearance.rs index 3a56ff7f1..e36871188 100644 --- a/crates/engine/src/tests/appearance.rs +++ b/crates/engine/src/tests/appearance.rs @@ -463,6 +463,56 @@ fn reduce_to_basic_keeps_the_active_row_of_the_first_selected_object() { assert_eq!(fill_paint(&node(&s, a), 0).color().unwrap().to_hex(), "#0000ff"); } +/// 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) { + let a = rect(s, 50.0); + let b = rect(s, 200.0); + run(s, "appearance.addFill", json!({})); + run(s, "appearance.moveItem", json!({"from": 2, "to": 0})); + run(s, "appearance.setItem", json!({"index": 0, "color": "#ff0000"})); + run(s, "select.set", json!({"ids": [b.0, a.0]})); + (a, b) +} + +/// The active row is a row of the first selected object also for paint, stroke and effect edits +/// when that object paints above the others. +#[test] +fn edits_change_the_active_row_of_the_first_selected_object() { + let mut s = session(); + let (a, b) = reversed_pair(&mut s); + run(&mut s, "appearance.setActiveItem", json!({"index": 0})); // b's red fill + run(&mut s, "paint.setFill", json!({"color": "#00ff00"})); + let (na, nb) = (node(&s, a), node(&s, b)); + assert_eq!(fill_paint(&nb, 0).color().unwrap().to_hex(), "#00ff00"); + assert_eq!(*fill_paint(&nb, 1), Paint::solid(Color::WHITE)); + // a shares no red row, so its fill row 0 changes, as for an active row the objects don't share. + assert_eq!(fill_paint(&na, 0).color().unwrap().to_hex(), "#00ff00"); + run(&mut s, "effect.apply", json!({"effect": "distort.twist"})); + let nb = node(&s, b); + assert_eq!((nb.appearance.items[0].effects().len(), nb.appearance.items[1].effects().len()), (1, 0)); + + let mut s = session(); + let (a, b) = reversed_pair(&mut s); + run(&mut s, "appearance.setActiveItem", json!({"index": 1})); // b's white fill, a's row 0 + run(&mut s, "paint.editGradient", json!({"kind": "radial"})); + let (na, nb) = (node(&s, a), node(&s, b)); + assert!(matches!(fill_paint(&nb, 1), Paint::Gradient(_)), "b's white fill takes the gradient"); + assert!(matches!(fill_paint(&na, 0), Paint::Gradient(_)), "so does a's equal fill"); + assert_eq!(fill_paint(&nb, 0).color().unwrap().to_hex(), "#ff0000"); + assert!(!matches!(stroke(&nb, 2).paint, Paint::Gradient(_)) && !matches!(stroke(&na, 1).paint, Paint::Gradient(_))); + // The effect goes on the equal rows, and the reply names the active row of b. + run(&mut s, "appearance.setActiveItem", json!({"index": 2})); // b's stroke, a's row 1 + let r = run(&mut s, "effect.apply", json!({"effect": "distort.twist"})); + assert_eq!(r["item"], 2); + let (na, nb) = (node(&s, a), node(&s, b)); + assert_eq!((stroke(&nb, 2).effects.len(), stroke(&na, 1).effects.len()), (1, 1)); + // With nothing selected an aligned item is an error, not a crash. + run(&mut s, "select.set", json!({"ids": []})); + let e = s.execute("effect.remove", &json!({"index": 0, "item": 0, "aligned": true})).unwrap_err().to_string(); + assert!(!e.contains("internal error"), "{e}"); +} + /// How many steps there are to undo and to redo, and whether the document has unsaved changes. fn history(s: &Session) -> (usize, usize, bool) { let st = s.doc().unwrap();