Merge main into #691

This commit is contained in:
bflatastic
2026-10-17 14:57:55 +07:00
14 changed files with 198 additions and 13 deletions
+58 -4
View File
@@ -26,10 +26,17 @@ pub fn specs() -> Vec<CommandSpec> {
spec!(
"object.convertToFrame",
"Convert to frame",
"{ids?} (groups become frames of the same box in place, their layers staying where they are: no fill, not clipping. Frames are left alone; instances, main components and layers inside instances are refused)",
"{ids?} (groups become frames of the same box in place, their layers staying where they are: no fill, not clipping; sections become frames that keep their fill and don't clip. Frames are left alone; instances, main components, layers inside instances and sections holding sections are refused)",
true,
|s, p| convert(s, p, "object.convertToFrame", true)
),
spec!(
"object.convertToSection",
"Convert to section",
"{ids?} (frames on the page or in a section become sections in place, keeping their fill and layers; what only a frame has goes: auto layout, clipping, layout grids, guides and scrolling. Sections are left alone; nested frames, instances and main components are refused)",
true,
convert_to_section
),
spec!(
"object.wrapInSection",
"Wrap in new section",
@@ -199,6 +206,13 @@ fn convert(s: &mut Session, p: &Value, cmd: &'static str, to_frame: bool) -> Res
let n = doc.get(i)?;
let refuse = |why: &str| Err(bad(cmd, format!("\"{}\" {why}", n.name)));
match (&n.kind, to_frame) {
// A frame can't hold sections.
(NodeKind::Section { .. }, true)
if n.children.iter().any(|c| doc.node(*c).is_some_and(|c| matches!(c.kind, NodeKind::Section { .. }))) =>
{
return refuse("holds sections, which can't go inside a frame: convert those first");
}
(NodeKind::Section { .. }, true) => todo.push(i),
(NodeKind::Instance { .. }, _) => return refuse("is an instance: its layers come from its main component (detach it first)"),
(NodeKind::Component { .. }, _) => return refuse("is a main component, which stays a frame"),
(NodeKind::Frame | NodeKind::Group, _) if crate::components::owning_instance(doc, i).is_some() => {
@@ -212,12 +226,15 @@ fn convert(s: &mut Session, p: &Value, cmd: &'static str, to_frame: bool) -> Res
}
for i in &todo {
if to_frame {
// The group's box as it is; nothing painted or clipped, so the look stays.
// The box as it is and nothing clipped, so the look stays: a group has nothing painted, a
// section keeps its fill.
let n = doc.get_mut(*i)?;
if matches!(n.kind, NodeKind::Group) {
n.fills.clear();
n.strokes.clear();
}
n.kind = NodeKind::Frame;
n.clip = false;
n.fills.clear();
n.strokes.clear();
continue;
}
let n = doc.get_mut(*i)?;
@@ -242,6 +259,43 @@ fn convert(s: &mut Session, p: &Value, cmd: &'static str, to_frame: bool) -> Res
Ok(json!({"ids": todo}))
}
/// Frames on the page or in a section become sections in place (sections only sit there).
fn convert_to_section(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "object.convertToSection";
let ids = req_targets(cmd, s, p)?;
let doc = s.doc_mut()?;
let mut todo = Vec::new();
for i in ids {
let n = doc.get(i)?;
let refuse = |why: &str| Err(bad(cmd, format!("\"{}\" {why}", n.name)));
let on_board = n.parent.and_then(|p| doc.node(p)).is_some_and(|p| p.is_page() || matches!(p.kind, NodeKind::Section { .. }));
match &n.kind {
NodeKind::Section { .. } => {}
NodeKind::Instance { .. } => return refuse("is an instance: its layers come from its main component (detach it first)"),
NodeKind::Component { .. } | NodeKind::ComponentSet { .. } => return refuse("is a main component, which stays a frame"),
NodeKind::Frame if !on_board => return refuse("is inside a frame or group: only frames on the page or in a section become sections"),
NodeKind::Frame => todo.push(i),
_ => return refuse("is not a frame"),
}
}
for i in &todo {
let n = doc.get_mut(*i)?;
n.kind = NodeKind::Section { contents_hidden: false };
(n.layout, n.clip, n.overflow) = (None, false, proto::Overflow::None);
n.grids.clear();
n.guides.clear();
n.styles.grid = None;
n.bound.retain(|k, _| !k.starts_with("padding") && !matches!(k.as_str(), "gap" | "counterAxisSpacing"));
let kids = n.children.clone();
for k in kids {
// Out of the flow there is nothing to fill or to be absolute against.
doc.get_mut(k)?.child_layout.absolute = false;
}
figcraft_layout::coherent_sizing(doc, *i);
}
Ok(json!({"ids": todo}))
}
fn ungroup(s: &mut Session, p: &Value) -> Result<Value> {
let ids = req_targets("object.ungroup", s, p)?;
let doc = s.doc_mut()?;
+49 -3
View File
@@ -1,5 +1,5 @@
//! Frames and groups converted into each other in place: the layers stay where they are on the
//! page, what only a frame has goes with it, what can't convert is refused, one undo step.
//! Frames, groups and sections converted into each other in place: the layers stay where they are on
//! the page, what only a frame has goes with it, what can't convert is refused, one undo step.
use serde_json::{Value, json};
@@ -136,7 +136,7 @@ fn what_cannot_convert_is_refused_and_nothing_changes() {
#[test]
fn junk_parameters_are_refused_not_fatal() {
let junk = [json!(null), json!(true), json!(-1), json!(1e308), json!("x"), json!([[1]]), json!({"a": 1}), json!([0, 999_999, "7"])];
for cmd in ["object.convertToGroup", "object.convertToFrame"] {
for cmd in ["object.convertToGroup", "object.convertToFrame", "object.convertToSection"] {
for j in &junk {
let mut s = Session::new();
let f = id(ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 50, "height": 50})));
@@ -148,3 +148,49 @@ fn junk_parameters_are_refused_not_fatal() {
}
}
}
#[test]
fn a_frame_on_the_page_becomes_a_section_and_back() {
let mut s = Session::new();
let f = id(ex(&mut s, "frame.create", json!({"x": 100, "y": 50, "width": 300, "height": 200, "name": "Flow", "props": {"fill": "#FFEEDD"}})));
let (a, b) = (rect(&mut s, f, 20.0, 30.0), rect(&mut s, f, 400.0, 90.0));
ex(&mut s, "layout.add", json!({"ids": [f], "direction": "horizontal"}));
ex(&mut s, "node.set", json!({"id": f, "props": {"width": 300, "height": 200}}));
let before = [page_box(&s, a), page_box(&s, b), page_box(&s, f)];
assert_eq!(ex(&mut s, "object.convertToSection", json!({"ids": [f]}))["ids"], json!([f]));
let n = s.doc().unwrap().node(f).unwrap().clone();
assert!(matches!(n.kind, NodeKind::Section { contents_hidden: false }));
assert_eq!((n.name.as_str(), n.children.clone(), n.fills.len()), ("Flow", vec![a, b], 1), "same name, layers and fill");
assert!(n.layout.is_none() && !n.clip, "no auto layout or clipping on a section");
assert_eq!([page_box(&s, a), page_box(&s, b), page_box(&s, f)], before, "nothing moves");
// And back: a frame that keeps the fill and doesn't clip, so the look stays.
assert_eq!(ex(&mut s, "object.convertToFrame", json!({"ids": [f]}))["ids"], json!([f]));
let n = s.doc().unwrap().node(f).unwrap().clone();
assert!(matches!(n.kind, NodeKind::Frame) && n.fills.len() == 1 && !n.clip);
assert_eq!([page_box(&s, a), page_box(&s, b), page_box(&s, f)], before);
// Each conversion is one undo step.
ex(&mut s, "edit.undo", json!({}));
assert!(matches!(kind(&s, f), NodeKind::Section { .. }));
ex(&mut s, "edit.undo", json!({}));
assert!(matches!(kind(&s, f), NodeKind::Frame));
assert!(s.doc().unwrap().node(f).unwrap().layout.is_some(), "the frame's auto layout is back");
}
#[test]
fn only_frames_on_the_page_or_in_a_section_become_sections() {
let mut s = Session::new();
let outer = id(ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 300, "height": 300})));
let inner = id(ex(&mut s, "frame.create", json!({"parent": outer, "x": 10, "y": 10, "width": 100, "height": 100})));
let err = s.execute("object.convertToSection", &json!({"ids": [inner]})).unwrap_err().to_string();
assert!(err.contains("inside a frame"), "{err}");
let r = rect(&mut s, outer, 150.0, 150.0);
assert!(s.execute("object.convertToSection", &json!({"ids": [r]})).is_err(), "a rectangle isn't a frame");
// A frame inside a section converts; a section holding a section can't become a frame.
let sec = id(ex(&mut s, "node.create", json!({"type": "section", "x": 500, "y": 0, "width": 400, "height": 400})));
let in_sec = id(ex(&mut s, "frame.create", json!({"parent": sec, "x": 20, "y": 20, "width": 100, "height": 100})));
ex(&mut s, "object.convertToSection", json!({"ids": [in_sec]}));
assert!(matches!(kind(&s, in_sec), NodeKind::Section { .. }));
let err = s.execute("object.convertToFrame", &json!({"ids": [sec]})).unwrap_err().to_string();
assert!(err.contains("holds sections"), "{err}");
assert!(matches!(kind(&s, sec), NodeKind::Section { .. }), "nothing changed");
}
+15
View File
@@ -111,6 +111,10 @@ const DENIED: &[&str] = &[
"edit.redo",
"edit.repeat",
"command.batch",
// Network sessions, online files and their recovery copies on disk.
"collab.",
// Opens files in new windows.
"window.",
"view.theme",
];
/// Read-only exceptions to [`DENIED`].
@@ -128,6 +132,17 @@ fn refusal(m: &Manifest, command: &str, params: &Value) -> Option<String> {
if command == "image.place" && get(params, "path").is_some() {
return Some("plugins can't read files: pass the image as `data`".into());
}
// `path` here is a file on disk (elsewhere it is a vector path or an action's place).
if matches!(command, "variable.export" | "codeConnect.export" | "component.playground") && get(params, "path").is_some() {
return Some("plugins can't write files: leave out `path` to get the result back".into());
}
if command == "codeConnect.import" && get(params, "path").is_some() {
return Some("plugins can't read files: pass the connections as `json`".into());
}
// Variables JSON may be given as a path: only objects and JSON text for plugins.
if command == "variable.import" && get(params, "variables").and_then(Value::as_str).is_some_and(|t| !t.trim_start().starts_with('{')) {
return Some("plugins can't read files: pass the variables JSON itself".into());
}
if !m.permissions.allows(command) {
return Some(format!("`{command}` isn't in the commands {} asked permission for", m.name));
}
+7
View File
@@ -169,6 +169,13 @@ fn files_dialogs_history_and_plugins_are_off_limits() {
("plugin.remove", json!({"id": "org.test.deny"})),
("edit.undo", json!({})),
("command.batch", json!({"commands": [{"command": "file.saveAs", "params": {"path": target}}]})),
("variable.export", json!({"format": "css", "path": target})),
("codeConnect.export", json!({"path": target})),
("codeConnect.import", json!({"path": "/etc/passwd"})),
("component.playground", json!({"id": a, "path": target})),
("variable.import", json!({"variables": "/etc/passwd"})),
("collab.recoverFile", json!({"path": "copy.figcraft", "file": "x", "session": 1, "client": 1, "seq": 0})),
("window.new", json!({"path": "C:/Windows/win.ini"})),
("doc.switch", json!({"index": 0})),
("pref.keepTool", json!({})),
// Without a name it would open inline renaming in the layers panel: a UI request.