Integration: the Eyedropper matches the clicked frame where no image pixel shows; sampling skips clipped-out pixels and hidden states (#572, #561)

color.sample takes `matchFrom`: with no opaque image pixel under the point (an empty part of an image frame, a transparent pixel), it applies that object's attributes as object.matchAttributes does, in the same command; the tool passes the object under the pointer. sample_item and the tool walk shown_children(), so only a multi-state object's active state is sampled, and sample_item skips points outside the graphic's clipping path. object.matchAttributes's body is a function both commands share.
This commit is contained in:
aiongg committed 2026-10-12 18:47:51 +00:00
1 parent 20a14d95f0
commit 796e9d713d
4 files changed
+93 -27

No files matched your search

+23 -20
View File
@@ -105,26 +105,7 @@ pub fn specs() -> Vec<CommandSpec> {
None,
"{from: id, ids?} — copy fill, stroke, corners, opacity, effects, wrap",
has_selection,
|s, p| {
let from = super::id_param(p, "from").ok_or_else(|| bad("object.matchAttributes", "missing from"))?;
let src = s.doc()?.doc.item(from).cloned().ok_or(designcraft_doc::DocError::NoItem(from))?;
set_flag(
s,
p,
move |i| {
if i.id == src.id {
return;
}
i.fill = src.fill.clone();
i.stroke = src.stroke.clone();
i.corners = src.corners;
i.opacity = src.opacity;
i.blend = src.blend;
i.effects = src.effects.clone();
},
false,
)
}
match_attributes
),
cmd!("transform.flip", "Flip", ["Object", "Transform"], None, "{axis: horizontal|vertical, ids?}", has_selection, |s, p| {
let h = str_param(p, "axis") != Some("vertical");
@@ -1876,6 +1857,28 @@ fn corner_options(s: &mut Session, p: &Value) -> Result<Value> {
set_flag(s, p, move |i| i.corners = opts, false)
}
/// `object.matchAttributes`: the selected objects (or `ids`) take `from`'s appearance.
pub(super) fn match_attributes(s: &mut Session, p: &Value) -> Result<Value> {
let from = super::id_param(p, "from").ok_or_else(|| bad("object.matchAttributes", "missing from"))?;
let src = s.doc()?.doc.item(from).cloned().ok_or(designcraft_doc::DocError::NoItem(from))?;
set_flag(
s,
p,
move |i| {
if i.id == src.id {
return;
}
i.fill = src.fill.clone();
i.stroke = src.stroke.clone();
i.corners = src.corners;
i.opacity = src.opacity;
i.blend = src.blend;
i.effects = src.effects.clone();
},
false,
)
}
fn set_flag(s: &mut Session, p: &Value, f: impl Fn(&mut Item), deselect: bool) -> Result<Value> {
let ids = targets(s, p)?;
s.edit(|d, sel| {
+54 -3
View File
@@ -190,7 +190,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Sample Color (Eyedropper)",
[],
None,
"{at: [x,y] (spread coords), spread?, target?: fill|stroke, ids?} → {color: \"#rrggbb\", swatch, from: image id} — the Eyedropper on a placed image: the image's colour at the point (an unnamed colour) fills the selected text, else the selected objects",
"{at: [x,y] (spread coords), spread?, target?: fill|stroke, ids?, matchFrom?: id} → {color: \"#rrggbb\", swatch, from: image id} — the Eyedropper on a placed image: the image's colour at the point (an unnamed colour) fills the selected text, else the selected objects. With no opaque image pixel there, `matchFrom` gives the object whose attributes apply instead (object.matchAttributes)",
|s| super::has_text(s).or_else(|_| super::has_selection(s)),
sample_color
),
@@ -1141,7 +1141,18 @@ fn sample_color(s: &mut Session, p: &Value) -> Result<Value> {
let at = super::point_param(p, "at").ok_or_else(|| bad(ID, "missing at"))?;
let sr = super::spread_param(p, "spread");
let st = s.doc()?;
let (from, color) = image_color_at(&st.doc, sr, at).ok_or_else(|| bad(ID, "no placed image under the point"))?;
let Some((from, color)) = image_color_at(&st.doc, sr, at) else {
// No opaque image pixel there (an empty part of an image frame, a transparent pixel): the
// Eyedropper applies the clicked object's attributes instead.
return match p.get("matchFrom") {
Some(from) => {
let mut params = p.clone();
params["from"] = from.clone();
super::object::match_attributes(s, &params)
}
None => Err(bad(ID, "no placed image under the point")),
};
};
let hex = color.to_hex();
let mut params = p.clone();
params["color"] = json!(hex);
@@ -1182,12 +1193,23 @@ fn sample_item(
}
let xf = outer * it.xf;
match &it.content {
Content::Group { items } => items.iter().rev().find_map(|c| sample_item(d, c, xf, at, depth + 1)),
// Only the active state of a multi-state object shows.
Content::Group { .. } => {
let shown: Vec<_> = it.shown_children().collect();
shown.into_iter().rev().find_map(|c| sample_item(d, c, xf, at, depth + 1))
}
Content::Graphic(g) => {
let inner = xf.inverse() * at;
if !designcraft_geom::hit::fill_contains(&it.path.to_bezpath(), designcraft_geom::FillRule::NonZero, inner) {
return None;
}
// A clipping path hides the image outside it.
if let Some((region, even_odd)) = g.clip_region() {
let rule = if even_odd { designcraft_geom::FillRule::EvenOdd } else { designcraft_geom::FillRule::NonZero };
if !designcraft_geom::hit::fill_contains(&region, rule, inner) {
return None;
}
}
let gp = g.xf.inverse() * inner;
let (w, h) = g.size;
// Written so that NaN (a degenerate transform) is outside too.
@@ -2317,6 +2339,35 @@ mod sample_color_tests {
assert_eq!(s.doc().unwrap().history.undo.len(), undo);
}
#[test]
fn where_no_image_pixel_shows_the_clicked_frame_is_matched_instead() {
let (mut s, image) = session_with_image();
let rect = s.execute("frame.create", &json!({"rect": [300, 300, 400, 400]})).unwrap()["id"].as_u64().unwrap();
s.execute("object.fill", &json!({"swatch": "[Black]", "ids": [image]})).unwrap();
// The frame reaches 72 pt below its image, and a clipping path shows the left (red) pixel only.
s.edit(|d, _| {
let it = d.item_mut(ItemId(image)).ok_or(crate::EngineError::NoDocument)?;
let b = it.inner_bounds();
it.path = designcraft_geom::shapes::rectangle(designcraft_geom::Rect::new(b.x0, b.y0, b.x1, b.y1 + 72.0));
let designcraft_doc::Content::Graphic(g) = &mut it.content else { return Err(crate::EngineError::NoDocument) };
let left = designcraft_geom::shapes::rectangle(designcraft_geom::Rect::new(0.0, 0.0, g.size.0 / 2.0, g.size.1));
g.clip = Some(std::sync::Arc::new(designcraft_doc::ClippingPath::new(designcraft_doc::ClippingKind::PhotoshopPath, left)));
Ok(())
})
.unwrap();
s.execute("selection.set", &json!({"ids": [rect]})).unwrap();
let r = s.execute("color.sample", &json!({"at": [100, 100], "matchFrom": image})).unwrap();
assert_eq!(r["color"], "#ff0000", "inside the clipping path the image is sampled");
// The clipped-out blue pixel and the empty frame below the image: nothing to sample, so the
// frame's attributes apply.
for at in [[180, 100], [100, 180]] {
assert!(s.execute("color.sample", &json!({"at": at})).is_err(), "{at:?}");
s.execute("object.fill", &json!({"swatch": "[Paper]", "ids": [rect]})).unwrap();
s.execute("color.sample", &json!({"at": at, "matchFrom": image})).unwrap();
assert_eq!(s.doc().unwrap().doc.item(ItemId(rect)).unwrap().fill.swatch, "[Black]", "{at:?}");
}
}
#[test]
fn sampling_and_explicit_colour_use_the_same_text_application_path() {
for command in ["color.sample", "object.color"] {
+4
View File
@@ -1441,6 +1441,10 @@ fn eyedropper_samples_a_placed_image_and_matches_other_objects() {
let cx = ctx(&d2, &s, &c, &l);
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 320.0 + off.x, 120.0 + off.y));
assert_eq!(a, vec![Action::Exec("object.matchAttributes".into(), serde_json::json!({"from": swatch.0}))]);
// An image the pointer can reach names its frame, whose attributes apply where no pixel shows.
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 120.0 + off.x, 130.0 + off.y));
let sampled = serde_json::json!({"at": [120.0, 130.0], "spread": {"kind": "doc", "index": 0}, "matchFrom": photo.0});
assert_eq!(a, vec![Action::Exec("color.sample".into(), sampled)]);
// Vector graphics and missing assets must retain the ordinary attribute-matching path.
for mime in ["application/pdf", "application/postscript", "image/svg+xml", "application/octet-stream"] {
std::sync::Arc::make_mut(d2.assets.get_mut(&designcraft_doc::AssetId(1)).unwrap()).mime = mime.into();
+12 -4
View File
@@ -257,8 +257,10 @@ fn image_under(cx: &ToolContext, p: Point) -> Option<(SpreadRef, Point)> {
return None;
}
let xf = outer * it.xf;
if let Content::Group { items } = &it.content {
return items.iter().rev().find_map(|c| shows(d, c, xf, at, depth + 1));
if let Content::Group { .. } = &it.content {
// Only the active state of a multi-state object shows.
let shown: Vec<_> = it.shown_children().collect();
return shown.into_iter().rev().find_map(|c| shows(d, c, xf, at, depth + 1));
}
let graphic = matches!(it.content, Content::Graphic(_));
// Unfilled frames (a text frame over the photo) let the image show through.
@@ -297,11 +299,17 @@ impl Tool for EyedropperTool {
return vec![];
}
let selected = !cx.selection.items.is_empty() || cx.selection.text.is_some() || cx.selection.cells.is_some();
let hit = cx.hit(ev.pos).map(|(_, id)| id);
// A placed image under the pointer is sampled (its layer may be locked: sampling changes nothing).
if selected && let Some((sr, at)) = image_under(cx, ev.pos) {
return vec![Action::Exec("color.sample".into(), json!({"at": [at.x, at.y], "spread": sr}))];
// Where the image shows no opaque pixel, the engine matches the clicked object instead.
let mut p = json!({"at": [at.x, at.y], "spread": sr});
if let Some(id) = hit {
p["matchFrom"] = json!(id.0);
}
return vec![Action::Exec("color.sample".into(), p)];
}
let Some((_, id)) = cx.hit(ev.pos) else { return vec![] };
let Some(id) = hit else { return vec![] };
if !selected {
return vec![Action::Exec("selection.set".into(), json!({"ids": [id.0]}))];
}