Merge remote-tracking branch 'upstream/main' into feat/tailwind-output

This commit is contained in:
vetlucasmartins
2026-10-24 10:05:51 +01:00
86 changed files with 2950 additions and 155 deletions
+14 -4
View File
@@ -217,14 +217,14 @@ pub fn specs() -> Vec<CommandSpec> {
spec!(
"component.addProperty",
"Add component property",
"{id? (component, set, or a layer inside a component: it gets bound), name, type: boolean|text|instanceSwap|variant|slot, default?, layer? (node bound to it; a frame for slots), preferred? (instance swap and slot properties: component or set ids offered first)}",
"{id? (component, set, or a layer inside a component: it gets bound), name, type: boolean|text|instanceSwap|variant|slot, default? (boolean: the bound layer's visibility in the main; without it, the layer's current visibility), layer? (node bound to it; a frame for slots), preferred? (instance swap and slot properties: component or set ids offered first)}",
true,
add_prop
),
spec!(
"component.editProperty",
"Edit component property",
"{id? (component or set), name, newName?, default? (variant properties: that variant takes the top-left place), value? + newValue? (rename a variant value), index? (variant properties: move to this place; the variants' names follow), preferred? (instance swap and slot properties: component or set ids offered first)}",
"{id? (component or set), name, newName?, default? (variant properties: that variant takes the top-left place; boolean: the main's bound layers show or hide to match), value? + newValue? (rename a variant value), index? (variant properties: move to this place; the variants' names follow), preferred? (instance swap and slot properties: component or set ids offered first)}",
true,
edit_prop
),
@@ -1151,7 +1151,8 @@ fn add_prop_for(cmd: &str, s: &mut Session, p: &Value) -> Result<Value> {
}
let layer_node = layer.and_then(|l| doc.node(l)).cloned();
let def = match t.to_ascii_lowercase().as_str() {
"boolean" | "bool" => PropDef::Boolean { default: boolean(p, "default").unwrap_or(true) },
// Without a default, a bound layer keeps showing as it does: its visibility is the default.
"boolean" | "bool" => PropDef::Boolean { default: boolean(p, "default").or_else(|| layer_node.as_ref().map(|n| n.visible)).unwrap_or(true) },
"text" => PropDef::Text {
default: string(p, "default")
.or_else(|| layer_node.as_ref().and_then(|n| n.kind.text()).map(|t| t.characters.clone()))
@@ -1255,7 +1256,12 @@ fn bind(cmd: &str, doc: &mut Document, layer: NodeId, property: Option<&str>, fi
}
_ => {}
}
doc.get_mut(layer)?.prop_refs.insert(field.to_string(), property.to_string());
let n = doc.get_mut(layer)?;
n.prop_refs.insert(field.to_string(), property.to_string());
// On the main, a boolean property's value is the visibility of the layers it controls.
if let PropDef::Boolean { default } = def {
n.visible = *default;
}
Ok(())
}
@@ -1424,6 +1430,10 @@ fn edit_prop(s: &mut Session, p: &Value) -> Result<Value> {
rename_prop_refs(doc, *c, &name, Some(nn))?;
}
}
// The main's bound layers show the new default.
if let PropDef::Boolean { default } = new_def {
set_boolean_default(doc, &comps, new_name.as_deref().unwrap_or(&name), default);
}
ok()
}
+39 -18
View File
@@ -167,6 +167,42 @@ pub fn parse_path_data(d: &str) -> Option<figcraft_geom::BezPath> {
figcraft_geom::BezPath::from_svg(d).ok()
}
/// A vector's geometry made from SVG path data (see [`path_network`]).
pub(crate) struct PathShape {
/// The network, with the path's own box moved to the origin and scaled to the size asked for.
pub network: VectorNetwork,
pub width: f64,
pub height: f64,
/// Where the path's own box starts in its coordinates (unscaled).
pub offset: (f64, f64),
}
/// The network SVG path data `d` draws, for `node.create` and `node.set`: the path's own box moves
/// to the origin, and `width`/`height` scale it as [`vector_scale`] says. Data that doesn't parse
/// or draws nothing is refused.
pub(crate) fn path_network(cmd: &str, d: &str, width: Option<f64>, height: Option<f64>) -> Result<PathShape> {
use figcraft_geom::Shape;
let empty = || bad(cmd, "`path` draws nothing: a vector needs a visible line or area");
let mut path = parse_path_data(d).ok_or_else(|| bad(cmd, "`path` is not valid SVG path data"))?;
let bb = path.bounding_box();
if !(bb.x0.is_finite() && bb.y0.is_finite() && bb.width().is_finite() && bb.height().is_finite()) {
return Err(bad(cmd, "`path` has coordinates too large to draw"));
}
if bb.width() <= 1e-9 && bb.height() <= 1e-9 {
return Err(empty());
}
path.apply_affine(figcraft_geom::Affine::translate((-bb.x0, -bb.y0)));
let (sx, sy) = vector_scale(width, height, bb.width(), bb.height(), "a path").map_err(|e| bad(cmd, e))?;
if sx != 1.0 || sy != 1.0 {
path.apply_affine(figcraft_geom::Affine::scale_non_uniform(sx, sy));
}
let network = VectorNetwork::from_path(&path);
if network.segments.is_empty() {
return Err(empty());
}
Ok(PathShape { network, width: bb.width() * sx, height: bb.height() * sy, offset: (bb.x0, bb.y0) })
}
/// How much a vector's path is scaled to take the size asked for: with both sides, each scales
/// independently (as resizing a vector does); with one, the other keeps its proportion. A size on
/// an axis the geometry has none of is an error unless it is 0 (a line's height), and a size of 0
@@ -229,27 +265,12 @@ pub fn create(s: &mut Session, p: &Value) -> Result<Value> {
let asked = (num(p, "width"), num(p, "height"));
if let NodeKind::Vector { network } = &mut kind {
if let Some(d) = string(p, "path") {
let mut path = parse_path_data(&d).ok_or_else(|| bad(cmd, "`path` is not valid SVG path data"))?;
use figcraft_geom::Shape;
let bb = path.bounding_box();
if bb.width() <= 1e-9 && bb.height() <= 1e-9 {
return Err(bad(cmd, "`path` draws nothing: a vector needs a visible line or area"));
}
path.apply_affine(figcraft_geom::Affine::translate((-bb.x0, -bb.y0)));
let (sx, sy) = vector_scale(asked.0, asked.1, bb.width(), bb.height(), "a path").map_err(|e| bad(cmd, e))?;
if sx != 1.0 || sy != 1.0 {
path.apply_affine(figcraft_geom::Affine::scale_non_uniform(sx, sy));
}
*network = VectorNetwork::from_path(&path);
if network.segments.is_empty() {
return Err(bad(cmd, "`path` draws nothing: a vector needs a visible line or area"));
}
let shape = path_network(cmd, &d, asked.0, asked.1)?;
*network = shape.network;
if boolean(p, "closed") == Some(false) {
network.regions.clear();
}
w = bb.width() * sx;
h = bb.height() * sy;
offset = (bb.x0, bb.y0);
(w, h, offset) = (shape.width, shape.height, shape.offset);
} else if let Some(Value::Array(pts)) = get(p, "points") {
let pts: Vec<Point> = pts.iter().filter_map(|v| Some(Point::new(f64_of(v.get(0)?)?, f64_of(v.get(1)?)?))).collect();
if pts.len() < 2 {
+46 -26
View File
@@ -4,7 +4,7 @@ use std::collections::{BTreeMap, BTreeSet};
use figcraft_doc::*;
use figcraft_geom::Affine;
use serde_json::{Value, json};
use serde_json::{Map, Value, json};
use super::{CommandSpec, ok};
use crate::params::*;
@@ -216,7 +216,13 @@ pub fn specs() -> Vec<CommandSpec> {
let i = req_id("node.get", p, "id")?;
Ok(crate::inspect::node_json(s.doc()?, i, 1))
}),
spec!("node.reparent", "Move layer into", "{ids?, parent, index?}", true, reparent),
spec!(
"node.reparent",
"Move layer into",
"{ids?, parent, index? (final position among parent's children, bottom first; default top)}",
true,
reparent
),
spec!("node.reorder", "Reorder layer", "{id, index}", true, |s, p| {
let i = req_id("node.reorder", p, "id")?;
let idx = req_num("node.reorder", p, "index")?.max(0.0) as usize;
@@ -1169,6 +1175,9 @@ fn find_replace(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({"matches": hits}))
}
/// Keys only some layer types take, the test for those layers, and why the others skip them.
type TypedKeys<'a> = (&'a [&'a str], &'a dyn Fn(&Node) -> bool, &'a str);
fn set(s: &mut Session, p: &Value) -> Result<Value> {
let ids = req_targets("node.set", s, p)?;
let props = match get(p, "props") {
@@ -1199,34 +1208,44 @@ fn set(s: &mut Session, p: &Value) -> Result<Value> {
None => Vec::new(),
};
let mut warnings = font_style_warnings(&s.ds()?.doc, &ids, &props);
// Auto layout keys go to the layers that can have auto layout; with none among the targets,
// the first refuses them (`props::layout_mut`).
// Type-specific keys go to the layers that take them and are skipped, with a warning, on the
// others: auto layout keys to frame-like layers, `path` to vectors. With none among the targets
// that take them, the first refuses them (`props::layout_mut`, `props::apply`).
let doc = &s.ds()?.doc;
let layout_keys: Vec<&str> = props.keys().map(String::as_str).filter(|k| super::props::LAYOUT_KEYS.contains(k)).collect();
let mut without_layout = props.clone();
let mut skipped = BTreeSet::new();
if !layout_keys.is_empty() {
let doc = &s.ds()?.doc;
let frame_like = |i: &NodeId| doc.node(*i).is_some_and(|n| n.kind.is_frame_like());
if ids.iter().any(frame_like) {
skipped = ids.iter().copied().filter(|i| !frame_like(i)).collect();
without_layout.retain(|k, _| !layout_keys.contains(&k.as_str()));
let path_keys: Vec<&str> = props.keys().map(String::as_str).filter(|k| *k == "path").collect();
let frame_like = |n: &Node| n.kind.is_frame_like();
let vector = |n: &Node| matches!(n.kind, NodeKind::Vector { .. });
let typed: [TypedKeys; 2] = [
(&layout_keys, &frame_like, "only frames, components and instances have auto layout"),
(&path_keys, &vector, "only vector layers have a path"),
];
let mut skipped: BTreeMap<NodeId, Map<String, Value>> = BTreeMap::new();
for (keys, takes, why) in typed {
let takes = |i: &NodeId| doc.node(*i).is_some_and(takes);
if keys.is_empty() || !ids.iter().any(takes) {
continue;
}
if !skipped.is_empty() {
let names: Vec<String> =
skipped.iter().take(10).filter_map(|i| doc.node(*i)).map(|n| format!("{} “{}” ({})", n.id, n.name, n.kind.type_name())).collect();
let more = if skipped.len() > names.len() { format!(" and {} more", skipped.len() - names.len()) } else { String::new() };
warnings.push(format!(
"{} skipped on {}{more}: only frames, components and instances have auto layout",
layout_keys.iter().map(|k| format!("`{k}`")).collect::<Vec<_>>().join(", "),
names.join(", ")
));
let others: Vec<NodeId> = ids.iter().copied().filter(|i| !takes(i)).collect();
if others.is_empty() {
continue;
}
for i in &others {
skipped.entry(*i).or_insert_with(|| props.clone()).retain(|k, _| !keys.contains(&k.as_str()));
}
let names: Vec<String> =
others.iter().take(10).filter_map(|i| doc.node(*i)).map(|n| format!("{} “{}” ({})", n.id, n.name, n.kind.type_name())).collect();
let more = if others.len() > names.len() { format!(" and {} more", others.len() - names.len()) } else { String::new() };
warnings.push(format!(
"{} skipped on {}{more}: {why}",
keys.iter().map(|k| format!("`{k}`")).collect::<Vec<_>>().join(", "),
names.join(", ")
));
}
let doc = s.doc_mut()?;
let before = super::styles::bound_before(doc, &ids);
for i in ids {
let props = if skipped.contains(&i) { &without_layout } else { &props };
super::props::apply("node.set", doc, i, props)?;
super::props::apply("node.set", doc, i, skipped.get(&i).unwrap_or(&props))?;
}
super::styles::detach_edited(doc, &before);
if let Some(o) = pivot {
@@ -1312,9 +1331,10 @@ fn reparent(s: &mut Session, p: &Value) -> Result<Value> {
let index = num(p, "index").map(|i| i.max(0.0) as usize);
let doc = s.doc_mut()?;
let ids = z_sorted(doc, &roots(doc, &ids));
// The layers land together under the first sibling at or above `index` that stays put (none: on
// top), wherever they came from.
let above = index.and_then(|at| doc.get(parent).ok()?.children.iter().skip(at).find(|c| !ids.contains(c)).copied());
// `index` is where the first moved layer ends up among the parent's children (bottom first),
// counted after the move, the way `node.reorder` counts it. The layers land together, keeping
// their order, under the sibling that stays put and ends up just after them (none: on top).
let above = index.and_then(|at| doc.get(parent).ok()?.children.iter().filter(|c| !ids.contains(c)).nth(at).copied());
for i in &ids {
// Counted with the layer still in place, as `reparent` expects.
let at = above.and_then(|a| doc.node(parent)?.children.iter().position(|c| *c == a));
+3
View File
@@ -99,6 +99,7 @@ fn registry() -> &'static Registry {
list.extend(crate::code_connect::specs());
list.extend(crate::annotations::specs());
list.extend(crate::dev_resources::specs());
list.extend(crate::dev_measurements::specs());
list.extend(crate::distance::specs());
list.extend(crate::collab::specs());
list.extend(crate::branches::specs());
@@ -109,6 +110,8 @@ fn registry() -> &'static Registry {
list.extend(crate::online::specs());
list.extend(crate::invite_links::specs());
list.extend(crate::templates::specs());
list.extend(crate::datafill::specs());
list.extend(crate::textfit::specs());
list.extend(crate::board::specs());
let mut index = HashMap::new();
for (i, s) in list.iter().enumerate() {
+51 -9
View File
@@ -26,7 +26,9 @@ fontSize, lineHeight (auto|px|\"150%\"), letterSpacing (px|\"2%\"), textAlign (l
textDecorationColor (colour, or auto/null: the text's), textCase (original|upper|lower|title|smallCaps), paragraphSpacing, paragraphIndent, \
listStyle (none|bulleted|numbered; with start/end: the paragraphs those chars touch), indentation (list nesting level 0-8, same range rule), \
listSpacing (px between list items, in place of paragraphSpacing), hangingPunctuation, hangingList (markers outside the box), leadingTrim (vertical trim: none|capHeight), maxLines, features ({tag: bool}, e.g. {\"liga\": false, \"tnum\": true}), \
fontVariations / variationSettings ({axis: number}, e.g. {\"wght\": 550, \"wdth\": 85}; null = none), pointCount, innerRadius, arcStart, arcSweep, \
fontVariations / variationSettings ({axis: number}, e.g. {\"wght\": 550, \"wdth\": 85}; null = none), \
path (a vector's SVG path data: replaces its geometry, keeping the layer, its paints, styles and bindings; width/height in the same call scale it as \
node.create does; its own box goes at the layer's x/y, or with x/y given, at x + its left edge and y + its top as node.create places it), pointCount, innerRadius, arcStart, arcSweep, \
arcInner, booleanOperation (a boolean group's operation, or an operand's own; null: the group's), operandOperation (a layer in a boolean \
group: how it combines with the operands below it; null: the group's), constrainProportions, description, interactions, overflow, pluginData {k: v}";
@@ -382,7 +384,7 @@ fn paint_none(v: &Value) -> bool {
/// paints set in the same call don't detach them.
fn apply_order(k: &str) -> u8 {
match k {
"width" | "height" | "w" | "h" => 0,
"path" | "width" | "height" | "w" | "h" => 0,
"rotation" => 2,
"x" | "y" => 3,
"fillStyle" | "strokeStyle" | "effectStyle" | "textStyle" | "gridStyle" => 4,
@@ -391,16 +393,35 @@ fn apply_order(k: &str) -> u8 {
}
}
/// An explicit width (`is_w`) or height makes hugging or filling on that axis fixed, like typing
/// into W or H.
fn fix_axis(doc: &mut Document, id: NodeId, is_w: bool) -> Result<()> {
let n = doc.get_mut(id)?;
if is_w {
n.child_layout.sizing_h = Sizing::Fixed;
} else {
n.child_layout.sizing_v = Sizing::Fixed;
}
Ok(())
}
/// Apply a property map to one node. `x`/`y` are relative to the parent.
pub fn apply(cmd: &str, doc: &mut Document, id: NodeId, props: &Map<String, Value>) -> Result<()> {
let mut unknown = Vec::new();
let mut keys: Vec<(&String, &Value)> = props.iter().collect();
keys.sort_by_key(|(k, _)| apply_order(k));
// Where a new `path` had its own box, for `x`/`y` in the same call (as `node.create` places it).
let mut path_offset = (0.0, 0.0);
for (k, v) in keys {
let k = k.as_str();
match k {
"path" => path_offset = set_path(cmd, doc, id, v, props)?,
// A new path takes the size asked for as its scale (`set_path`); the size still fixes
// its auto layout axis.
"width" | "height" | "w" | "h" if props.contains_key("path") => fix_axis(doc, id, k.starts_with('w'))?,
"x" | "y" => {
let val = f64_of(v).ok_or_else(|| bad(cmd, format!("`{k}` must be a number")))?;
let val = val + if k == "x" { path_offset.0 } else { path_offset.1 };
// X/Y are the node's own origin (its rotated top-left for rotated nodes), in the
// frame it is in (groups have no space of their own).
let n = doc.get(id)?;
@@ -442,13 +463,7 @@ pub fn apply(cmd: &str, doc: &mut Document, id: NodeId, props: &Map<String, Valu
TextAutoResize::None
};
}
// Explicit width/height makes hugging/filling fixed (like typing into W/H).
let n = doc.get_mut(id)?;
if is_w {
n.child_layout.sizing_h = Sizing::Fixed;
} else {
n.child_layout.sizing_v = Sizing::Fixed;
}
fix_axis(doc, id, is_w)?;
}
"rotation" => {
let deg = f64_of(v).ok_or_else(|| bad(cmd, "`rotation` must be degrees"))?;
@@ -939,6 +954,33 @@ pub fn apply(cmd: &str, doc: &mut Document, id: NodeId, props: &Map<String, Valu
Ok(())
}
/// Replace vector `id`'s geometry with SVG path data `v`, as `node.create` reads it: `width`/`height`
/// in the same call scale it, and the path's own box goes at the layer's origin, which keeps its
/// place, rotation and everything else. Returns where the path's own box was, which `x`/`y` in the
/// same call add (so `{path, x: 0, y: 0}` places it as `node.create` does). Layers inside an
/// instance are refused: their geometry comes from the main component, and a sync would undo it.
fn set_path(cmd: &str, doc: &mut Document, id: NodeId, v: &Value, props: &Map<String, Value>) -> Result<(f64, f64)> {
let d = v.as_str().ok_or_else(|| bad(cmd, "`path` must be SVG path data (a string)"))?;
let n = doc.get(id)?;
if !matches!(n.kind, NodeKind::Vector { .. }) {
return Err(bad(cmd, format!("`path` applies to vector layers, and layer {} “{}” is a {}", n.id, n.name, n.kind.type_name())));
}
if crate::components::inside_instance(doc, id) {
return Err(bad(cmd, format!("`path`: layer {} “{}” is inside an instance: edit its main component, or detach the instance", n.id, n.name)));
}
let size = |long: &str, short: &str| -> Result<Option<f64>> {
props.get(long).or_else(|| props.get(short)).map(|v| f64_of(v).ok_or_else(|| bad(cmd, format!("`{long}` must be a number")))).transpose()
};
let shape = super::create::path_network(cmd, d, size("width", "w")?, size("height", "h")?)?;
let n = doc.get_mut(id)?;
if let NodeKind::Vector { network } = &mut n.kind {
*network = shape.network;
}
n.width = shape.width;
n.height = shape.height;
Ok(shape.offset)
}
/// The override group a property key belongs to.
pub fn override_group(k: &str) -> &'static str {
match k {