mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-28 04:16:46 -06:00
M1: sections and frames: resize to fit, section colours, ready-for-dev status (#23)
- Object ▸ Resize to fit (Alt+Shift+Cmd+R, `object.resizeToFit`): frames, sections and components shrink or grow to their visible contents without moving them on the page; auto layout frames switch to hug instead. - Sections get colour swatches (light tints) in the Design panel. - Frames and sections get a handoff status (`devStatus`: ready for dev / completed) set from the panel and shown as a badge next to their name on the canvas; `node.get` reports it. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -113,6 +113,7 @@ pub static MENU: &[MenuItem] = &[
|
||||
m!("Object", "Group selection", "object.group", "Cmd+G"),
|
||||
m!("Object", "Ungroup selection", "object.ungroup", "Shift+Cmd+G"),
|
||||
m!("Object", "Remove frame", "object.removeFrame", ""),
|
||||
m!("Object", "Resize to fit", "object.resizeToFit", "Alt+Shift+Cmd+R"),
|
||||
m!("Object", SEP, "", ""),
|
||||
m!("Object", "Add auto layout", "layout.add", "Shift+A"),
|
||||
m!("Object", "Remove auto layout", "layout.remove", "Alt+Shift+A"),
|
||||
|
||||
@@ -289,6 +289,14 @@ pub fn apply(cmd: &str, doc: &mut Document, id: NodeId, props: &Map<String, Valu
|
||||
"effects" => doc.get_mut(id)?.effects = serde_json::from_value(snake(v)).map_err(|e| bad(cmd, format!("effects: {e}")))?,
|
||||
"clip" | "clipsContent" => doc.get_mut(id)?.clip = v.as_bool().ok_or_else(|| bad(cmd, "`clip` must be a boolean"))?,
|
||||
"isMask" => doc.get_mut(id)?.is_mask = v.as_bool().unwrap_or(false),
|
||||
"devStatus" => {
|
||||
let st = if v.is_null() {
|
||||
Some(DevStatus::None)
|
||||
} else {
|
||||
v.as_str().and_then(|s| DevStatus::ALL.into_iter().find(|d| d.id().eq_ignore_ascii_case(s)))
|
||||
};
|
||||
doc.get_mut(id)?.dev_status = st.ok_or_else(|| bad(cmd, "devStatus: none|readyForDev|completed"))?;
|
||||
}
|
||||
"maskType" => {
|
||||
let t = v.as_str().and_then(|s| MaskType::ALL.into_iter().find(|m| m.id().eq_ignore_ascii_case(s)));
|
||||
doc.get_mut(id)?.mask_type = t.ok_or_else(|| bad(cmd, "maskType: alpha|vector|luminance"))?;
|
||||
|
||||
@@ -16,6 +16,13 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
spec!("object.ungroup", "Ungroup selection", "{ids?}", true, ungroup),
|
||||
spec!("object.frameSelection", "Frame selection", "{ids?, name?}", true, |s, p| wrap(s, p, Wrap::Frame)),
|
||||
spec!("object.removeFrame", "Remove frame", "{ids?}", true, ungroup),
|
||||
spec!(
|
||||
"object.resizeToFit",
|
||||
"Resize to fit",
|
||||
"{ids?} (frames, sections and components shrink or grow to their contents; the contents stay put)",
|
||||
true,
|
||||
resize_to_fit
|
||||
),
|
||||
spec!("object.mask", "Use as mask", "{ids?}", true, mask),
|
||||
spec!("object.releaseMask", "Release mask", "{ids?}", true, |s, p| {
|
||||
for i in req_targets("object.releaseMask", s, p)? {
|
||||
@@ -477,3 +484,50 @@ fn join(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
flatten(s, &json!({"ids": ids}))
|
||||
}
|
||||
|
||||
/// Fit containers to their visible children's bounds, keeping the children where they are on the
|
||||
/// page (auto layout frames hug instead); rotated containers fit in their own axes.
|
||||
fn resize_to_fit(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "object.resizeToFit";
|
||||
let ids = req_targets(cmd, s, p)?;
|
||||
let doc = s.doc_mut()?;
|
||||
let mut fitted = Vec::new();
|
||||
for id in ids {
|
||||
let Some(n) = doc.node(id).cloned() else { continue };
|
||||
let container = n.kind.is_frame_like() || matches!(n.kind, NodeKind::Section { .. });
|
||||
if !container || n.children.is_empty() || matches!(n.kind, NodeKind::Instance { .. }) {
|
||||
continue;
|
||||
}
|
||||
// Auto layout frames fit by hugging their contents.
|
||||
if n.layout.is_some() {
|
||||
let node = doc.get_mut(id)?;
|
||||
node.child_layout.sizing_h = Sizing::Hug;
|
||||
node.child_layout.sizing_v = Sizing::Hug;
|
||||
fitted.push(id);
|
||||
continue;
|
||||
}
|
||||
// Children's bounds in the container's own coordinates.
|
||||
let mut b: Option<figcraft_geom::Rect> = None;
|
||||
for c in n.children.iter().filter_map(|c| doc.node(*c)).filter(|c| c.visible) {
|
||||
let r = figcraft_geom::transform_rect_bbox(c.transform, c.local_rect());
|
||||
if r.x0.is_finite() && r.y0.is_finite() && r.x1.is_finite() && r.y1.is_finite() {
|
||||
b = Some(b.map_or(r, |u| u.union(r)));
|
||||
}
|
||||
}
|
||||
let Some(b) = b else { continue };
|
||||
let (w, h) = (b.width().max(0.01), b.height().max(0.01));
|
||||
// Move the container by the offset, and its children back by it.
|
||||
let shift = Affine::translate((b.x0, b.y0));
|
||||
let node = doc.get_mut(id)?;
|
||||
node.transform *= shift;
|
||||
node.width = w;
|
||||
node.height = h;
|
||||
for c in &n.children {
|
||||
if let Ok(cn) = doc.get_mut(*c) {
|
||||
cn.transform = shift.inverse() * cn.transform;
|
||||
}
|
||||
}
|
||||
fitted.push(id);
|
||||
}
|
||||
Ok(json!({"ids": fitted}))
|
||||
}
|
||||
|
||||
@@ -101,6 +101,9 @@ pub fn node_json(doc: &Document, id: NodeId, depth: usize) -> Value {
|
||||
if n.kind.is_frame_like() {
|
||||
o.insert("clip".into(), json!(n.clip));
|
||||
}
|
||||
if !n.dev_status.is_none() {
|
||||
o.insert("devStatus".into(), json!(n.dev_status.id()));
|
||||
}
|
||||
if n.is_mask {
|
||||
o.insert("isMask".into(), json!(true));
|
||||
o.insert("maskType".into(), json!(n.mask_type.id()));
|
||||
|
||||
@@ -756,6 +756,8 @@ mod frame_tests;
|
||||
#[cfg(test)]
|
||||
mod mask_tests;
|
||||
#[cfg(test)]
|
||||
mod section_tests;
|
||||
#[cfg(test)]
|
||||
mod style_tests;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
//! Resize to fit and handoff status.
|
||||
|
||||
use serde_json::json;
|
||||
|
||||
use crate::Session;
|
||||
use crate::params::id_of;
|
||||
|
||||
fn id(v: serde_json::Value) -> figcraft_doc::NodeId {
|
||||
id_of(&v["id"]).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resize_to_fit_keeps_contents_in_place() {
|
||||
let mut s = Session::new();
|
||||
let f = id(s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 400, "height": 300})).unwrap());
|
||||
let a = id(s.execute("shape.rectangle", &json!({"x": 50, "y": 40, "width": 100, "height": 50, "parent": f})).unwrap());
|
||||
let b = id(s.execute("shape.ellipse", &json!({"x": 120, "y": 100, "width": 30, "height": 30, "parent": f})).unwrap());
|
||||
let before = (s.doc().unwrap().world_bounds(a).unwrap(), s.doc().unwrap().world_bounds(b).unwrap());
|
||||
s.execute("object.resizeToFit", &json!({"ids": [f]})).unwrap();
|
||||
let doc = s.doc().unwrap();
|
||||
let fb = doc.world_bounds(f).unwrap();
|
||||
assert_eq!((fb.x0, fb.y0, fb.width(), fb.height()), (50.0, 40.0, 100.0, 90.0));
|
||||
assert_eq!((doc.world_bounds(a).unwrap(), doc.world_bounds(b).unwrap()), before, "contents don't move on the page");
|
||||
s.undo().unwrap();
|
||||
assert_eq!(s.doc().unwrap().world_bounds(f).unwrap().width(), 400.0);
|
||||
// Sections too; empty containers and plain shapes are left alone.
|
||||
let sec = id(s.execute("node.create", &json!({"type": "section", "x": 600, "y": 0, "width": 500, "height": 500})).unwrap());
|
||||
s.execute("node.reparent", &json!({"ids": [f], "parent": sec})).unwrap();
|
||||
let empty = id(s.execute("frame.create", &json!({"x": 0, "y": 600, "width": 80, "height": 80})).unwrap());
|
||||
let r = s.execute("object.resizeToFit", &json!({"ids": [sec, empty, a]})).unwrap();
|
||||
assert_eq!(r["ids"].as_array().unwrap().len(), 1);
|
||||
assert_eq!(s.doc().unwrap().world_bounds(sec).unwrap().width(), 400.0, "section wraps the frame");
|
||||
// Auto layout frames hug instead.
|
||||
s.execute("layout.add", &json!({"ids": [f]})).unwrap();
|
||||
s.execute("node.set", &json!({"id": f, "props": {"width": 600}})).unwrap();
|
||||
s.execute("object.resizeToFit", &json!({"ids": [f]})).unwrap();
|
||||
let n = s.doc().unwrap().node(f).unwrap().clone();
|
||||
assert_eq!(n.child_layout.sizing_h, figcraft_doc::Sizing::Hug);
|
||||
assert!(n.width < 600.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dev_status_prop() {
|
||||
let mut s = Session::new();
|
||||
let f = id(s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 40, "height": 40})).unwrap());
|
||||
s.execute("node.set", &json!({"id": f, "props": {"devStatus": "readyForDev"}})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().node(f).unwrap().dev_status, figcraft_doc::DevStatus::ReadyForDev);
|
||||
assert_eq!(s.execute("node.get", &json!({"id": f})).unwrap()["devStatus"], json!("readyForDev"));
|
||||
s.execute("node.set", &json!({"id": f, "props": {"devStatus": null}})).unwrap();
|
||||
assert!(s.doc().unwrap().node(f).unwrap().dev_status.is_none());
|
||||
assert!(s.execute("node.set", &json!({"id": f, "props": {"devStatus": "shipped"}})).is_err());
|
||||
}
|
||||
Reference in New Issue
Block a user