mirror of
https://github.com/storytold/figcraft.git
synced 2026-11-11 16:30:12 -06:00
Merge remote-tracking branch 'upstream/main' into feat/tailwind-output
This commit is contained in:
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user