mirror of
https://github.com/storytold/designcraft.git
synced 2026-10-31 07:39:17 +00:00
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:
1 parent
20a14d95f0
commit
796e9d713d
4 files changed
+93
-27
No files matched your search
@@ -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| {
|
||||
|
||||
@@ -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, ¶ms)
|
||||
}
|
||||
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(®ion, 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"] {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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]}))];
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user