fix: A selected graphic's Transform values are the graphic's

With a placed graphic selected, transform.set acted on the graphic but
the Control bar and Properties still read the frame's values: after
setting the image of a -45° frame to 0°, a spinner step sent -44, and a
200 pt frame holding a 400 pt image showed W 200, so 201 halved the
image. transform.info now reports the graphic's values at the top level
in that case, and sel_info gives the graphic's bounds.

Also: `id` names the target explicitly, like `ids`, so it isn't taken
as a content selection; and a content selection of locked frames
records no empty undo step.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
moppeeer
2026-10-12 09:42:06 +02:00
co-authored by Claude Opus 5.5
parent 2f21b005f3
commit be4f60ff85
2 changed files with 89 additions and 4 deletions
+51 -3
View File
@@ -1189,15 +1189,32 @@ fn values_json(m: Affine) -> Value {
json!({"scaleX": r(v.scale_x * 100.0), "scaleY": r(v.scale_y * 100.0), "rotation": r(v.rotation), "shear": r(v.shear)})
}
/// Whether `transform.set` / `transform.info` act on the placed graphic in each frame: `content`
/// when given, else whether the selection is content (Direct Selection) when no `ids` or `id` name
/// the targets.
fn content_mode(s: &Session, p: &Value) -> Result<bool> {
if let Some(c) = p.get("content").and_then(Value::as_bool) {
return Ok(c);
}
let explicit = p.get("ids").is_some() || p.get("id").is_some();
Ok(!explicit && s.doc()?.selection.content)
}
fn transform_info(s: &mut Session, p: &Value) -> Result<Value> {
let ids = targets(s, p)?;
let totals = s.prefs.transformations_are_totals;
let content = content_mode(s, p)?;
let d = &s.doc()?.doc;
let id = *ids.first().ok_or_else(|| bad("transform.info", "nothing selected"))?;
let (_, m) = measured_xf(d, id, totals).ok_or_else(|| bad("transform.info", "no such object"))?;
let mut out = values_json(m);
if let Some(g) = d.item(id).and_then(Item::graphic) {
out["content"] = values_json(if totals { m * g.xf } else { g.xf });
let c = values_json(if totals { m * g.xf } else { g.xf });
// With the graphic selected, its values are the ones the Transform fields show and set.
if content {
out = c.clone();
}
out["content"] = c;
}
Ok(out)
}
@@ -1251,8 +1268,7 @@ fn transform_values(s: &mut Session, p: &Value, ids: &[ItemId], rf: usize) -> Re
fn transform_set(s: &mut Session, p: &Value) -> Result<Value> {
let ids = targets(s, p)?;
// Direct Selection of a placed graphic (or `content`): the graphic moves inside its frame.
let content = p.get("content").and_then(Value::as_bool).unwrap_or(p.get("ids").is_none() && s.doc()?.selection.content);
if content {
if content_mode(s, p)? {
return transform_content(s, p, &ids);
}
if ["rotation", "shear", "scaleX", "scaleY"].iter().any(|k| p.get(*k).is_some()) {
@@ -1337,6 +1353,10 @@ fn transform_content(s: &mut Session, p: &Value, ids: &[ItemId]) -> Result<Value
if !ids.iter().any(|i| d.item(*i).is_some_and(|it| it.graphic().is_some())) {
return Err(bad("transform.set", "no placed graphic to transform"));
}
// Only locked frames: nothing to change, and no undo step.
if !ids.iter().any(|i| d.item(*i).is_some_and(|it| it.graphic().is_some() && !it.locked)) {
return Ok(json!({ "changed": 0 }));
}
}
s.edit(|d, _| {
let mut changed = 0;
@@ -2937,6 +2957,34 @@ mod content_transform_tests {
assert!((undone["content"]["rotation"].as_f64().unwrap() + 45.0).abs() < 1e-6, "{undone}");
}
#[test]
fn with_the_graphic_selected_transform_info_reports_the_graphic() {
let (mut s, f) = rotated_frame_with_image();
s.execute("transform.set", &json!({"rotation": 0})).unwrap();
// What the Control bar and Properties read: the graphic's 0, not the frame's -45, so a
// spinner step from it gives 1, not -44.
let info = s.execute("transform.info", &json!({})).unwrap();
assert!(info["rotation"].as_f64().unwrap().abs() < 1e-6, "{info}");
let rot = info["rotation"].as_f64().unwrap() + 1.0;
s.execute("transform.set", &json!({"rotation": rot})).unwrap();
let after = s.execute("transform.info", &json!({"ids": [f]})).unwrap();
assert!((after["content"]["rotation"].as_f64().unwrap() - 1.0).abs() < 1e-6, "{after}");
assert!((after["rotation"].as_f64().unwrap() + 45.0).abs() < 1e-6, "the frame stays: {after}");
// `id` names the target explicitly, like `ids`: the frame.
let by_id = s.execute("transform.info", &json!({"id": f})).unwrap();
assert!((by_id["rotation"].as_f64().unwrap() + 45.0).abs() < 1e-6, "{by_id}");
}
#[test]
fn a_locked_frame_records_no_undo_step() {
let (mut s, f) = rotated_frame_with_image();
s.execute("object.lock", &json!({"ids": [f]})).unwrap();
s.execute("selection.set", &json!({"ids": [f], "content": true})).unwrap();
let undo = s.doc().unwrap().history.undo.len();
assert_eq!(s.execute("transform.set", &json!({"rotation": 0})).unwrap()["changed"], 0);
assert_eq!(s.doc().unwrap().history.undo.len(), undo, "no empty undo step");
}
#[test]
fn a_frame_selection_still_transforms_the_frame_and_an_empty_frame_has_no_content() {
let (mut s, f) = rotated_frame_with_image();
+38 -1
View File
@@ -62,7 +62,15 @@ pub fn sel_info(app: &DesignApp) -> Option<SelInfo> {
let mut b: Option<Rect> = None;
for id in &st.selection.items {
let it = d.item(*id)?;
let ib = if with_stroke { it.visible_bounds() } else { it.bounds() };
// Direct Selection of a placed graphic: its bounds, as the Transform fields then set.
let ib = match it.graphic() {
Some(g) if st.selection.content => {
let full = d.find(*id).map_or(designcraft_geom::Affine::IDENTITY, |l| d.parent_xf(&l));
(full * it.xf * g.xf).transform_rect_bbox(Rect::new(0.0, 0.0, g.size.0, g.size.1))
}
_ if with_stroke => it.visible_bounds(),
_ => it.bounds(),
};
b = Some(b.map_or(ib, |r| r.union(ib)));
}
let b = b?;
@@ -698,3 +706,32 @@ mod font_menu_ui_tests {
}
}
}
#[cfg(test)]
mod content_selection_tests {
use serde_json::json;
/// With a placed graphic selected (Direct Selection), X/Y/W/H are the graphic's: what the
/// Transform fields then set.
#[test]
fn a_selected_graphic_shows_its_own_bounds() {
let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
app.run("file.new", json!({})).unwrap();
let f = app.run("frame.create", json!({"rect": [100, 100, 300, 300], "content": "graphic"})).unwrap()["id"].clone();
let png = designcraft_render::Rendered { width: 400, height: 400, pixels: vec![200u8; 400 * 400 * 4] }.to_png();
let b64 = designcraft_engine::cmd::base64_encode(&png);
app.run("file.place", json!({"base64": b64, "name": "a.png", "frame": f})).unwrap();
app.run("selection.set", json!({"ids": [f]})).unwrap();
let frame = super::sel_info(&app).unwrap().page_rect;
assert!((frame.width() - 200.0).abs() < 1e-6, "{frame:?}");
app.run("selection.set", json!({"ids": [f], "content": true})).unwrap();
// Placing fitted it to 50%: back to its own 400 pt.
app.run("transform.set", json!({"scaleX": 100, "scaleY": 100})).unwrap();
let g = super::sel_info(&app).unwrap().page_rect;
assert!((g.width() - 400.0).abs() < 1e-6, "the graphic's width, not the frame's: {g:?}");
// So a width typed from it scales the graphic by that ratio, not by half.
app.run("transform.set", json!({"width": 401})).unwrap();
let after = super::sel_info(&app).unwrap().page_rect;
assert!((after.width() - 401.0).abs() < 1e-6, "{after:?}");
}
}