mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-27 03:46:50 -06:00
Merge main into #691
This commit is contained in:
@@ -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()?;
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user