Merge pull request #4858 from dotCooCoo/fix/reduce-basic-keeps-selected-fill

Appearance: Reduce to Basic Appearance keeps the selected fill or stroke row
This commit is contained in:
BFlat
2026-10-31 20:12:37 +07:00
committed by GitHub
2 changed files with 86 additions and 4 deletions
+23 -4
View File
@@ -71,7 +71,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Reduce to Basic Appearance",
["Window", "Appearance"],
None,
"{ids?|id?, target?} keep only the topmost visible fill and stroke, without their own opacity, blend mode or effects (a group keeps no rows it lacked), and drop the object's effects",
"{ids?|id?, target?} keep only one visible fill and stroke (without ids: the Appearance panel's active row in place of the topmost one of its kind; else the topmost), without their own opacity, blend mode or effects (a group keeps no rows it lacked), and drop the object's effects",
has_doc,
reduce_basic
),
@@ -614,11 +614,30 @@ fn reduce_basic(s: &mut Session, p: &Value) -> Result<Value> {
let ids = require_targets(s, p, "appearance.reduceToBasic")?;
// When every object comes out as it was, the document and its undo history stay as they are.
let before = s.doc()?.doc.clone();
// The Appearance panel's active row (a row of the first selected object's stack, and in each
// other target the row equal to it there), when the command acts on that selection. The targets
// are in paint order, so the first selected object can be any of them.
let first = s.doc()?.selection.subjects().first().and_then(|f| ids.iter().position(|id| id == f));
let active = match (s.appearance_item(), first) {
(Some(index), Some(k)) if p.get("ids").is_none() && p.get("id").is_none() && !targets_contents(p)? => {
let mut from = ids.clone();
from.swap(0, k);
let mut rows = items_per_target(&before, &from, &[index], true);
rows.swap(0, k);
rows
}
_ => vec![Vec::new(); ids.len()],
};
super::overprint::edit_counted(s, "Reduce to Basic Appearance", |d| {
for id in &ids {
for (id, at) in ids.iter().zip(&active) {
if let Some(n) = d.node_mut(*id) {
// The topmost visible fill and stroke stay, without their own transparency or effects.
let top = |fill: bool| n.appearance.items.iter().rev().find(|i| i.visible() && i.is_fill() == fill).cloned();
// The active row stays when it is a visible fill or stroke; otherwise the topmost
// visible one of each kind does. Both lose their own transparency and effects.
let picked = at.first().and_then(|i| n.appearance.items.get(*i)).filter(|i| i.visible()).cloned();
let top = |fill: bool| match &picked {
Some(i) if i.is_fill() == fill => Some(i.clone()),
_ => n.appearance.items.iter().rev().find(|i| i.visible() && i.is_fill() == fill).cloned(),
};
let (fill, stroke) = (top(true), top(false));
// Other objects always keep a fill row (None when there was none); a group only
// keeps the rows it had.
+63
View File
@@ -400,6 +400,69 @@ fn clear_and_reduce_to_basic() {
assert_eq!(node(&s, id).opacity, 0.4);
}
/// Reduce to Basic Appearance keeps the Appearance panel's active fill or stroke row instead of the
/// topmost one of its kind, in each selected object with the same stack. With `ids` it keeps the
/// topmost ones.
#[test]
fn reduce_to_basic_keeps_the_active_row() {
let mut s = session();
let a = rect(&mut s, 50.0);
let b = rect(&mut s, 200.0);
let c = rect(&mut s, 300.0);
let stack = |s: &mut Session| {
run(s, "appearance.addFill", json!({}));
run(s, "appearance.addStroke", json!({})); // [Fill, Stroke, Fill, Stroke]
run(s, "appearance.setItem", json!({"index": 0, "color": "#00ff00"}));
run(s, "appearance.setItem", json!({"index": 2, "color": "#ff0000"}));
run(s, "appearance.setItem", json!({"index": 1, "weight": 7}));
};
run(&mut s, "select.set", json!({"ids": [a.0, b.0]}));
stack(&mut s);
// The lower, green fill row is active: both objects keep it and their top stroke.
run(&mut s, "appearance.setActiveItem", json!({"index": 0}));
run(&mut s, "appearance.reduceToBasic", json!({}));
for id in [a, b] {
let n = node(&s, id);
assert!(n.appearance.is_basic(), "{:?}", n.appearance);
assert_eq!(fill_paint(&n, 0).color().unwrap().to_hex(), "#00ff00");
assert_eq!(stroke(&n, 1).width, 1.0);
}
// The lower stroke row is active: it stays, with the top fill.
run(&mut s, "edit.undo", json!({}));
run(&mut s, "appearance.setActiveItem", json!({"index": 1}));
run(&mut s, "appearance.reduceToBasic", json!({}));
let n = node(&s, a);
assert_eq!(fill_paint(&n, 0).color().unwrap().to_hex(), "#ff0000");
assert_eq!(stroke(&n, 1).width, 7.0);
// Given ids, the topmost fill and stroke stay.
run(&mut s, "select.set", json!({"ids": [c.0]}));
stack(&mut s);
run(&mut s, "appearance.setActiveItem", json!({"index": 0}));
run(&mut s, "appearance.reduceToBasic", json!({"ids": [c.0]}));
let n = node(&s, c);
assert_eq!(fill_paint(&n, 0).color().unwrap().to_hex(), "#ff0000");
assert_eq!(stroke(&n, 1).width, 1.0);
}
/// The active row is a row of the first selected object even when that object paints above the
/// others; another object without an equal row keeps its topmost fill.
#[test]
fn reduce_to_basic_keeps_the_active_row_of_the_first_selected_object() {
let mut s = session();
let a = rect(&mut s, 50.0);
run(&mut s, "appearance.addFill", json!({}));
run(&mut s, "appearance.setItem", json!({"index": 2, "color": "#0000ff"})); // [Fill, Stroke, Fill blue]
let b = rect(&mut s, 200.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]
run(&mut s, "select.set", json!({"ids": [b.0, a.0]}));
run(&mut s, "appearance.setActiveItem", json!({"index": 0}));
run(&mut s, "appearance.reduceToBasic", json!({}));
assert_eq!(fill_paint(&node(&s, b), 0).color().unwrap().to_hex(), "#00ff00");
assert_eq!(fill_paint(&node(&s, a), 0).color().unwrap().to_hex(), "#0000ff");
}
/// 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();