mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-29 04:46:50 -06:00
Merge main into #628
This commit is contained in:
@@ -520,7 +520,6 @@ fn load_with(
|
||||
if loaded.doc.name.is_empty() {
|
||||
loaded.doc.name = file_stem(name_hint);
|
||||
}
|
||||
super::styles::apply_paint_bindings_on_load(&mut loaded.doc);
|
||||
Ok((loaded, false))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -53,7 +53,10 @@ fn initial_color_bindings_settle_in_constructor_and_loaders() {
|
||||
|
||||
let native = figcraft_format::save_bytes(&stale, None).unwrap();
|
||||
let (loaded, _) = crate::cmd::file::load_bytes(&native, "bindings.figcraft").unwrap();
|
||||
let loaded_id = assert_dark_bound_color(&loaded, name_transform);
|
||||
// A native file settles when it opens as a document, not in the loader.
|
||||
let opened = Session::with_document(loaded);
|
||||
let loaded = opened.doc().unwrap();
|
||||
let loaded_id = assert_dark_bound_color(loaded, name_transform);
|
||||
assert_eq!(loaded_id, id);
|
||||
assert_eq!(loaded.node(id).unwrap().source, stale.first_page());
|
||||
assert!(loaded.node(id).unwrap().overrides.contains("sentinel"));
|
||||
|
||||
@@ -64,14 +64,9 @@ impl Reader<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The network in blob `b`; `None` when it's truncated, out of range or not finite.
|
||||
#[allow(dead_code, reason = "for readers that don't need the count of styled segments")]
|
||||
pub(super) fn decode(b: &[u8]) -> Option<Network> {
|
||||
decode_styled(b).map(|(net, _)| net)
|
||||
}
|
||||
|
||||
/// The network in blob `b` and how many of its segments have a style id of their own (per-segment
|
||||
/// stroke overrides, which the document model can't hold); `None` as for `decode`.
|
||||
/// stroke overrides, which the document model can't hold); `None` when it's truncated, out of
|
||||
/// range or not finite.
|
||||
pub(super) fn decode_styled(b: &[u8]) -> Option<(Network, usize)> {
|
||||
let mut r = Reader { b, pos: 0 };
|
||||
let (nv, ns, nr) = (r.u32()? as usize, r.u32()? as usize, r.u32()? as usize);
|
||||
|
||||
@@ -355,9 +355,9 @@ fn unreadable_conditions_skip_the_conditional() {
|
||||
#[test]
|
||||
fn damaged_network_blobs_never_panic() {
|
||||
let good = square_blob();
|
||||
assert!(network::decode(&good).is_some());
|
||||
assert!(network::decode_styled(&good).is_some());
|
||||
for cut in 0..good.len() {
|
||||
assert!(network::decode(&good[..cut]).is_none(), "{cut}");
|
||||
assert!(network::decode_styled(&good[..cut]).is_none(), "{cut}");
|
||||
}
|
||||
let mut r = Lcg(5);
|
||||
for _ in 0..5000 {
|
||||
@@ -366,7 +366,7 @@ fn damaged_network_blobs_never_panic() {
|
||||
let i = (r.next() as usize) % m.len();
|
||||
m[i] ^= 1 << (r.next() % 8);
|
||||
}
|
||||
if let Some(net) = network::decode(&m) {
|
||||
if let Some((net, _)) = network::decode_styled(&m) {
|
||||
assert!(net.segments.iter().all(|s| s.a < net.vertices.len() && s.b < net.vertices.len()));
|
||||
assert!(net.regions.iter().flat_map(|r| &r.loops).flatten().all(|i| *i < net.segments.len()));
|
||||
assert!(net.vertices.iter().all(|v| v.x.is_finite() && v.y.is_finite()));
|
||||
@@ -375,13 +375,13 @@ fn damaged_network_blobs_never_panic() {
|
||||
// Counts larger than the blob, and indices out of range.
|
||||
let mut huge = vec![0xff; 12];
|
||||
huge.extend_from_slice(&[0; 64]);
|
||||
assert!(network::decode(&huge).is_none());
|
||||
assert!(network::decode_styled(&huge).is_none());
|
||||
let mut bad = arrow_blob();
|
||||
bad[12 + 24 + 4..12 + 24 + 8].copy_from_slice(&7u32.to_le_bytes());
|
||||
assert!(network::decode(&bad).is_none());
|
||||
assert!(network::decode_styled(&bad).is_none());
|
||||
for _ in 0..2000 {
|
||||
let junk: Vec<u8> = (0..(r.next() % 120)).map(|_| r.next() as u8).collect();
|
||||
let _ = network::decode(&junk);
|
||||
let _ = network::decode_styled(&junk);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -201,6 +201,8 @@ name = "English"
|
||||
"Code or invite link" = "Code or invite link"
|
||||
"Color profile: {name}" = "Color profile: {name}"
|
||||
"Colour and text styles, Light and Dark variables, buttons, inputs and cards" = "Colour and text styles, Light and Dark variables, buttons, inputs and cards"
|
||||
"Convert to frame" = "Convert to frame"
|
||||
"Convert to section" = "Convert to section"
|
||||
"CSS custom properties…" = "CSS custom properties…"
|
||||
"Can edit" = "Can edit"
|
||||
"Can't read this library: {e}" = "Can't read this library: {e}"
|
||||
|
||||
@@ -302,6 +302,8 @@ status = "draft — needs native review"
|
||||
"Copied as SVG" = "Copiado como SVG"
|
||||
"Copied as text" = "Copiado como texto"
|
||||
"Copied {value}" = "Copiado: {value}"
|
||||
"Convert to frame" = "Convertir en marco"
|
||||
"Convert to section" = "Convertir en sección"
|
||||
"Copy" = "Copiar"
|
||||
"Copy as" = "Copiar como"
|
||||
"Copy as CSS" = "Copiar como CSS"
|
||||
@@ -1839,6 +1841,7 @@ status = "draft — needs native review"
|
||||
"object.batchRename" = "Cambiar nombre en lote"
|
||||
"object.convertToFrame" = "Convertir en marco"
|
||||
"object.convertToGroup" = "Convertir en grupo"
|
||||
"object.convertToSection" = "Convertir en sección"
|
||||
"object.flipH" = "Voltear horizontalmente"
|
||||
"object.flipV" = "Voltear verticalmente"
|
||||
"object.frameSelection" = "Enmarcar selección"
|
||||
|
||||
@@ -84,7 +84,7 @@ impl AboutTab {
|
||||
}
|
||||
|
||||
/// The About window's width, the same on every tab (the Contributors table needs the room).
|
||||
pub const ABOUT_WIDTH: f32 = 640.0;
|
||||
pub const ABOUT_WIDTH: f32 = 660.0;
|
||||
|
||||
/// The About window's tab row, drawn like the Design / Prototype tabs of the right panel.
|
||||
/// A row of tabs (the About window's, the shortcuts panel's): the current one highlighted.
|
||||
|
||||
@@ -467,6 +467,22 @@ pub fn popups(app: &mut FigApp, ctx: &egui::Context) {
|
||||
.collect();
|
||||
(!secs.is_empty()).then(|| if secs.iter().all(|h| *h) { tr("Show section contents") } else { tr("Hide section contents") })
|
||||
});
|
||||
// Frames on the page (or in a section) convert to sections, and sections to frames.
|
||||
let convert = app.session.doc().ok().and_then(|doc| {
|
||||
let nodes: Vec<_> = ids.iter().filter_map(|i| doc.node(*i)).collect();
|
||||
let on_board = |n: &figcraft_doc::Node| {
|
||||
n.parent.and_then(|p| doc.node(p)).is_some_and(|p| p.is_page() || matches!(p.kind, figcraft_doc::NodeKind::Section { .. }))
|
||||
};
|
||||
if nodes.is_empty() {
|
||||
None
|
||||
} else if nodes.iter().all(|n| matches!(n.kind, figcraft_doc::NodeKind::Section { .. })) {
|
||||
Some((tr("Convert to frame"), "object.convertToFrame"))
|
||||
} else if nodes.iter().all(|n| matches!(n.kind, figcraft_doc::NodeKind::Frame) && on_board(n)) {
|
||||
Some((tr("Convert to section"), "object.convertToSection"))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
});
|
||||
let mut entries: Vec<(&str, &str)> = if has_sel {
|
||||
vec![
|
||||
(tr("Copy"), "edit.copy"),
|
||||
@@ -519,6 +535,11 @@ pub fn popups(app: &mut FigApp, ctx: &egui::Context) {
|
||||
{
|
||||
entries.insert(at + 1, (label, "section.toggleContents"));
|
||||
}
|
||||
if let Some(entry) = convert
|
||||
&& let Some(at) = entries.iter().position(|e| e.1 == "object.wrapInSection")
|
||||
{
|
||||
entries.insert(at + 1, entry);
|
||||
}
|
||||
// A right-clicked ruler guide (selected, with no layers) can be removed.
|
||||
if !has_sel && app.session.ds().is_ok_and(|d| d.selected_guide().is_some()) {
|
||||
entries.splice(0..0, [(tr("Remove guide"), "guide.remove"), ("-", "")]);
|
||||
|
||||
Reference in New Issue
Block a user