mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-31 05:46:46 -06:00
Merge main into #840
# Conflicts: # apps/figcraft-cli/src/files.rs # apps/figcraft-cli/src/main.rs # crates/engine/src/cmd/file.rs # crates/engine/src/lib.rs # crates/ui-egui/locales/en.toml # crates/ui-egui/locales/es.toml # crates/ui-egui/src/export_panel.rs # crates/ui-egui/src/export_preview_tests.rs # docs/file-format-parity.md # docs/mcp.md # docs/roadmap.md # docs/ui-parity.md
This commit is contained in:
@@ -27,6 +27,13 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
spacing
|
||||
),
|
||||
spec!("arrange.tidy", "Tidy up", "{ids?, spacing?}", true, tidy),
|
||||
spec!(
|
||||
"arrange.reorder",
|
||||
"Reorder selection",
|
||||
"{ids?, source, target, swap?, fromGesture?} (insert at target's visual slot; no hierarchy changes)",
|
||||
true,
|
||||
reorder
|
||||
),
|
||||
spec!("arrange.packH", "Pack horizontal", "{ids?}", true, |s, p| pack(s, p, true)),
|
||||
spec!("arrange.packV", "Pack vertical", "{ids?}", true, |s, p| pack(s, p, false)),
|
||||
spec!("arrange.roundToPixels", "Round to pixels", "{ids?}", true, round_px),
|
||||
@@ -199,8 +206,9 @@ fn distribute(s: &mut Session, p: &Value, horizontal: bool) -> Result<Value> {
|
||||
let mut items: Vec<(NodeId, Rect)> = ids.iter().filter_map(|i| doc.world_bounds(*i).map(|b| (*i, b))).collect();
|
||||
items.sort_by(|a, b| if horizontal { a.1.x0.total_cmp(&b.1.x0) } else { a.1.y0.total_cmp(&b.1.y0) });
|
||||
let total: f64 = items.iter().map(|(_, b)| if horizontal { b.width() } else { b.height() }).sum();
|
||||
let (Some(first), Some(last)) = (items.first(), items.last()) else { return ok() };
|
||||
let span = if horizontal { last.1.x1 - first.1.x0 } else { last.1.y1 - first.1.y0 };
|
||||
let Some(first) = items.first() else { return ok() };
|
||||
let end = items.iter().map(|(_, b)| if horizontal { b.x1 } else { b.y1 }).fold(f64::MIN, f64::max);
|
||||
let span = if horizontal { end - first.1.x0 } else { end - first.1.y0 };
|
||||
let gap = num(p, "spacing").unwrap_or((span - total) / (items.len().saturating_sub(1).max(1)) as f64);
|
||||
let mut pos = if horizontal { first.1.x0 } else { first.1.y0 };
|
||||
for (i, b) in items.clone() {
|
||||
@@ -487,9 +495,15 @@ pub fn scale_node(doc: &mut Document, id: NodeId, k: f64) -> Result<()> {
|
||||
t.paragraph_spacing *= k;
|
||||
t.paragraph_indent *= k;
|
||||
t.list_spacing *= k;
|
||||
if let Some(tp) = &mut t.path {
|
||||
tp.scale(k, k);
|
||||
}
|
||||
}
|
||||
if let NodeKind::Vector { network } = &mut n.kind {
|
||||
network.scale(k, k);
|
||||
for v in &mut network.vertices {
|
||||
v.radius *= k;
|
||||
}
|
||||
}
|
||||
n.width *= k;
|
||||
n.height *= k;
|
||||
@@ -528,6 +542,7 @@ pub(crate) fn resize_cmd(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (col, row) = string(p, "anchor").and_then(|a| crate::tools::anchor_of(&a)).unwrap_or((0, 0));
|
||||
let (ax, ay) = (f64::from(col) / 2.0, f64::from(row) / 2.0);
|
||||
let doc = s.doc_mut()?;
|
||||
let before = super::styles::bound_before(doc, &ids);
|
||||
for i in ids {
|
||||
let n = doc.get(i)?;
|
||||
let (ow, oh) = (n.width, n.height);
|
||||
@@ -551,6 +566,49 @@ pub(crate) fn resize_cmd(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
super::props::restore_worlds(doc, &kept)?;
|
||||
}
|
||||
super::styles::detach_edited(doc, &before);
|
||||
ok()
|
||||
}
|
||||
|
||||
/// Reorder visual slots without rebuilding or reparenting layers.
|
||||
fn reorder(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "arrange.reorder";
|
||||
let ids = req_targets(cmd, s, p)?;
|
||||
let source = req_id(cmd, p, "source")?;
|
||||
let target = req_id(cmd, p, "target")?;
|
||||
let ds = s.ds()?;
|
||||
let base = ds.interaction.as_ref().filter(|_| boolean(p, "fromGesture") == Some(true)).map(|i| i.doc.clone()).unwrap_or_else(|| ds.doc.clone());
|
||||
let layout =
|
||||
crate::selection_reorder::ReorderLayout::new(&base, &ids).ok_or_else(|| bad(cmd, "select a regular row, column or complete aligned grid"))?;
|
||||
let positions = layout
|
||||
.positions(source, target, boolean(p, "swap") == Some(true))
|
||||
.ok_or_else(|| bad(cmd, "source and target must belong to the selection"))?;
|
||||
// Validate every delta before editing; absolute targets always come from the gesture's
|
||||
// snapshot so crossing several slots and returning to the start does not accumulate edits.
|
||||
let doc = s.doc()?;
|
||||
let mut moves = Vec::new();
|
||||
for (id, at) in positions {
|
||||
let now = doc.world_bounds(id).ok_or_else(|| bad(cmd, "layer is no longer available"))?;
|
||||
let (dx, dy) = (at.x - now.x0, at.y - now.y0);
|
||||
if !dx.is_finite() || !dy.is_finite() {
|
||||
return Err(bad(cmd, "non-finite reorder result"));
|
||||
}
|
||||
if dx.abs() > 1e-9 || dy.abs() > 1e-9 {
|
||||
let node = doc.get(id)?;
|
||||
let parent = node.parent.map(|p| doc.world_transform(p)).unwrap_or(Affine::IDENTITY);
|
||||
let transform = parent.inverse() * Affine::translate((dx, dy)) * parent * node.transform;
|
||||
if !transform.as_coeffs().iter().all(|v| v.is_finite()) {
|
||||
return Err(bad(cmd, "non-finite local transform"));
|
||||
}
|
||||
moves.push((id, dx, dy));
|
||||
}
|
||||
}
|
||||
if !moves.is_empty() {
|
||||
let doc = s.doc_mut()?;
|
||||
for (id, dx, dy) in moves {
|
||||
move_world(doc, id, dx, dy)?;
|
||||
}
|
||||
}
|
||||
ok()
|
||||
}
|
||||
|
||||
|
||||
@@ -56,11 +56,17 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
s.set_selection(out.iter().filter_map(id_of).collect());
|
||||
Ok(json!({"ids": out}))
|
||||
}),
|
||||
spec!("component.combineVariants", "Combine as variants", "{ids? (components)}", true, combine),
|
||||
spec!(
|
||||
"component.combineVariants",
|
||||
"Combine as variants",
|
||||
"{ids? (main components; none already a variant, a library copy or inside an instance)} (wraps them in a new component set; names without `=` become \"Property 1=Default\", \"Property 1=VariantN\")",
|
||||
true,
|
||||
combine
|
||||
),
|
||||
spec!(
|
||||
"component.addVariant",
|
||||
"Add variant",
|
||||
"{id? (component set or variant), property?, value?} (duplicates the variant; `property`=`value` names the new one)",
|
||||
"{id? (component set, variant, or a main component not in a set yet: it becomes a set of itself and the new variant), property?, value?} (duplicates the variant; `property`=`value` names the new one)",
|
||||
true,
|
||||
add_variant
|
||||
),
|
||||
@@ -159,7 +165,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
spec!(
|
||||
"component.resetOverride",
|
||||
"Reset override",
|
||||
"{id?, group? (fills|strokes|effects|opacity|visible|name|corners|characters|textStyle|padding|gap|alignment|grids|exports|interactions|size|position|component|props|slot; all when omitted)}",
|
||||
"{id?, group? (fills|strokes|effects|opacity|visible|name|corners|characters|textStyle|padding|gap|alignment|grids|exports|interactions|size|position|modes|component|props|slot; all when omitted)}",
|
||||
true,
|
||||
reset_override
|
||||
),
|
||||
@@ -211,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
|
||||
),
|
||||
@@ -330,23 +336,25 @@ fn create(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({"id": target}))
|
||||
}
|
||||
|
||||
/// The name a new component set takes from its first variant: the path before the last '/'
|
||||
/// ("Button/State=Hover" → "Button"), or a plain name as is ("Button"). A bare "prop=value" name
|
||||
/// ("State=Default, Size=Small") names no set.
|
||||
fn set_name_from(variant: &str) -> Option<String> {
|
||||
let base = variant.rsplit_once('/').map_or(variant, |(path, _)| path).trim();
|
||||
(!base.is_empty() && !base.contains('=')).then(|| base.to_string())
|
||||
}
|
||||
|
||||
/// Wrap components in a new component set (named after the first) placed around them.
|
||||
fn wrap_in_set(doc: &mut Document, ids: &[NodeId]) -> Result<NodeId> {
|
||||
let cmd = "component.combineVariants";
|
||||
fn wrap_in_set(cmd: &str, doc: &mut Document, ids: &[NodeId]) -> Result<NodeId> {
|
||||
let top = *ids.last().ok_or_else(|| bad(cmd, "nothing selected"))?;
|
||||
let parent = doc.get(top)?.parent.ok_or_else(|| bad(cmd, "page"))?;
|
||||
if inside_instance(doc, top) || owning_instance(doc, parent).is_some() {
|
||||
return Err(EngineError::Disabled(cmd.into(), "components inside an instance can't be combined".into()));
|
||||
return Err(EngineError::Disabled(cmd.into(), INSIDE_INSTANCE.into()));
|
||||
}
|
||||
let bounds = doc.bounds_of(ids).unwrap_or_default();
|
||||
let pw = doc.world_transform(parent);
|
||||
let local = figcraft_geom::transform_rect_bbox(pw.inverse(), bounds.inflate(20.0, 20.0));
|
||||
let base = ids
|
||||
.first()
|
||||
.and_then(|i| doc.node(*i))
|
||||
.map(|n| n.name.split(['/', '=']).next().unwrap_or("Component").trim().to_string())
|
||||
.filter(|n| !n.is_empty() && !n.contains(','))
|
||||
.unwrap_or_else(|| "Component".into());
|
||||
let base = ids.first().and_then(|i| doc.node(*i)).and_then(|n| set_name_from(&n.name)).unwrap_or_else(|| doc.next_name("Component"));
|
||||
let set = doc.alloc_id();
|
||||
let mut n = Node::new(set, NodeKind::ComponentSet { description: String::new() }, base, local.width(), local.height());
|
||||
n.transform = figcraft_geom::Affine::translate((local.x0, local.y0));
|
||||
@@ -358,22 +366,71 @@ fn wrap_in_set(doc: &mut Document, ids: &[NodeId]) -> Result<NodeId> {
|
||||
Ok(set)
|
||||
}
|
||||
|
||||
fn combine(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "component.combineVariants";
|
||||
let ids = req_targets(cmd, s, p)?;
|
||||
let doc = s.doc_mut()?;
|
||||
const INSIDE_INSTANCE: &str = "components inside an instance can't become variants";
|
||||
|
||||
/// Why `ids` (distinct) can't become the variants of a new component set, or `None` when they
|
||||
/// can: anything but main components, components that are variants already (combining them would
|
||||
/// put a set in a set), library copies, components inside an instance, and a component inside
|
||||
/// another one of `ids`.
|
||||
fn combine_refusal(doc: &Document, ids: &[NodeId]) -> Option<&'static str> {
|
||||
if !ids.iter().all(|i| doc.node(*i).is_some_and(|n| matches!(n.kind, NodeKind::Component { .. }))) {
|
||||
return Err(bad(cmd, "select components only"));
|
||||
Some("select components only")
|
||||
} else if ids.iter().any(|i| variant_set(doc, *i).is_some()) {
|
||||
Some("components that are variants already can't be combined again")
|
||||
} else if ids.iter().any(|i| doc.is_remote(*i)) {
|
||||
Some(crate::library::READ_ONLY)
|
||||
} else if ids.iter().any(|i| inside_instance(doc, *i)) {
|
||||
Some(INSIDE_INSTANCE)
|
||||
} else if roots(doc, ids).len() != ids.len() {
|
||||
Some("a component can't be combined with one inside it")
|
||||
} else {
|
||||
None
|
||||
}
|
||||
let ids = z_sorted(doc, &ids);
|
||||
let set = wrap_in_set(doc, &ids)?;
|
||||
}
|
||||
|
||||
/// Whether "Combine as variants" is on offer for `ids` (menus and the Design panel): two or more
|
||||
/// main components that [`combine_refusal`] lets through.
|
||||
pub fn can_combine_variants(doc: &Document, ids: &[NodeId]) -> bool {
|
||||
ids.len() >= 2 && combine_refusal(doc, ids).is_none() && ids.iter().collect::<BTreeSet<_>>().len() == ids.len()
|
||||
}
|
||||
|
||||
/// Whether "Add variant" is on offer for `id`: a component set with variants, a variant, or a main
|
||||
/// component not in a set yet (it becomes one); never a library copy or inside an instance.
|
||||
pub fn can_add_variant(doc: &Document, id: NodeId) -> bool {
|
||||
let Some(n) = doc.node(id) else { return false };
|
||||
let local = !doc.is_remote(id) && !inside_instance(doc, id);
|
||||
match n.kind {
|
||||
NodeKind::ComponentSet { .. } => {
|
||||
local && n.children.iter().any(|c| doc.node(*c).is_some_and(|c| matches!(c.kind, NodeKind::Component { .. })))
|
||||
}
|
||||
NodeKind::Component { .. } => local,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Wrap `ids` (z-sorted main components) in a new component set, naming them "Property 1=Default",
|
||||
/// "Property 1=VariantN" unless already in prop=value form. Returns the set.
|
||||
fn make_variant_set(cmd: &str, doc: &mut Document, ids: &[NodeId]) -> Result<NodeId> {
|
||||
let set = wrap_in_set(cmd, doc, ids)?;
|
||||
for (k, i) in ids.iter().enumerate() {
|
||||
// Name variants "Property 1=Variant N" unless already in prop=value form.
|
||||
let node = doc.get_mut(*i)?;
|
||||
if !node.name.contains('=') {
|
||||
node.name = format!("Property 1={}", if k == 0 { "Default".to_string() } else { format!("Variant{}", k + 1) });
|
||||
}
|
||||
}
|
||||
Ok(set)
|
||||
}
|
||||
|
||||
fn combine(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "component.combineVariants";
|
||||
let mut seen = BTreeSet::new();
|
||||
let ids: Vec<NodeId> = req_targets(cmd, s, p)?.into_iter().filter(|i| seen.insert(*i)).collect();
|
||||
let doc = s.doc_mut()?;
|
||||
if let Some(why) = combine_refusal(doc, &ids) {
|
||||
return Err(EngineError::Disabled(cmd.into(), why.into()));
|
||||
}
|
||||
let ids = z_sorted(doc, &ids);
|
||||
let set = make_variant_set(cmd, doc, &ids)?;
|
||||
s.set_selection(vec![set]);
|
||||
Ok(json!({"id": set}))
|
||||
}
|
||||
@@ -397,15 +454,19 @@ fn fit_set(doc: &mut Document, set: NodeId) {
|
||||
|
||||
fn add_variant(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "component.addVariant";
|
||||
let t = targets(s, p).first().copied().ok_or_else(|| bad(cmd, "select a component set or variant"))?;
|
||||
let t = targets(s, p).first().copied().ok_or_else(|| bad(cmd, "select a component set or component"))?;
|
||||
let doc = s.doc_mut()?;
|
||||
let (set, base) = match doc.get(t)?.kind {
|
||||
NodeKind::ComponentSet { .. } => {
|
||||
let last = doc.get(t)?.children.last().copied().ok_or_else(|| bad(cmd, "the component set is empty"))?;
|
||||
(t, last)
|
||||
}
|
||||
NodeKind::Component { .. } => (variant_set(doc, t).ok_or_else(|| bad(cmd, "the component isn't a variant (combine as variants first)"))?, t),
|
||||
_ => return Err(bad(cmd, "select a component set or variant")),
|
||||
NodeKind::Component { .. } => match variant_set(doc, t) {
|
||||
Some(set) => (set, t),
|
||||
// A main component on its own becomes a set of itself, then gains the new variant.
|
||||
None => (make_variant_set(cmd, doc, &[t])?, t),
|
||||
},
|
||||
_ => return Err(bad(cmd, "select a component set or component")),
|
||||
};
|
||||
let options = variant_options(doc, set);
|
||||
let mut pairs = variant_props(&doc.get(base)?.name);
|
||||
@@ -472,6 +533,7 @@ fn detach(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// Own layers lose their link (slot content stays as it is); nested instances stay
|
||||
// instances of their own mains.
|
||||
let nested = unlink_layers(doc, i, Some(i));
|
||||
keep_main_modes(doc, i)?;
|
||||
let n = doc.get_mut(i)?;
|
||||
n.kind = NodeKind::Frame;
|
||||
n.source = None;
|
||||
@@ -491,6 +553,19 @@ fn detach(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({"ids": out}))
|
||||
}
|
||||
|
||||
/// Make the variable modes instance `i` takes from its main its own (it's being detached).
|
||||
fn keep_main_modes(doc: &mut Document, i: NodeId) -> Result<()> {
|
||||
let modes = match doc.get(i)?.kind {
|
||||
NodeKind::Instance { component, .. } => doc.node(component).map(|m| m.var_modes.clone()).unwrap_or_default(),
|
||||
_ => return Ok(()),
|
||||
};
|
||||
let own = &mut doc.get_mut(i)?.var_modes;
|
||||
for (c, m) in modes {
|
||||
own.entry(c).or_insert(m);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn reset_instance(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "component.resetInstance";
|
||||
let ids = req_targets(cmd, s, p)?;
|
||||
@@ -608,7 +683,7 @@ fn make_slot(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let owner = Owner::resolve(doc, id).ok_or_else(|| bad(cmd, "the frame isn't inside a component"))?;
|
||||
let name = string(p, "name").unwrap_or_else(|| fresh_prop_name(doc, &owner, &n.name));
|
||||
let preferred = get(p, "preferred").cloned().unwrap_or(Value::Null);
|
||||
add_prop(s, &json!({"id": id, "layer": id, "name": name, "type": "slot", "preferred": preferred}))
|
||||
add_prop_for(cmd, s, &json!({"id": id, "layer": id, "name": name, "type": "slot", "preferred": preferred}))
|
||||
}
|
||||
|
||||
fn remove_slot(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -711,6 +786,38 @@ fn push_changes(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({"layers": pushed}))
|
||||
}
|
||||
|
||||
fn restore_prop_defs(doc: &Document, props: &BTreeMap<String, Value>) -> BTreeMap<String, PropDef> {
|
||||
let mut defs = BTreeMap::new();
|
||||
for (k, v) in props {
|
||||
let def = match v {
|
||||
Value::Bool(b) => PropDef::Boolean { default: *b },
|
||||
Value::String(t) => PropDef::Text { default: t.clone() },
|
||||
other => match id_of(other) {
|
||||
Some(c) if doc.contains(c) => PropDef::InstanceSwap { default: c, preferred: Vec::new() },
|
||||
_ => continue,
|
||||
},
|
||||
};
|
||||
defs.insert(k.clone(), def);
|
||||
}
|
||||
defs
|
||||
}
|
||||
|
||||
fn restore_id_map(doc: &mut Document, i: NodeId, component: NodeId, sub: &[NodeId]) -> BTreeMap<NodeId, NodeId> {
|
||||
let mut map: BTreeMap<NodeId, NodeId> = BTreeMap::new();
|
||||
let mut used: BTreeSet<NodeId> = BTreeSet::new();
|
||||
map.insert(i, component);
|
||||
used.insert(component);
|
||||
for d in sub.iter().filter(|d| **d != i) {
|
||||
let src = doc.node(*d).and_then(|n| n.source);
|
||||
let nid = match src {
|
||||
Some(s) if !doc.contains(s) && used.insert(s) => s,
|
||||
_ => doc.alloc_id(),
|
||||
};
|
||||
map.insert(*d, nid);
|
||||
}
|
||||
map
|
||||
}
|
||||
|
||||
fn restore_main(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "component.restoreMain";
|
||||
let i = targets(s, p).first().copied().ok_or_else(|| bad(cmd, "select an instance"))?;
|
||||
@@ -729,35 +836,13 @@ fn restore_main(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// The main comes back with its old id (other instances point to it) and its layers with the
|
||||
// ids their copies mirror, so every instance re-links by source.
|
||||
let sub = doc.subtree(i);
|
||||
let mut map: BTreeMap<NodeId, NodeId> = BTreeMap::new();
|
||||
let mut used: BTreeSet<NodeId> = BTreeSet::new();
|
||||
map.insert(i, component);
|
||||
used.insert(component);
|
||||
for d in sub.iter().filter(|d| **d != i) {
|
||||
let src = doc.node(*d).and_then(|n| n.source);
|
||||
let nid = match src {
|
||||
Some(s) if !doc.contains(s) && used.insert(s) => s,
|
||||
_ => doc.alloc_id(),
|
||||
};
|
||||
map.insert(*d, nid);
|
||||
}
|
||||
let map = restore_id_map(doc, i, component, &sub);
|
||||
let mut nodes = Vec::new();
|
||||
let mut nested = Vec::new();
|
||||
for d in &sub {
|
||||
let Some(mut n) = doc.node(*d).cloned() else { continue };
|
||||
if *d == i {
|
||||
let mut defs = BTreeMap::new();
|
||||
for (k, v) in &props {
|
||||
let def = match v {
|
||||
Value::Bool(b) => PropDef::Boolean { default: *b },
|
||||
Value::String(t) => PropDef::Text { default: t.clone() },
|
||||
other => match id_of(other) {
|
||||
Some(c) if doc.contains(c) => PropDef::InstanceSwap { default: c, preferred: Vec::new() },
|
||||
_ => continue,
|
||||
},
|
||||
};
|
||||
defs.insert(k.clone(), def);
|
||||
}
|
||||
let defs = restore_prop_defs(doc, &props);
|
||||
n.kind = NodeKind::Component { description: String::new(), props: defs };
|
||||
n.source = None;
|
||||
n.overrides.clear();
|
||||
@@ -1039,7 +1124,12 @@ fn valid_prop_name(cmd: &str, name: &str) -> Result<String> {
|
||||
}
|
||||
|
||||
fn add_prop(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "component.addProperty";
|
||||
add_prop_for("component.addProperty", s, p)
|
||||
}
|
||||
|
||||
/// Add a component property for command `cmd`, which errors name (`component.makeSlot` adds its
|
||||
/// slot property here).
|
||||
fn add_prop_for(cmd: &str, s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let target = targets(s, p).first().copied().ok_or_else(|| bad(cmd, "select a component"))?;
|
||||
let name = valid_prop_name(cmd, &req_string(cmd, p, "name")?)?;
|
||||
let t = req_string(cmd, p, "type")?;
|
||||
@@ -1061,7 +1151,8 @@ fn add_prop(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()))
|
||||
@@ -1084,7 +1175,7 @@ fn add_prop(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
.unwrap_or_else(|| "Default".into());
|
||||
let set = match owner {
|
||||
Owner::Set(s) => s,
|
||||
Owner::Comp(c) => wrap_in_set(doc, &[c])?,
|
||||
Owner::Comp(c) => wrap_in_set(cmd, doc, &[c])?,
|
||||
};
|
||||
for c in Owner::Set(set).components(doc) {
|
||||
let cn = doc.get_mut(c)?;
|
||||
@@ -1165,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(())
|
||||
}
|
||||
|
||||
@@ -1334,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()
|
||||
}
|
||||
|
||||
|
||||
@@ -83,7 +83,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
spec!(
|
||||
"image.place",
|
||||
"Place image",
|
||||
"{data (base64) | path, x?, y?, width?, height?, parent?, mode? (fill|fit|crop|tile)} → {id, image, width, height, imageProfile (its embedded colour profile; null: taken as sRGB)}",
|
||||
"{data (base64) | path, x?, y?, width?, height?, parent?, target? (an existing layer: the image is added on top of its fills instead of making a layer), mode? (fill|fit|crop|tile)} → {id, image, width, height, imageProfile (its embedded colour profile; null: taken as sRGB)}",
|
||||
true,
|
||||
place_image
|
||||
),
|
||||
@@ -111,7 +111,8 @@ pub fn frame_at(doc: &Document, page: NodeId, pt: Point, exclude: &[NodeId]) ->
|
||||
return None;
|
||||
}
|
||||
let n = doc.node(id)?;
|
||||
if !n.visible || n.locked {
|
||||
// Board objects (stickies, shapes with text) never take layers in.
|
||||
if !n.visible || n.locked || n.is_board_atom() {
|
||||
return None;
|
||||
}
|
||||
let is_container = n.kind.is_frame_like() || matches!(n.kind, NodeKind::Section { .. });
|
||||
@@ -166,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
|
||||
@@ -228,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 {
|
||||
@@ -372,9 +394,28 @@ fn read_path(path: &str) -> Result<Vec<u8>> {
|
||||
Err(crate::EngineError::Other(format!("{path}: files can't be read in the browser")))
|
||||
}
|
||||
|
||||
/// Layers that can show an image inside their own outline.
|
||||
pub fn accepts_image_fill(kind: &NodeKind) -> bool {
|
||||
kind.is_shape() || kind.is_frame_like() || matches!(kind, NodeKind::Text(_) | NodeKind::BooleanOp { .. })
|
||||
}
|
||||
|
||||
/// Add an image fill on top of the layer's fills: the others keep their place and variable
|
||||
/// bindings; a linked fill style no longer describes the list, so the link goes.
|
||||
fn add_image_fill(node: &mut Node, image: String, mode: ImageScaleMode) {
|
||||
node.fills.push(Paint::image(image, mode));
|
||||
node.styles.fill = None;
|
||||
}
|
||||
|
||||
/// Place an image as a rectangle with an image fill (Figma's image layer).
|
||||
pub fn place_image(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "image.place";
|
||||
let target = opt_id(cmd, p, "target")?;
|
||||
if let Some(id) = target {
|
||||
let node = s.doc()?.node(id).ok_or(crate::EngineError::NoNode(id))?;
|
||||
if !accepts_image_fill(&node.kind) {
|
||||
return Err(bad(cmd, "`target` must be a layer that can have a fill"));
|
||||
}
|
||||
}
|
||||
let bytes = if let Some(b64) = string(p, "data") {
|
||||
use base64::Engine as _;
|
||||
let b64 = b64.split_once(',').map(|(_, d)| d.to_string()).unwrap_or(b64);
|
||||
@@ -390,6 +431,14 @@ pub fn place_image(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
"tile" => ImageScaleMode::Tile,
|
||||
_ => ImageScaleMode::Fill,
|
||||
};
|
||||
if let Some(id) = target {
|
||||
let doc = s.doc_mut()?;
|
||||
let (hash, _, _) = figcraft_format::add_image(doc, bytes).map_err(|e| bad(cmd, e.to_string()))?;
|
||||
let profile = figcraft_render::images::profile_name(&hash, doc.images.get(&hash));
|
||||
let node = doc.get_mut(id)?;
|
||||
add_image_fill(node, hash.clone(), mode);
|
||||
return Ok(json!({"id": id, "image": hash, "width": node.width, "height": node.height, "imageProfile": profile}));
|
||||
}
|
||||
let page = s.page()?;
|
||||
let parent = crate::params::opt_id(cmd, p, "parent")?.unwrap_or(page);
|
||||
let ds = s.ds_mut()?;
|
||||
|
||||
+241
-42
@@ -4,11 +4,11 @@ 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::*;
|
||||
use crate::{EngineError, Result, Session, bad, spec};
|
||||
use crate::{BatchStep, EngineError, Result, Session, bad, spec};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
@@ -18,13 +18,19 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
spec!(
|
||||
"edit.duplicate",
|
||||
"Duplicate",
|
||||
"{ids?, dx?, dy? (page offset; default: the offset of the last duplicate when ids are its copies, still selected; else top-level frames to the right with a 100 gap, everything else in place)}",
|
||||
"{ids?, dx?, dy? (page offset; default: the offset of the last duplicate when ids are its copies, still selected; else top-level frames to the right with a 100 gap, everything else in place)} a main component's copy is an instance of it (a variant stays a new variant in its set)",
|
||||
true,
|
||||
duplicate
|
||||
),
|
||||
spec!("edit.copy", "Copy", "{ids?}", false, copy),
|
||||
spec!("edit.cut", "Cut", "{ids?}", true, cut),
|
||||
spec!("edit.paste", "Paste", "{parent?, x?, y? (page coordinates of the pasted top-left)}", true, paste),
|
||||
spec!(
|
||||
"edit.paste",
|
||||
"Paste",
|
||||
"{parent?, x?, y? (page coordinates of the pasted top-left)} a copied main component pastes as an instance of it within its file (cut ones, and pastes into other files, keep the component)",
|
||||
true,
|
||||
paste
|
||||
),
|
||||
spec!(
|
||||
"edit.pasteDesignLayers",
|
||||
"Paste design layers",
|
||||
@@ -210,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;
|
||||
@@ -223,6 +235,22 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
spec!("stroke.remove", "Remove stroke", "{ids?, index}", true, |s, p| paint_list(s, p, false, false)),
|
||||
spec!("fill.set", "Set fill", "{ids?, index?, paint | color, opacity?, visible?, blend?}", true, |s, p| paint_set(s, p, true)),
|
||||
spec!("stroke.set", "Set stroke", "{ids?, index?, paint | color, opacity?, visible?, blend?}", true, |s, p| paint_set(s, p, false)),
|
||||
spec!("fill.copy", "Copy fill", "{id?, index? (default the top fill)}", false, |s, p| paint_copy(s, p, true)),
|
||||
spec!("fill.paste", "Paste fill", "{ids?, index? (replace the fill there; without: add one)}", true, |s, p| paint_paste(s, p, true)),
|
||||
spec!(
|
||||
"stroke.copy",
|
||||
"Copy stroke",
|
||||
"{id?, index? (default the top stroke)} its paint and the stroke's weight, alignment, sides, caps, joins and dashes",
|
||||
false,
|
||||
|s, p| paint_copy(s, p, false)
|
||||
),
|
||||
spec!(
|
||||
"stroke.paste",
|
||||
"Paste stroke",
|
||||
"{ids?, index? (replace the stroke there; without: add one)} the copied stroke's settings come with its paint",
|
||||
true,
|
||||
|s, p| paint_paste(s, p, false)
|
||||
),
|
||||
spec!(
|
||||
"fill.pattern",
|
||||
"Pattern fill",
|
||||
@@ -261,7 +289,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
}),
|
||||
spec!("effect.set", "Set effect", "{ids?, index, effect {..}}", true, |s, p| {
|
||||
let i = req_index("effect.set", p, "index")?;
|
||||
let e: Effect = parse("effect.set", p, "effect")?.ok_or_else(|| bad("effect.set", "`effect` required"))?;
|
||||
let e = super::props::effect_from("effect.set", p, "effect")?.ok_or_else(|| bad("effect.set", "`effect` required"))?;
|
||||
for id in req_targets("effect.set", s, p)? {
|
||||
let n = s.doc_mut()?.get_mut(id)?;
|
||||
if let Some(slot) = n.effects.get_mut(i) {
|
||||
@@ -270,6 +298,8 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
}
|
||||
ok()
|
||||
}),
|
||||
spec!("effect.copy", "Copy effect", "{id?, index? (default the top effect)}", false, effect_copy),
|
||||
spec!("effect.paste", "Paste effect", "{ids?, index? (replace the effect there; without: add one)}", true, effect_paste),
|
||||
spec!("grid.add", "Add layout grid", "{ids?, grid? {kind: grid|columns|rows, ..}}", true, |s, p| {
|
||||
let g: LayoutGrid = parse("grid.add", p, "grid")?.unwrap_or_default();
|
||||
for id in req_targets("grid.add", s, p)? {
|
||||
@@ -330,15 +360,16 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
}
|
||||
ok()
|
||||
}),
|
||||
spec!("command.batch", "Run several commands", "{label?, commands: [{command, params}]} (one undo step)", false, |s, p| {
|
||||
spec!("command.batch", "Run several commands", "{label?, commands: [{command, params, as?}]} (one undo step)", false, |s, p| {
|
||||
let label = string(p, "label").unwrap_or_else(|| "Batch".into());
|
||||
let cmds: Vec<(String, Value)> = match get(p, "commands") {
|
||||
let cmds: Vec<BatchStep> = match get(p, "commands") {
|
||||
Some(Value::Array(a)) => {
|
||||
let mut out = Vec::new();
|
||||
for (i, c) in a.iter().enumerate() {
|
||||
let cmd =
|
||||
c.get("command").and_then(Value::as_str).ok_or_else(|| bad("command.batch", format!("step {i} has no `command`")))?;
|
||||
out.push((cmd.to_string(), c.get("params").cloned().unwrap_or(json!({}))));
|
||||
let params = c.get("params").cloned().unwrap_or(json!({}));
|
||||
out.push(BatchStep { name: crate::batch::step_name(i, c)?, ..BatchStep::new(cmd, params) });
|
||||
}
|
||||
out
|
||||
}
|
||||
@@ -466,7 +497,7 @@ fn duplicate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let top_frame = doc.node(parent).is_some_and(Node::is_page) && n.kind.is_frame_like();
|
||||
let idx = doc.index_in_parent(i).map(|x| x + 1);
|
||||
let w = n.width;
|
||||
let nid = doc.duplicate_into(i, parent, idx)?;
|
||||
let nid = crate::components::copy_in_file(doc, i, parent, idx)?;
|
||||
let (dx, dy) = match (num(p, "dx"), num(p, "dy")) {
|
||||
(Some(dx), dy) => (dx, dy.unwrap_or(0.0)),
|
||||
(None, Some(dy)) => (0.0, dy),
|
||||
@@ -481,12 +512,16 @@ fn duplicate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
nn.transform = Affine::translate((dx, dy)) * nn.transform;
|
||||
}
|
||||
}
|
||||
if rename {
|
||||
// An instance keeps the name instances get by default.
|
||||
if rename && !matches!(doc.get(nid)?.kind, NodeKind::Instance { .. }) {
|
||||
let nn = doc.get_mut(nid)?;
|
||||
nn.name = bump_name(&nn.name);
|
||||
}
|
||||
out.push(nid);
|
||||
}
|
||||
// Board connectors copied with what they join join the copies.
|
||||
let map = crate::board::copy_map(doc, &ids, &out);
|
||||
crate::board::rejoin(doc, &crate::board::copied_items(doc, &map), &map, false);
|
||||
s.set_selection(out.clone());
|
||||
s.ds_mut()?.last_duplicate = Some(Duplicated { sources: ids, copies: out.clone() });
|
||||
Ok(json!({"ids": out}))
|
||||
@@ -515,9 +550,17 @@ pub fn copy_to_clipboard(s: &mut Session, cmd: &str, ids: &[NodeId]) -> Result<(
|
||||
s.clipboard.source = Some(source);
|
||||
s.clipboard.source_path = source_path;
|
||||
s.clipboard.source_doc = uid;
|
||||
s.clipboard.copied_components = nodes
|
||||
.iter()
|
||||
.filter(|(_, sub)| {
|
||||
cmd == "edit.copy" && sub.first().is_some_and(|n| matches!(n.kind, NodeKind::Component { .. } | NodeKind::ComponentSet { .. }))
|
||||
})
|
||||
.map(|(root, _)| *root)
|
||||
.collect();
|
||||
s.clipboard.nodes = nodes;
|
||||
s.clipboard.images = images;
|
||||
s.clipboard.bounds = bounds;
|
||||
s.clipboard.layers_generation = s.clipboard.layers_generation.wrapping_add(1);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -577,6 +620,10 @@ pub fn design_clipboard_buffer(html: &str) -> Option<Vec<u8>> {
|
||||
figcraft_format::fig::is_fig(&bytes).then_some(bytes)
|
||||
}
|
||||
|
||||
/// Paste design layers from clipboard HTML or base64 `data`: decode and reject missing,
|
||||
/// invalid or oversized data, load a temporary `.fig`, take its top-level page layers,
|
||||
/// store them as a foreign-file clipboard source with a bumped layers generation, then
|
||||
/// delegate to `paste` and append import warnings.
|
||||
fn paste_design_layers(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const CMD: &str = "edit.pasteDesignLayers";
|
||||
let bytes = match (string(p, "html"), string(p, "data")) {
|
||||
@@ -591,9 +638,28 @@ fn paste_design_layers(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return Err(EngineError::Disabled(CMD.into(), "the copied layers came in empty".into()));
|
||||
}
|
||||
let (nodes, images, bounds) = copied(&doc, &tops);
|
||||
// A design-app paste replaces the copied layers, not the copied rows: a `fill.copy` before it
|
||||
// still pastes afterwards.
|
||||
let props = s.clipboard.props.take();
|
||||
let paint = s.clipboard.paint.take();
|
||||
let stroke = s.clipboard.stroke.take();
|
||||
let effect = s.clipboard.effect.take();
|
||||
// No open file has id 0. So paste treats this copy as a copy from a different file.
|
||||
s.clipboard = crate::Clipboard { nodes, images, props, bounds, source_doc: 0, source: Some(doc), source_path: None };
|
||||
let layers_generation = s.clipboard.layers_generation.wrapping_add(1);
|
||||
s.clipboard = crate::Clipboard {
|
||||
nodes,
|
||||
images,
|
||||
props,
|
||||
paint,
|
||||
stroke,
|
||||
effect,
|
||||
bounds,
|
||||
source_doc: 0,
|
||||
source: Some(doc),
|
||||
source_path: None,
|
||||
copied_components: BTreeSet::new(),
|
||||
layers_generation,
|
||||
};
|
||||
let mut out = paste(s, p)?;
|
||||
if !other.issues.is_empty() {
|
||||
out["warnings"] = json!(other.issues);
|
||||
@@ -623,6 +689,7 @@ pub fn paste_into(s: &mut Session, parent: NodeId, at: Option<(f64, f64)>) -> Re
|
||||
}
|
||||
// From another file: its instances follow the mains this file can have.
|
||||
let foreign = clip.source.as_ref().filter(|_| s.ds().is_ok_and(|d| d.uid != clip.source_doc));
|
||||
let copy_local = s.ds()?.uid == clip.source_doc;
|
||||
let doc = s.doc_mut()?;
|
||||
for (k, v) in &clip.images {
|
||||
doc.images.entry(k.clone()).or_insert_with(|| v.clone());
|
||||
@@ -636,6 +703,16 @@ pub fn paste_into(s: &mut Session, parent: NodeId, at: Option<(f64, f64)>) -> Re
|
||||
let mut out = Vec::new();
|
||||
let mut all = BTreeMap::new();
|
||||
for (root, nodes) in &clip.nodes {
|
||||
if copy_local
|
||||
&& clip.copied_components.contains(root)
|
||||
&& let Some(n) = nodes.first().filter(|n| matches!(n.kind, NodeKind::Component { .. }))
|
||||
&& doc.node(*root).is_some_and(|n| matches!(n.kind, NodeKind::Component { .. }))
|
||||
{
|
||||
let instance = crate::components::create_instance(doc, *root, parent, 0.0, 0.0)?;
|
||||
doc.get_mut(instance)?.transform = doc.world_transform(parent).inverse() * shift * n.transform;
|
||||
out.push(instance);
|
||||
continue;
|
||||
}
|
||||
let mut map = BTreeMap::new();
|
||||
for n in nodes {
|
||||
map.insert(n.id, doc.alloc_id());
|
||||
@@ -649,6 +726,10 @@ pub fn paste_into(s: &mut Session, parent: NodeId, at: Option<(f64, f64)>) -> Re
|
||||
out.push(doc.paste_nodes(&nodes, &map, *root, into, None)?);
|
||||
all.extend(map);
|
||||
}
|
||||
// Board connectors pasted with what they join join the pasted copies.
|
||||
let originals: Vec<(NodeId, figcraft_doc::BoardItem)> =
|
||||
clip.nodes.iter().flat_map(|(_, nodes)| nodes).filter_map(|n| Some((*all.get(&n.id)?, n.board.clone()?))).collect();
|
||||
crate::board::rejoin(doc, &originals, &all, !copy_local);
|
||||
if let Some(src) = foreign {
|
||||
crate::library::adopt_pasted(doc, src, clip.source_path.as_deref(), &all)?;
|
||||
}
|
||||
@@ -661,10 +742,15 @@ fn paste(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// else in the frame's centre; otherwise in place on the page (or the viewport centre).
|
||||
let sel = s.selection();
|
||||
let doc = s.doc()?;
|
||||
let copy_local = s.ds()?.uid == s.clipboard.source_doc;
|
||||
let target = id(p, "parent").or_else(|| {
|
||||
sel.first()
|
||||
.copied()
|
||||
.filter(|i| sel.len() == 1 && doc.node(*i).is_some_and(|n| n.kind.is_frame_like() && !matches!(n.kind, NodeKind::Instance { .. })))
|
||||
.filter(|i| {
|
||||
sel.len() == 1
|
||||
&& doc.node(*i).is_some_and(|n| n.kind.is_frame_like() && !matches!(n.kind, NodeKind::Instance { .. }) && !n.is_board_atom())
|
||||
})
|
||||
.filter(|i| !copy_local || !s.clipboard.copied_components.iter().any(|root| doc.is_ancestor(*root, *i)))
|
||||
});
|
||||
let parent = target.unwrap_or(page);
|
||||
let at = match (num(p, "x"), num(p, "y")) {
|
||||
@@ -931,9 +1017,13 @@ pub fn opacity_from_digits(digits: &str) -> Option<f32> {
|
||||
fn opacity_keys(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let digits = req_string("object.opacityKeys", p, "digits")?;
|
||||
let o = opacity_from_digits(&digits).ok_or_else(|| bad("object.opacityKeys", "`digits` must be one or two digits"))?;
|
||||
for i in req_targets("object.opacityKeys", s, p)? {
|
||||
s.doc_mut()?.get_mut(i)?.opacity = o;
|
||||
let ids = req_targets("object.opacityKeys", s, p)?;
|
||||
let doc = s.doc_mut()?;
|
||||
let before = super::styles::bound_before(doc, &ids);
|
||||
for i in ids {
|
||||
doc.get_mut(i)?.opacity = o;
|
||||
}
|
||||
super::styles::detach_edited(doc, &before);
|
||||
Ok(json!({"opacity": o}))
|
||||
}
|
||||
|
||||
@@ -1127,6 +1217,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") {
|
||||
@@ -1157,34 +1250,46 @@ 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 {
|
||||
for (i, q) in pinned {
|
||||
let d = o - doc.world_transform(i) * q;
|
||||
@@ -1268,9 +1373,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));
|
||||
@@ -1331,10 +1437,12 @@ fn paint_set(s: &mut Session, p: &Value, fills: bool) -> Result<Value> {
|
||||
slot.kind = figcraft_color::PaintKind::Solid { color: c.with_alpha(1.0) };
|
||||
if c.a < 1.0 {
|
||||
slot.opacity = c.a;
|
||||
slot.color_alpha = c.a;
|
||||
}
|
||||
}
|
||||
if let Some(o) = num(p, "opacity") {
|
||||
slot.opacity = (if o > 1.0 { o / 100.0 } else { o }).clamp(0.0, 1.0) as f32;
|
||||
slot.color_alpha = 1.0;
|
||||
}
|
||||
if let Some(v) = boolean(p, "visible") {
|
||||
slot.visible = v;
|
||||
@@ -1342,7 +1450,7 @@ fn paint_set(s: &mut Session, p: &Value, fills: bool) -> Result<Value> {
|
||||
if let Some(b) = blend(p, "blend") {
|
||||
slot.blend = b;
|
||||
}
|
||||
super::lists::remap_stop_bindings(n, fills, at, &old);
|
||||
super::lists::rebind_edited_paint(n, fills, at, &old);
|
||||
if fills {
|
||||
n.styles.fill = None;
|
||||
} else {
|
||||
@@ -1352,6 +1460,61 @@ fn paint_set(s: &mut Session, p: &Value, fills: bool) -> Result<Value> {
|
||||
ok()
|
||||
}
|
||||
|
||||
/// Copy paint `index` (the top one by default) of the first target into the clipboard. A stroke
|
||||
/// takes its node-wide settings (weight, alignment, sides, caps, joins, dashes) along.
|
||||
fn paint_copy(s: &mut Session, p: &Value, fills: bool) -> Result<Value> {
|
||||
let cmd = if fills { "fill.copy" } else { "stroke.copy" };
|
||||
let id = targets(s, p).first().copied().ok_or_else(|| bad(cmd, "select a layer"))?;
|
||||
let at = opt_index(cmd, p, "index")?;
|
||||
let (paint, stroke) = {
|
||||
let doc = s.doc()?;
|
||||
let n = doc.get(id)?;
|
||||
let list = if fills { &n.fills } else { &n.strokes };
|
||||
let at = at.unwrap_or(list.len().saturating_sub(1));
|
||||
let paint = list.get(at).cloned().ok_or_else(|| bad(cmd, format!("no {} at index {at}", if fills { "fill" } else { "stroke" })))?;
|
||||
(paint, (!fills).then(|| n.stroke.clone()))
|
||||
};
|
||||
s.clipboard.paint = Some((fills, paint.clone()));
|
||||
s.clipboard.stroke = stroke;
|
||||
serde_json::to_value(&paint).map_err(|e| bad(cmd, e.to_string()))
|
||||
}
|
||||
|
||||
/// Paste the copied paint onto the targets: replacing paint `index` there, or adding one. A
|
||||
/// stroke's copied settings go onto each target too.
|
||||
fn paint_paste(s: &mut Session, p: &Value, fills: bool) -> Result<Value> {
|
||||
let cmd = if fills { "fill.paste" } else { "stroke.paste" };
|
||||
let Some((copied_fills, paint)) = s.clipboard.paint.clone() else {
|
||||
return Err(EngineError::Disabled(cmd.into(), "no copied fill or stroke".into()));
|
||||
};
|
||||
if copied_fills != fills {
|
||||
let what = if fills { "stroke" } else { "fill" };
|
||||
return Err(EngineError::Disabled(cmd.into(), format!("the copied paint is a {what}")));
|
||||
}
|
||||
let ids = req_targets(cmd, s, p)?;
|
||||
// A copied pattern pointing at a target or a layer inside it would make the layer pattern
|
||||
// itself; `fill.pattern` refuses the same, and the renderer would drop the pattern.
|
||||
if let figcraft_color::PaintKind::Pattern(pt) = &paint.kind {
|
||||
let doc = s.doc()?;
|
||||
if ids.iter().any(|t| doc.is_ancestor(*t, NodeId(pt.source))) {
|
||||
return Err(bad(cmd, "a layer can't be filled with itself or a layer inside it"));
|
||||
}
|
||||
}
|
||||
let mut q = json!({"ids": ids, "paint": serde_json::to_value(paint).map_err(|e| bad(cmd, e.to_string()))?});
|
||||
let out = match opt_index(cmd, p, "index")? {
|
||||
Some(at) => {
|
||||
q["index"] = json!(at);
|
||||
paint_set(s, &q, fills)?
|
||||
}
|
||||
None => paint_list(s, &q, fills, true)?,
|
||||
};
|
||||
if let Some(stroke) = s.clipboard.stroke.clone().filter(|_| !fills) {
|
||||
for id in ids {
|
||||
s.doc_mut()?.get_mut(id)?.stroke = stroke.clone();
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn pattern_fill(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const CMD: &str = "fill.pattern";
|
||||
let src = req_id(CMD, p, "source")?;
|
||||
@@ -1376,8 +1539,8 @@ fn pattern_fill(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
fn effect_add(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let e: Effect = match get(p, "effect") {
|
||||
Some(v) => serde_json::from_value(v.clone()).map_err(|e| bad("effect.add", e.to_string()))?,
|
||||
let e: Effect = match super::props::effect_from("effect.add", p, "effect")? {
|
||||
Some(e) => e,
|
||||
None => {
|
||||
let ty = string(p, "type").unwrap_or_else(|| "dropShadow".into());
|
||||
Effect::of_type(&ty).ok_or_else(|| bad("effect.add", format!("unknown effect type `{ty}`")))?
|
||||
@@ -1388,3 +1551,39 @@ fn effect_add(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
ok()
|
||||
}
|
||||
|
||||
/// Copy effect `index` (the top one by default) of the first target into the clipboard.
|
||||
fn effect_copy(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const CMD: &str = "effect.copy";
|
||||
let id = targets(s, p).first().copied().ok_or_else(|| bad(CMD, "select a layer"))?;
|
||||
let at = opt_index(CMD, p, "index")?;
|
||||
let e = {
|
||||
let doc = s.doc()?;
|
||||
let list = &doc.get(id)?.effects;
|
||||
let at = at.unwrap_or(list.len().saturating_sub(1));
|
||||
list.get(at).cloned().ok_or_else(|| bad(CMD, format!("no effect at index {at}")))?
|
||||
};
|
||||
s.clipboard.effect = Some(e.clone());
|
||||
serde_json::to_value(&e).map_err(|err| bad(CMD, err.to_string()))
|
||||
}
|
||||
|
||||
/// Paste the copied effect onto the targets: replacing effect `index` there, or adding one.
|
||||
fn effect_paste(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const CMD: &str = "effect.paste";
|
||||
let Some(e) = s.clipboard.effect.clone() else {
|
||||
return Err(EngineError::Disabled(CMD.into(), "no copied effect".into()));
|
||||
};
|
||||
let at = opt_index(CMD, p, "index")?;
|
||||
for id in req_targets(CMD, s, p)? {
|
||||
let n = s.doc_mut()?.get_mut(id)?;
|
||||
match at {
|
||||
Some(i) => {
|
||||
if let Some(slot) = n.effects.get_mut(i) {
|
||||
*slot = e.clone();
|
||||
}
|
||||
}
|
||||
None => n.effects.push(e.clone()),
|
||||
}
|
||||
}
|
||||
ok()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,373 @@
|
||||
//! Running a session on a board: a countdown everyone sees and votes on objects.
|
||||
//!
|
||||
//! Both live in the document ([`figcraft_doc::facilitation`]) so they reach everyone in a live
|
||||
//! session, and neither is an undo step: undoing an edit leaves the timer and the vote as they are.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use figcraft_doc::{BoardTimer, Document, NodeId, TIMER_MAX_MS, VOTES_MAX, Vote, VoteScope};
|
||||
use figcraft_geom::{Point, Rect};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::board::{require, text_of};
|
||||
use crate::cmd::CommandSpec;
|
||||
use crate::params::{id, num, req_string, req_targets, string};
|
||||
use crate::tools::PointerEvent;
|
||||
use crate::{EngineError, Result, Session, bad, spec};
|
||||
|
||||
/// Minutes a timer starts with, and adds, when none are given.
|
||||
const START_MINUTES: f64 = 5.0;
|
||||
const ADD_MINUTES: f64 = 1.0;
|
||||
/// Votes each person gets when none are given.
|
||||
const VOTES: u32 = 3;
|
||||
/// A vote badge's radius on screen (logical pixels).
|
||||
pub const BADGE_PX: f64 = 11.0;
|
||||
/// The longest a voter name is kept.
|
||||
const MAX_VOTER: usize = 64;
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
spec!(
|
||||
"board.timer",
|
||||
"Timer",
|
||||
"{action: start|pause|resume|add|stop, minutes? (start: default 5; add: default 1; up to 1440)} → the timer's state (as board.timerState) one countdown per board, the same for everyone in a live session",
|
||||
false,
|
||||
timer_cmd
|
||||
),
|
||||
spec!(
|
||||
"board.timerState",
|
||||
"Timer state",
|
||||
"{} → {state: off|running|paused|done, left (ms), text (m:ss), ends? (ms since 1970, while running)}",
|
||||
false,
|
||||
|s, _| {
|
||||
require(s, "board.timerState")?;
|
||||
Ok(timer_json(s.doc()?.timer, now_ms()))
|
||||
}
|
||||
),
|
||||
spec!(
|
||||
"board.vote",
|
||||
"Voting session",
|
||||
"{action: start|cast|retract|end|clear, votes? (start: per person, 1–99, default 3), on? (start: all|stickies|selection; default all), ids? (start with on: selection; default the selection), id (cast, retract: the object), voter? (default you)} → the vote's state (as board.voteResults) one vote at a time; others' votes show once it ends",
|
||||
false,
|
||||
vote_cmd
|
||||
),
|
||||
spec!(
|
||||
"board.voteResults",
|
||||
"Voting results",
|
||||
"{voter?} → {state: off|running|ended, votesPerPerson, on, by, left (your votes left), mine [{id, votes}], voters, results? [{id, name, votes, rank}] (once ended, most votes first)}",
|
||||
false,
|
||||
|s, p| {
|
||||
require(s, "board.voteResults")?;
|
||||
let who = voter(s, p);
|
||||
Ok(vote_json(s.doc()?, &who))
|
||||
}
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
/// Milliseconds since the Unix epoch (0 if the clock is before it).
|
||||
pub fn now_ms() -> u64 {
|
||||
web_time::SystemTime::now().duration_since(web_time::UNIX_EPOCH).map_or(0, |d| u64::try_from(d.as_millis()).unwrap_or(u64::MAX))
|
||||
}
|
||||
|
||||
/// `left` milliseconds as a clock: "4:05", "1:02:03" (whole seconds, rounded up).
|
||||
pub fn clock(left: u64) -> String {
|
||||
let s = left.div_ceil(1000);
|
||||
let (h, m, s) = (s / 3600, s / 60 % 60, s % 60);
|
||||
if h > 0 { format!("{h}:{m:02}:{s:02}") } else { format!("{m}:{s:02}") }
|
||||
}
|
||||
|
||||
/// The timer's state at `now`: off, running, paused or done (run out, until someone stops it).
|
||||
pub fn timer_state(t: Option<BoardTimer>, now: u64) -> &'static str {
|
||||
match t {
|
||||
None => "off",
|
||||
Some(BoardTimer::Paused { .. }) => "paused",
|
||||
Some(t) if t.left(now) == 0 => "done",
|
||||
Some(_) => "running",
|
||||
}
|
||||
}
|
||||
|
||||
fn timer_json(t: Option<BoardTimer>, now: u64) -> Value {
|
||||
let left = t.map_or(0, |t| t.left(now));
|
||||
let ends = match t {
|
||||
Some(BoardTimer::Running { ends }) => Some(ends),
|
||||
_ => None,
|
||||
};
|
||||
json!({"state": timer_state(t, now), "left": left, "text": clock(left), "ends": ends})
|
||||
}
|
||||
|
||||
/// `minutes` (or `default`) as milliseconds: more than 0, at most a day.
|
||||
fn duration(cmd: &str, p: &Value, default: f64) -> Result<u64> {
|
||||
let m = match p.get("minutes").filter(|v| !v.is_null()) {
|
||||
Some(_) => num(p, "minutes").ok_or_else(|| bad(cmd, "`minutes` must be a number"))?,
|
||||
None => default,
|
||||
};
|
||||
let ms = (m * 60_000.0).round();
|
||||
if !(1000.0..=TIMER_MAX_MS as f64).contains(&ms) {
|
||||
return Err(bad(cmd, "`minutes` must be from 1 second to 1440 minutes"));
|
||||
}
|
||||
Ok(ms as u64)
|
||||
}
|
||||
|
||||
/// The timer after `action` at `now` (`Ok(None)`: stopped).
|
||||
pub fn timer_after(cmd: &str, t: Option<BoardTimer>, action: &str, p: &Value, now: u64) -> Result<Option<BoardTimer>> {
|
||||
let off = || EngineError::Disabled(cmd.into(), "there is no timer: start one".into());
|
||||
Ok(match action {
|
||||
"start" => Some(BoardTimer::Running { ends: now.saturating_add(duration(cmd, p, START_MINUTES)?) }),
|
||||
"stop" => None,
|
||||
"pause" => match t {
|
||||
Some(r @ BoardTimer::Running { .. }) if r.left(now) > 0 => Some(BoardTimer::Paused { left: r.left(now) }),
|
||||
_ => return Err(EngineError::Disabled(cmd.into(), "the timer isn't running".into())),
|
||||
},
|
||||
"resume" => match t {
|
||||
Some(BoardTimer::Paused { left }) => Some(BoardTimer::Running { ends: now.saturating_add(left) }),
|
||||
_ => return Err(EngineError::Disabled(cmd.into(), "the timer isn't paused".into())),
|
||||
},
|
||||
"add" => {
|
||||
let more = duration(cmd, p, ADD_MINUTES)?;
|
||||
let left = t.ok_or_else(off)?.left(now).saturating_add(more).min(TIMER_MAX_MS);
|
||||
match t {
|
||||
Some(BoardTimer::Paused { .. }) => Some(BoardTimer::Paused { left }),
|
||||
_ => Some(BoardTimer::Running { ends: now.saturating_add(left) }),
|
||||
}
|
||||
}
|
||||
other => return Err(bad(cmd, format!("unknown action `{other}` (start|pause|resume|add|stop)"))),
|
||||
})
|
||||
}
|
||||
|
||||
fn timer_cmd(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
require(s, "board.timer")?;
|
||||
let action = req_string("board.timer", p, "action")?;
|
||||
let now = now_ms();
|
||||
let t = timer_after("board.timer", s.doc()?.timer, &action, p, now)?;
|
||||
if s.doc()?.timer != t {
|
||||
s.doc_mut()?.timer = t;
|
||||
}
|
||||
Ok(timer_json(t, now))
|
||||
}
|
||||
|
||||
/// Who votes: `voter` from the params, else you as the live session knows you (your client id
|
||||
/// there, so two people of the same name vote apart), else your name.
|
||||
pub fn voter(s: &Session, p: &Value) -> String {
|
||||
if let Some(v) = string(p, "voter").map(|v| v.trim().chars().filter(|c| !c.is_control()).take(MAX_VOTER).collect::<String>())
|
||||
&& !v.is_empty()
|
||||
{
|
||||
return v;
|
||||
}
|
||||
let live = s.ds().ok().filter(|ds| s.live_doc(ds.uid)).and_then(|ds| s.live_of(ds.uid).info.as_ref());
|
||||
match live {
|
||||
Some(info) => format!("peer {}", info.me),
|
||||
None => crate::cmd::comment::user_name(s),
|
||||
}
|
||||
}
|
||||
|
||||
fn running<'a>(cmd: &str, doc: &'a Document) -> Result<&'a Vote> {
|
||||
match &doc.vote {
|
||||
Some(v) if !v.ended => Ok(v),
|
||||
_ => Err(EngineError::Disabled(cmd.into(), "no vote is running".into())),
|
||||
}
|
||||
}
|
||||
|
||||
fn vote_cmd(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const CMD: &str = "board.vote";
|
||||
require(s, CMD)?;
|
||||
let action = req_string(CMD, p, "action")?;
|
||||
let who = voter(s, p);
|
||||
match action.as_str() {
|
||||
"start" => start_vote(s, p, CMD)?,
|
||||
"cast" | "retract" => change_ballot(s, p, CMD, &who, action == "cast")?,
|
||||
"end" => {
|
||||
running(CMD, s.doc()?)?;
|
||||
if let Some(v) = s.doc_mut()?.vote.as_mut() {
|
||||
v.ended = true;
|
||||
}
|
||||
}
|
||||
"clear" => {
|
||||
let doc = s.doc()?;
|
||||
if doc.vote.is_some() || !doc.votes.is_empty() {
|
||||
let doc = s.doc_mut()?;
|
||||
doc.vote = None;
|
||||
doc.votes.clear();
|
||||
}
|
||||
}
|
||||
other => return Err(bad(CMD, format!("unknown action `{other}` (start|cast|retract|end|clear)"))),
|
||||
}
|
||||
Ok(vote_json(s.doc()?, &who))
|
||||
}
|
||||
|
||||
/// `board.vote {action: start}`: a new vote (votes per person, on what), clearing the last results.
|
||||
fn start_vote(s: &mut Session, p: &Value, cmd: &str) -> Result<()> {
|
||||
if s.doc()?.vote.as_ref().is_some_and(|v| !v.ended) {
|
||||
return Err(EngineError::Disabled(cmd.into(), "a vote is already running: end it first".into()));
|
||||
}
|
||||
let per = match p.get("votes").filter(|v| !v.is_null()) {
|
||||
Some(_) => num(p, "votes")
|
||||
.filter(|n| n.fract() == 0.0 && (1.0..=f64::from(VOTES_MAX)).contains(n))
|
||||
.ok_or_else(|| bad(cmd, "`votes` must be a whole number from 1 to 99"))?,
|
||||
None => f64::from(VOTES),
|
||||
} as u32;
|
||||
let scope = match string(p, "on").as_deref().unwrap_or("all") {
|
||||
"all" => VoteScope::All,
|
||||
"stickies" => VoteScope::Stickies,
|
||||
"selection" => {
|
||||
let doc = s.doc()?;
|
||||
let ids: Vec<NodeId> = req_targets(cmd, s, p)?.into_iter().filter(|i| VoteScope::All.takes(doc, *i)).collect();
|
||||
if ids.is_empty() {
|
||||
return Err(bad(cmd, "pick the objects to vote on (not pages)"));
|
||||
}
|
||||
VoteScope::Only { ids }
|
||||
}
|
||||
other => return Err(bad(cmd, format!("unknown `on` `{other}` (all|stickies|selection)"))),
|
||||
};
|
||||
let by = crate::board::author(s);
|
||||
let doc = s.doc_mut()?;
|
||||
doc.votes.clear();
|
||||
doc.vote = Some(Vote { per_person: per, scope, ended: false, by });
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `board.vote {action: cast|retract, id}`: one vote of `who`'s placed on (`cast`) or taken back
|
||||
/// from an object, within the running vote's scope and votes per person.
|
||||
fn change_ballot(s: &mut Session, p: &Value, cmd: &str, who: &str, cast: bool) -> Result<()> {
|
||||
let target = id(p, "id").ok_or_else(|| bad(cmd, "`id` must be the object to vote on"))?;
|
||||
let doc = s.doc()?;
|
||||
let vote = running(cmd, doc)?;
|
||||
let mut ballot: Vec<NodeId> = vote.counted(doc, doc.votes.get(who).map_or(&[], Vec::as_slice)).collect();
|
||||
if cast {
|
||||
if !vote.scope.takes(doc, target) {
|
||||
return Err(match doc.node(target) {
|
||||
None => EngineError::NoNode(target),
|
||||
Some(_) => EngineError::Disabled(cmd.into(), "this vote isn't on that object".into()),
|
||||
});
|
||||
}
|
||||
if ballot.len() >= vote.per_person as usize {
|
||||
return Err(EngineError::Disabled(cmd.into(), "you have no votes left: remove one first".into()));
|
||||
}
|
||||
ballot.push(target);
|
||||
} else {
|
||||
let i =
|
||||
ballot.iter().rposition(|i| *i == target).ok_or_else(|| EngineError::Disabled(cmd.into(), "you have no vote on that object".into()))?;
|
||||
ballot.remove(i);
|
||||
}
|
||||
let votes = &mut s.doc_mut()?.votes;
|
||||
if ballot.is_empty() {
|
||||
votes.remove(who);
|
||||
} else {
|
||||
votes.insert(who.to_string(), ballot);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `who`'s votes per object, in the order first voted on.
|
||||
pub fn mine(doc: &Document, who: &str) -> Vec<(NodeId, u32)> {
|
||||
let (Some(vote), Some(ballot)) = (&doc.vote, doc.votes.get(who)) else { return Vec::new() };
|
||||
let mut out: Vec<(NodeId, u32)> = Vec::new();
|
||||
for id in vote.counted(doc, ballot) {
|
||||
match out.iter_mut().find(|(i, _)| *i == id) {
|
||||
Some((_, n)) => *n += 1,
|
||||
None => out.push((id, 1)),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// What an object is called in the results: its text (a sticky's note), else its name.
|
||||
pub fn object_name(doc: &Document, id: NodeId) -> String {
|
||||
let text = text_of(doc, id).and_then(|t| doc.node(t)).and_then(|n| n.kind.text()).map(|t| t.characters.trim().to_string());
|
||||
let name = text.filter(|t| !t.is_empty()).or_else(|| doc.node(id).map(|n| n.name.clone())).unwrap_or_default();
|
||||
let line = name.lines().next().unwrap_or_default();
|
||||
let short: String = line.chars().take(60).collect();
|
||||
if short.len() < name.len() { format!("{short}…") } else { short }
|
||||
}
|
||||
|
||||
fn vote_json(doc: &Document, who: &str) -> Value {
|
||||
let Some(vote) = &doc.vote else { return json!({"state": "off"}) };
|
||||
let on = match &vote.scope {
|
||||
VoteScope::All => json!("all"),
|
||||
VoteScope::Stickies => json!("stickies"),
|
||||
VoteScope::Only { ids } => json!({"selection": ids}),
|
||||
};
|
||||
let mine = mine(doc, who);
|
||||
let used: u32 = mine.iter().map(|m| m.1).sum();
|
||||
let voters = doc.votes.values().filter(|b| vote.counted(doc, b).next().is_some()).count();
|
||||
let mut out = json!({
|
||||
"state": if vote.ended { "ended" } else { "running" },
|
||||
"votesPerPerson": vote.per_person,
|
||||
"on": on,
|
||||
"by": vote.by,
|
||||
"left": vote.per_person.saturating_sub(used),
|
||||
"mine": mine.iter().map(|(id, n)| json!({"id": id, "votes": n})).collect::<Vec<_>>(),
|
||||
"voters": voters,
|
||||
});
|
||||
if vote.ended {
|
||||
let results: Vec<Value> =
|
||||
ranked(doc, vote).into_iter().map(|(rank, id, n)| json!({"id": id, "name": object_name(doc, id), "votes": n, "rank": rank})).collect();
|
||||
out["results"] = json!(results);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The objects with votes, most first, each with its rank (ties share one: "1, 2, 2, 4").
|
||||
pub fn ranked(doc: &Document, vote: &Vote) -> Vec<(usize, NodeId, u32)> {
|
||||
let mut last = None;
|
||||
let mut rank = 0;
|
||||
vote.tally(doc, &doc.votes)
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(i, (id, n))| {
|
||||
if last != Some(n) {
|
||||
rank = i + 1;
|
||||
last = Some(n);
|
||||
}
|
||||
(rank, id, n)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Where object `b`'s vote badge sits (its top-right corner) and its radius, in page units at
|
||||
/// `zoom` screen pixels per unit.
|
||||
pub fn badge(b: Rect, zoom: f64) -> (Point, f64) {
|
||||
(Point::new(b.x1, b.y0), BADGE_PX / zoom.max(1e-6))
|
||||
}
|
||||
|
||||
impl Session {
|
||||
/// A press with the Move tool while a vote runs: on one of your vote badges, or with Alt, it
|
||||
/// takes that vote back; on an object the vote is on, it puts a vote there. False when the
|
||||
/// press is for the tool (no vote running, or nothing to vote on there).
|
||||
pub(crate) fn vote_down(&mut self, ev: &PointerEvent) -> Result<bool> {
|
||||
let doc = self.doc()?;
|
||||
let Some(vote) = doc.vote.as_ref().filter(|v| !v.ended) else { return Ok(false) };
|
||||
let who = voter(self, &Value::Null);
|
||||
let p = ev.p();
|
||||
let on_badge = mine(doc, &who).into_iter().map(|(id, _)| id).find(|id| {
|
||||
doc.world_bounds(*id).is_some_and(|b| {
|
||||
let (c, r) = badge(b, ev.zoom);
|
||||
(p - c).hypot() <= r
|
||||
})
|
||||
});
|
||||
let hit = || {
|
||||
let leaf = self.hit(doc, self.page().ok()?, p, 2.0 / ev.zoom.max(1e-6))?;
|
||||
vote.scope.target(doc, leaf, figcraft_doc::geometry::select_target(doc, leaf, &[], false))
|
||||
};
|
||||
let (action, target) = match on_badge {
|
||||
Some(id) => ("retract", id),
|
||||
None => match hit() {
|
||||
Some(id) => (if ev.alt { "retract" } else { "cast" }, id),
|
||||
None => return Ok(false),
|
||||
},
|
||||
};
|
||||
self.execute("board.vote", &json!({"action": action, "id": target}))?;
|
||||
Ok(true)
|
||||
}
|
||||
}
|
||||
|
||||
/// Undo and redo bring back a document as it was, but the timer and the vote aren't undoable:
|
||||
/// keep them as they are now (`from`).
|
||||
pub(crate) fn keep(from: &Document, to: &mut Arc<Document>) {
|
||||
if from.timer != to.timer || from.vote != to.vote || from.votes != to.votes {
|
||||
let d = Arc::make_mut(to);
|
||||
d.timer = from.timer;
|
||||
d.vote = from.vote.clone();
|
||||
d.votes = from.votes.clone();
|
||||
}
|
||||
}
|
||||
+463
-51
@@ -11,26 +11,38 @@ use super::{CommandSpec, ok};
|
||||
use crate::params::*;
|
||||
use crate::{DocState, EngineError, Result, Session, bad, spec};
|
||||
|
||||
/// A new file of `kind` in a tab of its own.
|
||||
fn new_file(s: &mut Session, p: &Value, kind: FileKind) -> Result<Value> {
|
||||
let name = string(p, "name").unwrap_or_else(|| if kind == FileKind::Board { "Untitled board" } else { "Untitled" }.into());
|
||||
let mut doc = Document::new(name);
|
||||
doc.kind = kind;
|
||||
// A property of the page from here on: a later change of theme leaves it alone. Boards are
|
||||
// light whatever the theme.
|
||||
let background = if kind == FileKind::Board { BOARD_PAGE } else { s.new_page_background() };
|
||||
for page in doc.pages.clone() {
|
||||
if let Some(NodeKind::Page { background: b, .. }) = doc.node_mut(page).map(|n| &mut n.kind) {
|
||||
*b = background;
|
||||
}
|
||||
}
|
||||
s.docs.push(DocState::new(doc, None));
|
||||
s.active = s.docs.len() - 1;
|
||||
Ok(json!({"index": s.active, "kind": kind.id()}))
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
spec!("file.new", "New design file", "{name?}", false, |s, p| {
|
||||
let name = string(p, "name").unwrap_or_else(|| "Untitled".into());
|
||||
let mut doc = Document::new(name);
|
||||
// A property of the page from here on: a later change of theme leaves it alone.
|
||||
let background = s.new_page_background();
|
||||
for page in doc.pages.clone() {
|
||||
if let Some(NodeKind::Page { background: b, .. }) = doc.node_mut(page).map(|n| &mut n.kind) {
|
||||
*b = background;
|
||||
}
|
||||
}
|
||||
s.docs.push(DocState::new(doc, None));
|
||||
s.active = s.docs.len() - 1;
|
||||
Ok(json!({"index": s.active}))
|
||||
}),
|
||||
spec!("file.new", "New design file", "{name?}", false, |s, p| new_file(s, p, FileKind::Design)),
|
||||
spec!(
|
||||
"file.newBoard",
|
||||
"New board",
|
||||
"{name?} a board: sticky notes, shapes with text, connectors, marker and stamps on a light page",
|
||||
false,
|
||||
|s, p| new_file(s, p, FileKind::Board)
|
||||
),
|
||||
spec!(
|
||||
"file.open",
|
||||
"Open",
|
||||
"{path | data (base64) + name (tells the format; opens as a new unsaved document), variables? (REST: the local variables JSON as an object, JSON text or a path; default `<name>.variables.json` or `variables.json` next to the file)} (.figcraft, .json: FigCraft or REST API file JSON, .fig (best effort), .svg, .png, .jpg …); REST and .fig imports return `warnings`; a file opened anew returns `summary` {name, pages, layers, images} (one that was already open just returns its `index`)",
|
||||
"{path | data (base64) + name (tells the format; opens as a new unsaved document), variables? (REST: the local variables JSON as an object, JSON text or a path; default `<name>.variables.json` or `variables.json` next to the file)} (.figcraft, .json: FigCraft or REST API file JSON, .fig (best effort), .sketch (Sketch 48 or newer, best effort), .svg, .png, .jpg …); REST, .fig and .sketch imports return `warnings`; a file opened anew returns `summary` {name, pages, layers, images} (one that was already open just returns its `index`)",
|
||||
false,
|
||||
open
|
||||
),
|
||||
@@ -51,10 +63,17 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
spec!(
|
||||
"file.saveAs",
|
||||
"Save as",
|
||||
"{path} (no extension adds .figcraft); a .fig path writes an experimental export copy of the shown document with `warnings`, keeping the native path, unsaved changes and version preview",
|
||||
"{path | data: true (no path: returns the file as base64 `data` with its `name`, for a download)} (no extension adds .figcraft); a .fig path writes an experimental export copy of the shown document with `warnings`, keeping the native path, unsaved changes and version preview",
|
||||
false,
|
||||
save_as
|
||||
),
|
||||
spec!(
|
||||
"file.markSaved",
|
||||
"Mark saved",
|
||||
"{} clears the unsaved changes of the active tab's file and its branch tabs: run it once the file from `file.saveAs {data: true}` was delivered (a refused download keeps them)",
|
||||
false,
|
||||
mark_saved
|
||||
),
|
||||
spec!(
|
||||
"file.export",
|
||||
"Export",
|
||||
@@ -65,7 +84,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
spec!(
|
||||
"file.exportLayers",
|
||||
"Export layers",
|
||||
"{ids? (default the selection, else every layer on the page with export settings), assets? (true: the Inspect tab's assets in and under them instead: layers with export settings, icons as SVG, images as PNG), dir? (folder to write into; else each file comes back as base64 `data`), bleed? (PDF: points, or with a unit: \"3mm\", \"0.125in\", \"9pt\"), cropMarks?, bleedMarks?, cmyk? (PDF print options): for every PDF instead of each setting's own, vdCompat? (bestEffort|exact|ignore: for every XML instead of each setting's own)} every layer once per export setting, named `<layer><suffix>.<format>` (\"@2x\" when a scaled export has no suffix; repeats get \" 2\"; slashes in a layer name make folders). Returns {files: [{name, id, bytes, path | data, warnings (XML only)}], skipped (layers without settings)}",
|
||||
"{ids? (default the selection, else every layer on the page with export settings), assets? (true: the Inspect tab's assets in and under them instead: layers with export settings, icons as SVG, images as PNG), dir? (folder to write into; else each file comes back as base64 `data`), zip? (no dir: the files come back together as one base64 zip `data` instead), bleed? (PDF: points, or with a unit: \"3mm\", \"0.125in\", \"9pt\"), cropMarks?, bleedMarks?, cmyk? (PDF print options): for every PDF instead of each setting's own, vdCompat? (bestEffort|exact|ignore: for every XML instead of each setting's own)} every layer once per export setting, named `<layer><suffix>.<format>` (\"@2x\" when a scaled export has no suffix; repeats get \" 2\"; slashes in a layer name make folders). Returns {files: [{name, id, bytes, path | data, warnings (XML only)}], skipped (layers without settings), data? (zip)}",
|
||||
false,
|
||||
export_layers
|
||||
),
|
||||
@@ -86,12 +105,26 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
spec!(
|
||||
"file.import",
|
||||
"Import",
|
||||
"{path | data (base64) + name, x?, y?, center? (SVG: centre it on x, y), variables? (REST: the local variables JSON as an object, JSON text or a path)} SVG becomes an editable frame (shapes, text, gradients, masks, effects; with `warnings` for what it left out), images become image layers, .figcraft, REST API file JSON and .fig pages are appended with their variables and flows (REST and .fig: with `warnings`; those return `summary` {name, pages, layers, images} of the file read)",
|
||||
"{path | data (base64) + name, x?, y?, center? (SVG: centre it on x, y), variables? (REST: the local variables JSON as an object, JSON text or a path)} SVG becomes an editable frame (shapes, text, gradients, masks, effects; with `warnings` for what it left out), images become image layers, .figcraft, REST API file JSON, .fig and .sketch pages are appended with their variables and flows (REST, .fig and .sketch: with `warnings`; those return `summary` {name, pages, layers, images} of the file read)",
|
||||
true,
|
||||
import
|
||||
),
|
||||
spec!("file.placeImage", "Place image", "{path | data, x?, y?}", true, super::create::place_image),
|
||||
spec!("file.close", "Close tab", "{index?, force? (discard unsaved changes)}", false, close),
|
||||
spec!("file.moveTab", "Move tab", "{from, to} (indices among open documents)", false, |s, p| {
|
||||
let from = req_index("file.moveTab", p, "from")?;
|
||||
let to = req_index("file.moveTab", p, "to")?;
|
||||
if from >= s.docs.len() || to >= s.docs.len() {
|
||||
return Err(bad("file.moveTab", "no such document"));
|
||||
}
|
||||
if from != to {
|
||||
let active_uid = s.docs.get(s.active).map(|d| d.uid);
|
||||
let doc = s.docs.remove(from);
|
||||
s.docs.insert(to, doc);
|
||||
s.active = active_uid.and_then(|uid| s.docs.iter().position(|d| d.uid == uid)).unwrap_or(0);
|
||||
}
|
||||
Ok(json!({"index": to}))
|
||||
}),
|
||||
spec!(
|
||||
"file.recover",
|
||||
"Recover file",
|
||||
@@ -130,7 +163,10 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
let i = s.active;
|
||||
let uid = s.ds()?.uid;
|
||||
if let Some(slot) = s.docs.get_mut(i) {
|
||||
*slot = DocState::new(loaded.doc, Some(path)).with_versions(loaded.versions).with_branches(loaded.branches);
|
||||
*slot = DocState::new(loaded.doc, Some(path.clone()))
|
||||
.with_versions(loaded.versions)
|
||||
.with_branches(loaded.branches)
|
||||
.with_images_left_in_file(&path, loaded.images_past_budget);
|
||||
}
|
||||
// Edits on its branch tabs are reverted too.
|
||||
crate::branches::close_tabs(s, uid, None);
|
||||
@@ -352,7 +388,7 @@ fn version_index(cmd: &str, ds: &DocState, p: &Value) -> Result<usize> {
|
||||
if i < ds.versions.len() { Ok(i) } else { Err(bad(cmd, format!("no version at index {i}"))) }
|
||||
}
|
||||
|
||||
fn read_version(v: &Version) -> Result<Arc<Document>> {
|
||||
pub(crate) fn read_version(v: &Version) -> Result<Arc<Document>> {
|
||||
v.document().map_err(|e| EngineError::Other(format!("version “{}” can't be read: {e}", v.name)))
|
||||
}
|
||||
|
||||
@@ -462,11 +498,11 @@ pub(crate) fn write(path: &str, _bytes: &[u8]) -> Result<()> {
|
||||
}
|
||||
|
||||
fn ext(path: &str) -> String {
|
||||
std::path::Path::new(path).extension().and_then(|e| e.to_str()).unwrap_or("").to_ascii_lowercase()
|
||||
std::path::Path::new(path).extension().and_then(std::ffi::OsStr::to_str).unwrap_or("").to_ascii_lowercase()
|
||||
}
|
||||
|
||||
fn file_stem(path: &str) -> String {
|
||||
std::path::Path::new(path).file_stem().and_then(|e| e.to_str()).unwrap_or("Untitled").to_string()
|
||||
std::path::Path::new(path).file_stem().and_then(std::ffi::OsStr::to_str).unwrap_or("Untitled").to_string()
|
||||
}
|
||||
|
||||
/// The counts the import report shows for a freshly loaded document.
|
||||
@@ -522,13 +558,17 @@ fn load_with(
|
||||
if let Some(r) = load_fig(bytes, &file_stem(name_hint)) {
|
||||
return Ok((r?, true));
|
||||
}
|
||||
if let Some(r) = load_sketch(bytes, &file_stem(name_hint)) {
|
||||
return Ok((r?, true));
|
||||
}
|
||||
if let Some(r) = figcraft_format::rest::try_import_with(bytes, variables, images) {
|
||||
let (mut doc, warnings) = r.map_err(|e| EngineError::Other(e.to_string()))?;
|
||||
link_instances(&mut doc);
|
||||
return Ok((figcraft_format::Loaded { issues: warnings, ..doc.into() }, true));
|
||||
}
|
||||
let mut loaded = figcraft_format::load_bytes_with_versions(bytes).map_err(|e| EngineError::Other(e.to_string()))?;
|
||||
if loaded.doc.name.is_empty() {
|
||||
// Older files saved an untouched default title even after the user chose a filename.
|
||||
if loaded.doc.name.is_empty() || loaded.doc.name == "Untitled" {
|
||||
loaded.doc.name = file_stem(name_hint);
|
||||
}
|
||||
Ok((loaded, false))
|
||||
@@ -536,6 +576,219 @@ fn load_with(
|
||||
}
|
||||
}
|
||||
|
||||
/// Gives `None` when `bytes` are not a `.sketch` file. Then other readers can try.
|
||||
fn load_sketch(bytes: &[u8], name: &str) -> Option<Result<figcraft_format::Loaded>> {
|
||||
if !figcraft_format::sketch::is_sketch(bytes) {
|
||||
return None;
|
||||
}
|
||||
// The import and what follows read the installed fonts: the system's must be known first (the
|
||||
// desktop app scans them in the background; this waits for that scan).
|
||||
figcraft_text::fontdb::scan_system_fonts();
|
||||
let fonts: figcraft_format::sketch::FontLookup = Box::new(figcraft_text::fontdb::postscript_face);
|
||||
let r = figcraft_format::sketch::try_import_with_fonts(bytes, name, Some(fonts))?;
|
||||
Some(r.map_err(|e| EngineError::Other(e.to_string())).map(|(mut doc, warnings)| {
|
||||
match_font_families(&mut doc);
|
||||
platform_tracking(&mut doc);
|
||||
sketch_line_heights(&mut doc);
|
||||
link_instances(&mut doc);
|
||||
reflow_overridden_text(&mut doc);
|
||||
figcraft_format::Loaded { issues: warnings, ..doc.into() }
|
||||
}))
|
||||
}
|
||||
|
||||
/// Instances of a stack sized apart from their main are laid out at their size, and texts in
|
||||
/// instances whose string an override changed are measured again with the auto layout around them
|
||||
/// re-flowing (Sketch saves layouts measured with the master's string). The rest keeps the layout
|
||||
/// Sketch saved.
|
||||
fn reflow_overridden_text(doc: &mut Document) {
|
||||
let changed: Vec<NodeId> = doc
|
||||
.nodes()
|
||||
.filter(|n| match (&n.kind, n.source.and_then(|s| doc.node(s)).map(|s| &s.kind)) {
|
||||
(NodeKind::Text(t), Some(NodeKind::Text(main))) => t.characters != main.characters,
|
||||
_ => false,
|
||||
})
|
||||
.map(|n| n.id)
|
||||
.collect();
|
||||
let m = crate::measure::TextMeasure;
|
||||
// Instances of a stack at another size than their main: the stack at their size, innermost first.
|
||||
let mut resized: Vec<(usize, NodeId)> = doc
|
||||
.nodes()
|
||||
.filter(|n| n.layout.is_some())
|
||||
.filter(|n| match &n.kind {
|
||||
NodeKind::Instance { component, .. } => {
|
||||
doc.node(*component).is_some_and(|c| (c.width - n.width).abs() > 1e-6 || (c.height - n.height).abs() > 1e-6)
|
||||
}
|
||||
_ => false,
|
||||
})
|
||||
.map(|n| (doc.ancestors(n.id).len(), n.id))
|
||||
.collect();
|
||||
resized.sort_by_key(|r| std::cmp::Reverse(r.0));
|
||||
for (_, id) in resized {
|
||||
figcraft_layout::layout_frame(doc, id, &m);
|
||||
}
|
||||
for id in changed {
|
||||
if let Some((w, h)) = doc.node(id).and_then(figcraft_text::auto_size) {
|
||||
figcraft_layout::set_size(doc, id, w, h);
|
||||
}
|
||||
figcraft_layout::layout_up(doc, id, &m);
|
||||
}
|
||||
}
|
||||
|
||||
/// Font families a `.sketch` import guessed from PostScript names ("Ping Fang SC" from
|
||||
/// "PingFangSC-Semibold") take the installed family's name when they differ only in spaces and
|
||||
/// case.
|
||||
fn match_font_families(doc: &mut Document) {
|
||||
let installed = figcraft_text::fontdb::family_names();
|
||||
let mut cache: std::collections::BTreeMap<String, Option<String>> = std::collections::BTreeMap::new();
|
||||
let mut fix = |style: &mut figcraft_doc::TextStyle| {
|
||||
let found = cache.entry(style.font_family.clone()).or_insert_with(|| installed_family(&style.font_family, &installed));
|
||||
if let Some(f) = found {
|
||||
style.font_family = f.clone();
|
||||
}
|
||||
};
|
||||
let ids: Vec<NodeId> = doc.nodes().filter(|n| matches!(n.kind, NodeKind::Text(_))).map(|n| n.id).collect();
|
||||
for id in ids {
|
||||
if let Some(NodeKind::Text(td)) = doc.node_mut(id).map(|n| &mut n.kind) {
|
||||
fix(&mut td.style);
|
||||
for r in &mut td.runs {
|
||||
fix(&mut r.style);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Shared text styles too, so they stay like the layers linked to them.
|
||||
for st in &mut doc.styles {
|
||||
if let StyleKind::Text { style } = &mut st.kind {
|
||||
fix(style);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Sketch draws text with the tracking a font's `trak` table gives at its size (Apple's text engine
|
||||
/// adds it; FigCraft's, like Figma's, doesn't): the imported text keeps it as letter spacing, so
|
||||
/// lines measure and break as they did there. A named style's optical size follows the text size
|
||||
/// there too (FigCraft keeps the instance's), so the imported text carries it as an `opsz` value.
|
||||
fn platform_tracking(doc: &mut Document) {
|
||||
let mut cache: std::collections::BTreeMap<(String, String), Option<std::sync::Arc<figcraft_text::fontdb::Face>>> =
|
||||
std::collections::BTreeMap::new();
|
||||
let mut face = |style: &figcraft_doc::TextStyle| {
|
||||
cache
|
||||
.entry((style.font_family.clone(), style.font_style.clone()))
|
||||
.or_insert_with(|| {
|
||||
figcraft_text::fontdb::has_family(&style.font_family)
|
||||
.then(|| figcraft_text::fontdb::find_face(&style.font_family, &style.font_style))
|
||||
.flatten()
|
||||
})
|
||||
.clone()
|
||||
};
|
||||
let mut track = |style: &mut figcraft_doc::TextStyle| {
|
||||
let Some(f) = face(style) else { return };
|
||||
let t = f.tracking(style.font_size);
|
||||
if t != 0.0 && t.is_finite() {
|
||||
let px = style.letter_spacing.resolve(style.font_size);
|
||||
style.letter_spacing = figcraft_doc::LetterSpacing::Pixels(px + t);
|
||||
}
|
||||
if f.axis("opsz").is_some() && f.instance(&style.font_style).is_some() && style.font_size.is_finite() {
|
||||
style.variations.entry("opsz".into()).or_insert(style.font_size);
|
||||
}
|
||||
};
|
||||
let ids: Vec<NodeId> = doc.nodes().filter(|n| matches!(n.kind, NodeKind::Text(_))).map(|n| n.id).collect();
|
||||
for id in ids {
|
||||
if let Some(NodeKind::Text(td)) = doc.node_mut(id).map(|n| &mut n.kind) {
|
||||
track(&mut td.style);
|
||||
for r in &mut td.runs {
|
||||
track(&mut r.style);
|
||||
}
|
||||
}
|
||||
}
|
||||
for st in &mut doc.styles {
|
||||
if let StyleKind::Text { style } = &mut st.kind {
|
||||
track(style);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Families Sketch (Apple's text system) gives a fifth more than their ascent and descent as their
|
||||
/// natural line: classic Mac fonts, found on `sketchtool` renders. Other fonts show none of this.
|
||||
const TALL_LINE_FAMILIES: [&str; 11] =
|
||||
["Academy Engraved LET", "Courier", "Helvetica", "Hoefler Text", "Palatino", "Sathu", "Silom", "Skia", "Times", "Webdings", "Zapf Dingbats"];
|
||||
|
||||
/// Sketch's natural line for a font of `ascent` and `descent` in `upem` units at `size` px: its
|
||||
/// height in px and how far down the text moves so its baseline sits where Sketch puts it. Sketch
|
||||
/// rounds the ascent and descent to whole pixels each and leaves the font's line gap out (a fifth
|
||||
/// more for [`TALL_LINE_FAMILIES`]), with the baseline above the rounded descent; FigCraft centres
|
||||
/// the font's own ascent and descent in the line. `(0, 0)` without metrics.
|
||||
pub(crate) fn sketch_line(ascent: f64, descent: f64, upem: f64, family: &str, size: f64) -> (f64, f64) {
|
||||
let k = size / upem;
|
||||
let (a, d) = (ascent * k, descent * k);
|
||||
if !(a.is_finite() && d.is_finite()) || a + d <= 0.0 {
|
||||
return (0.0, 0.0);
|
||||
}
|
||||
let natural = a.round() + d.round();
|
||||
let line = if TALL_LINE_FAMILIES.iter().any(|f| f.eq_ignore_ascii_case(family)) { (natural * 1.2).round() } else { natural };
|
||||
let sketch_baseline = line - d.round();
|
||||
let figcraft_baseline = (line - (a + d)) / 2.0 + a;
|
||||
(line, sketch_baseline - figcraft_baseline)
|
||||
}
|
||||
|
||||
/// Text without a fixed line height takes the line Sketch drew it with (FigCraft's natural line,
|
||||
/// like Figma's, counts the font's line gap and centres the font in it; Sketch's doesn't), as a
|
||||
/// share of its size so it still follows the size, and moves to Sketch's baseline. Shared text
|
||||
/// styles take the line too.
|
||||
fn sketch_line_heights(doc: &mut Document) {
|
||||
// Per family and style: the face's ascent, descent and units per em.
|
||||
type Metrics = Option<(f64, f64, f64)>;
|
||||
let mut cache: std::collections::BTreeMap<(String, String), Metrics> = std::collections::BTreeMap::new();
|
||||
let mut set = |style: &mut TextStyle| -> f64 {
|
||||
if style.line_height != LineHeight::Auto || !style.font_size.is_finite() || style.font_size <= 0.0 {
|
||||
return 0.0;
|
||||
}
|
||||
let m = *cache.entry((style.font_family.clone(), style.font_style.clone())).or_insert_with(|| {
|
||||
figcraft_text::fontdb::has_family(&style.font_family)
|
||||
.then(|| figcraft_text::fontdb::find_face(&style.font_family, &style.font_style))
|
||||
.flatten()
|
||||
.map(|f| (f.ascent, f.descent, f.upem))
|
||||
});
|
||||
let Some((a, d, upem)) = m else { return 0.0 };
|
||||
let (line, down) = sketch_line(a, d, upem, &style.font_family, style.font_size);
|
||||
if line > 0.0 {
|
||||
style.line_height = LineHeight::Percent(line / style.font_size * 100.0);
|
||||
}
|
||||
down
|
||||
};
|
||||
let ids: Vec<NodeId> = doc.nodes().filter(|n| matches!(n.kind, NodeKind::Text(_))).map(|n| n.id).collect();
|
||||
for id in ids {
|
||||
// An auto layout places its texts itself (its next pass would take a move back).
|
||||
let laid_out =
|
||||
doc.node(id).is_some_and(|n| !n.child_layout.absolute && n.parent.and_then(|p| doc.node(p)).is_some_and(|p| p.layout.is_some()));
|
||||
let Some(n) = doc.node_mut(id) else { continue };
|
||||
let NodeKind::Text(td) = &mut n.kind else { continue };
|
||||
let down = set(&mut td.style);
|
||||
for r in &mut td.runs {
|
||||
set(&mut r.style);
|
||||
}
|
||||
// Down in the layer's own frame (a rotated text moves along its own axis).
|
||||
if !laid_out && down.is_finite() && down != 0.0 {
|
||||
n.transform *= figcraft_doc::geom::Affine::translate((0.0, down));
|
||||
}
|
||||
}
|
||||
for st in &mut doc.styles {
|
||||
if let StyleKind::Text { style } = &mut st.kind {
|
||||
set(style);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The installed family `family` means when it isn't installed under that exact name: the one
|
||||
/// whose name is the same without spaces, ignoring case.
|
||||
pub(crate) fn installed_family(family: &str, installed: &[String]) -> Option<String> {
|
||||
let key = |s: &str| s.chars().filter(|c| !c.is_whitespace()).flat_map(char::to_lowercase).collect::<String>();
|
||||
if installed.iter().any(|f| f.eq_ignore_ascii_case(family)) {
|
||||
return None;
|
||||
}
|
||||
let want = key(family);
|
||||
installed.iter().find(|f| key(f) == want).cloned()
|
||||
}
|
||||
|
||||
/// Gives `None` when `bytes` are not a `.fig` file. Then other readers can try.
|
||||
pub(crate) fn load_fig(bytes: &[u8], name: &str) -> Option<Result<figcraft_format::Loaded>> {
|
||||
let r = figcraft_format::fig::try_import(bytes, name)?;
|
||||
@@ -704,7 +957,13 @@ fn open(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
});
|
||||
}
|
||||
let summary = import_summary(&loaded.doc);
|
||||
s.docs.push(DocState::new(loaded.doc, native.then_some(path)).with_versions(loaded.versions).with_branches(loaded.branches));
|
||||
let images_left_out = if native { loaded.images_past_budget } else { 0 };
|
||||
s.docs.push(
|
||||
DocState::new(loaded.doc, native.then(|| path.clone()))
|
||||
.with_versions(loaded.versions)
|
||||
.with_branches(loaded.branches)
|
||||
.with_images_left_in_file(&path, images_left_out),
|
||||
);
|
||||
s.active = s.docs.len() - 1;
|
||||
let mut out = json!({"index": s.active, "summary": summary});
|
||||
out[if warnings { "warnings" } else { "repairs" }] = json!(loaded.issues);
|
||||
@@ -792,7 +1051,9 @@ impl FileSnapshot {
|
||||
let Some(ds) = s.docs.iter_mut().find(|d| d.uid == self.uid && d.path == self.path && Arc::ptr_eq(&d.saved_doc, &self.saved)) else {
|
||||
return Ok(false);
|
||||
};
|
||||
write(self.path.as_deref().ok_or(EngineError::NoDocument)?, bytes)?;
|
||||
let path = self.path.as_deref().ok_or(EngineError::NoDocument)?;
|
||||
refuse_dropping_images(ds, path)?;
|
||||
write(path, bytes)?;
|
||||
// The history or branches may have changed since: then they still count as unsaved.
|
||||
if ds.revision == self.revision {
|
||||
ds.versions_changed = false;
|
||||
@@ -804,6 +1065,16 @@ impl FileSnapshot {
|
||||
}
|
||||
}
|
||||
|
||||
fn refuse_dropping_images(ds: &DocState, path: &str) -> Result<()> {
|
||||
match ds.images_lost_saving_to(path) {
|
||||
Some(count) => Err(EngineError::Other(format!(
|
||||
"{} wasn't saved: {count} of its images weren't loaded (together they pass what FigCraft reads from one file), and saving over it would delete them. Save it under another name; this file keeps them.",
|
||||
file_stem(path)
|
||||
))),
|
||||
None => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
/// The branch tabs of the file in tab `uid` were saved with it.
|
||||
fn branch_tabs_saved(s: &mut Session, uid: u64) {
|
||||
for tab in s.docs.iter_mut().filter(|d| d.branch_of.as_ref().is_some_and(|(m, _)| *m == uid)) {
|
||||
@@ -828,31 +1099,63 @@ fn color_profile(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
pub(crate) fn save_ends_preview(s: &Session, cmd: &str, p: &Value) -> bool {
|
||||
let path =
|
||||
string(p, "path").or_else(|| if cmd == "file.save" { s.docs.get(crate::branches::file_tab(s)).and_then(|ds| ds.path.clone()) } else { None });
|
||||
path.is_some_and(|path| ext(&path) != "fig")
|
||||
path.is_some_and(|path| ext(&path) != "fig") || (cmd == "file.saveAs" && wants_data(p))
|
||||
}
|
||||
|
||||
/// `file.saveAs` asked for the file's bytes instead of a path to write (the browser's download).
|
||||
fn wants_data(p: &Value) -> bool {
|
||||
p.get("data").and_then(Value::as_bool) == Some(true)
|
||||
}
|
||||
|
||||
/// Save the file of the active tab (the main file for a branch tab, with its branches), or export
|
||||
/// the active document as an experimental `.fig` copy without changing its native save state.
|
||||
fn save_to(s: &mut Session, path: String) -> Result<Value> {
|
||||
if ext(&path) == "fig" {
|
||||
/// Without a path the file comes back as base64 `data` for the caller to deliver (#969).
|
||||
fn save_to(s: &mut Session, path: Option<String>) -> Result<Value> {
|
||||
if let Some(path) = path.as_deref().filter(|p| ext(p) == "fig") {
|
||||
return export_fig(s, &json!({"path": path}));
|
||||
}
|
||||
let i = crate::branches::file_tab(s);
|
||||
let ds = s.docs.get_mut(i).ok_or(EngineError::NoDocument)?;
|
||||
let bytes = file_bytes(ds)?;
|
||||
// A download (no path) writes over no file.
|
||||
if let Some(path) = path.as_deref() {
|
||||
refuse_dropping_images(ds, path)?;
|
||||
}
|
||||
let mut snapshot = FileSnapshot::of(ds);
|
||||
let follows_path = ds.path.as_deref().map_or(ds.doc.name == "Untitled", |old| ds.doc.name == file_stem(old));
|
||||
if let Some(path) = path.as_deref()
|
||||
&& follows_path
|
||||
&& ds.path.as_deref() != Some(path)
|
||||
{
|
||||
Arc::make_mut(&mut snapshot.doc).name = file_stem(path);
|
||||
}
|
||||
let bytes = snapshot.encode()?;
|
||||
let Some(path) = path else {
|
||||
// The download can still be refused: the caller marks the file saved once it was delivered.
|
||||
let name = format!("{}.figcraft", ds.doc.name);
|
||||
return Ok(json!({"name": name, "bytes": bytes.len(), "data": base64::engine::general_purpose::STANDARD.encode(&bytes)}));
|
||||
};
|
||||
write(&path, &bytes)?;
|
||||
ds.doc = snapshot.doc;
|
||||
ds.path = Some(path.clone());
|
||||
ds.mark_saved();
|
||||
ds.revision += 1;
|
||||
let uid = ds.uid;
|
||||
branch_tabs_saved(s, uid);
|
||||
mark_saved(s, &json!({}))?;
|
||||
remember(s, &path);
|
||||
Ok(json!({"path": path, "bytes": bytes.len()}))
|
||||
}
|
||||
|
||||
/// The file of the active tab (with its branch tabs) now matches what was written or delivered.
|
||||
fn mark_saved(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let i = crate::branches::file_tab(s);
|
||||
let ds = s.docs.get_mut(i).ok_or(EngineError::NoDocument)?;
|
||||
ds.mark_saved();
|
||||
ds.revision += 1;
|
||||
let uid = ds.uid;
|
||||
branch_tabs_saved(s, uid);
|
||||
Ok(json!({"saved": true}))
|
||||
}
|
||||
|
||||
fn save(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if let Some(path) = string(p, "path") {
|
||||
return save_to(s, path);
|
||||
return save_to(s, Some(path));
|
||||
}
|
||||
match s.docs.get(crate::branches::file_tab(s)).ok_or(EngineError::NoDocument)?.path.clone() {
|
||||
// Earlier builds wrote native data under a .fig name: never overwrite that file with an export.
|
||||
@@ -860,7 +1163,7 @@ fn save(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
s.request_ui("dialog.saveAs", json!({}));
|
||||
Err(EngineError::Disabled("file.save".into(), "the saved path ends in .fig: choose Save as or pass `path`".into()))
|
||||
}
|
||||
Some(path) => save_to(s, path),
|
||||
Some(path) => save_to(s, Some(path)),
|
||||
None => {
|
||||
s.request_ui("dialog.saveAs", json!({}));
|
||||
Err(EngineError::Disabled("file.save".into(), "the file has no path yet: pass `path`".into()))
|
||||
@@ -874,8 +1177,9 @@ fn save_as(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if ext(&path).is_empty() {
|
||||
path.push_str(".figcraft");
|
||||
}
|
||||
save_to(s, path)
|
||||
save_to(s, Some(path))
|
||||
}
|
||||
None if wants_data(p) => save_to(s, None),
|
||||
None => {
|
||||
s.request_ui("dialog.saveAs", json!({}));
|
||||
Err(EngineError::Disabled("file.saveAs".into(), "pass `path`".into()))
|
||||
@@ -908,14 +1212,37 @@ pub fn export_area(doc: &Document, ids: &[NodeId], setting: &ExportSetting) -> O
|
||||
.reduce(|a, b| a.union(b))
|
||||
}
|
||||
|
||||
/// What an export of `ids` draws, bottom to top: the layers themselves, or (contents only off,
|
||||
/// and always for slices) every top-level layer of their page, so whatever shows in the area is in.
|
||||
fn drawn_layers(doc: &Document, ids: &[NodeId], setting: &ExportSetting) -> Vec<NodeId> {
|
||||
let slice = ids.iter().any(|i| doc.node(*i).is_some_and(|n| matches!(n.kind, NodeKind::Slice)));
|
||||
match ids.first().and_then(|i| doc.page_of(*i)).and_then(|p| doc.node(p)) {
|
||||
Some(page) if slice || !setting.contents_only => page.children.clone(),
|
||||
_ => super::edit::z_sorted(doc, ids),
|
||||
/// What an export of `ids` draws, bottom to top: the layers themselves, or (contents only off)
|
||||
/// every top-level layer of their page, so whatever shows in the area is in. A slice paints nothing
|
||||
/// itself: with contents only on, a slice inside a frame or group draws that frame or group, and a
|
||||
/// slice anywhere else draws every top-level layer of its page.
|
||||
pub fn drawn_layers(doc: &Document, ids: &[NodeId], setting: &ExportSetting) -> Vec<NodeId> {
|
||||
let Some(page) = ids.first().and_then(|i| doc.page_of(*i)).and_then(|p| doc.node(p)) else {
|
||||
return super::edit::z_sorted(doc, ids);
|
||||
};
|
||||
if !setting.contents_only {
|
||||
return page.children.clone();
|
||||
}
|
||||
let mut own = Vec::with_capacity(ids.len());
|
||||
for &i in ids {
|
||||
if !doc.node(i).is_some_and(|n| matches!(n.kind, NodeKind::Slice)) {
|
||||
own.push(i);
|
||||
continue;
|
||||
}
|
||||
match slice_container(doc, i) {
|
||||
Some(c) => own.push(c),
|
||||
None => return page.children.clone(),
|
||||
}
|
||||
}
|
||||
own.sort_unstable();
|
||||
own.dedup();
|
||||
super::edit::z_sorted(doc, &super::edit::roots(doc, &own))
|
||||
}
|
||||
|
||||
/// The frame or group a slice sits in: None on the page or in a section.
|
||||
fn slice_container(doc: &Document, slice: NodeId) -> Option<NodeId> {
|
||||
let parent = doc.node(slice)?.parent?;
|
||||
doc.node(parent).is_some_and(|p| p.kind.is_frame_like() || matches!(p.kind, NodeKind::Group)).then_some(parent)
|
||||
}
|
||||
|
||||
/// [`export_bytes`] with an export setting's format and options (its size is `scale`: see
|
||||
@@ -1140,6 +1467,20 @@ fn size_param(cmd: &str, p: &Value) -> Result<Option<ExportSize>> {
|
||||
|
||||
/// Most files one `file.exportLayers` writes.
|
||||
const MAX_EXPORT_FILES: usize = 1000;
|
||||
/// Most bytes the files of one zip export add up to: the files, the archive and its base64 copy
|
||||
/// are all in memory at once (in the browser too).
|
||||
const MAX_ZIP_BYTES: usize = 256 << 20;
|
||||
|
||||
/// Keep `bytes` as zip member `name`, unless the members would pass `limit` bytes together.
|
||||
fn add_member<'a>(members: &mut Vec<(&'a str, Vec<u8>)>, name: &'a str, bytes: Vec<u8>, limit: usize) -> Result<()> {
|
||||
let total = members.iter().map(|(_, b)| b.len()).sum::<usize>().saturating_add(bytes.len());
|
||||
if total > limit {
|
||||
let mb = limit >> 20;
|
||||
return Err(bad("file.exportLayers", format!("the files would pass {mb} MB as one zip: export fewer layers at a time, or to a folder")));
|
||||
}
|
||||
members.push((name, bytes));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// One file of a layer export: the layer, its setting and the file name.
|
||||
pub struct ExportJob {
|
||||
@@ -1185,9 +1526,15 @@ fn export_path(name: &str) -> String {
|
||||
if parts.is_empty() { "Untitled".into() } else { parts.join("/") }
|
||||
}
|
||||
|
||||
/// `name` without path separators and characters file systems reject.
|
||||
fn safe_file_name(name: &str) -> String {
|
||||
name.chars().take(200).map(|c| if c.is_control() || "/\\:*?\"<>|".contains(c) { '_' } else { c }).collect()
|
||||
/// Characters no file name may contain (path separators and ones file systems reject).
|
||||
pub(crate) const FORBIDDEN_FILE_NAME_CHARS: &str = "/\\:*?\"<>|";
|
||||
|
||||
/// `name` without path separators and characters file systems reject (`/ \ : * ? " < > |` and
|
||||
/// control characters become `_`); everything else is kept, up to the first 200 characters.
|
||||
/// `figcraft-cli export` names its files by this rule too (and keeps at most 100 characters before
|
||||
/// the number and extension it adds).
|
||||
pub fn safe_file_name(name: &str) -> String {
|
||||
name.chars().take(200).map(|c| if c.is_control() || FORBIDDEN_FILE_NAME_CHARS.contains(c) { '_' } else { c }).collect()
|
||||
}
|
||||
|
||||
/// Layers on `page` that have export settings (document order).
|
||||
@@ -1208,6 +1555,9 @@ fn export_layers(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return Err(EngineError::Disabled(cmd.into(), "no export settings (add one with export.add)".into()));
|
||||
}
|
||||
let dir = string(p, "dir");
|
||||
// The browser saves one download at a time: several files go as one zip (#969).
|
||||
let zip = dir.is_none() && boolean(p, "zip").unwrap_or(false);
|
||||
let mut members = Vec::new();
|
||||
// Print options passed here stand in for every PDF setting's own.
|
||||
let print = has_print_param(p).then(|| print_param(cmd, p, PrintSettings::default())).transpose()?;
|
||||
// And a compatibility mode for every Android Vector Drawable.
|
||||
@@ -1233,12 +1583,18 @@ fn export_layers(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
write(&path, &bytes)?;
|
||||
f["path"] = json!(path);
|
||||
}
|
||||
None if zip => add_member(&mut members, job.file.as_str(), bytes, MAX_ZIP_BYTES)?,
|
||||
None => f["data"] = json!(base64::engine::general_purpose::STANDARD.encode(&bytes)),
|
||||
}
|
||||
files.push(f);
|
||||
}
|
||||
let with_settings: std::collections::BTreeSet<NodeId> = jobs.iter().map(|j| j.id).collect();
|
||||
Ok(json!({"files": files, "skipped": ids.len().saturating_sub(with_settings.len())}))
|
||||
let mut r = json!({"files": files, "skipped": ids.len().saturating_sub(with_settings.len())});
|
||||
if zip {
|
||||
let archive = figcraft_format::zip_files(members.iter().map(|(n, b)| (*n, b.as_slice()))).map_err(|e| EngineError::Other(e.to_string()))?;
|
||||
r["data"] = json!(base64::engine::general_purpose::STANDARD.encode(&archive));
|
||||
}
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
/// A PDF with one page per group of layers (an sRGB document), titled after the file; `area`
|
||||
@@ -1315,7 +1671,7 @@ fn import(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
"figcraft" | "json" | "fig" => {
|
||||
"figcraft" | "json" | "fig" | "sketch" => {
|
||||
let resolver = path.as_deref().map(rest_images);
|
||||
let images: figcraft_format::rest::ImageResolver = match &resolver {
|
||||
Some(r) => r,
|
||||
@@ -1417,14 +1773,36 @@ fn close(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return Err(EngineError::Disabled("file.close".into(), "the document has unsaved changes (pass force: true to discard)".into()));
|
||||
}
|
||||
let uid = ds.uid;
|
||||
let active_uid = s.docs.get(s.active).map(|d| d.uid);
|
||||
s.docs.remove(i);
|
||||
if s.active >= s.docs.len() {
|
||||
s.active = s.docs.len().saturating_sub(1);
|
||||
}
|
||||
// Removing a tab before the active one shifts its index; keep the same document selected.
|
||||
s.active = active_uid.and_then(|a| s.docs.iter().position(|d| d.uid == a)).unwrap_or(i.min(s.docs.len().saturating_sub(1)));
|
||||
crate::branches::close_tabs(s, uid, None);
|
||||
ok()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tab_order_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn moving_and_closing_inactive_tabs_preserves_the_active_document() {
|
||||
let mut session = Session::new();
|
||||
for name in ["Two", "Three", "Four"] {
|
||||
session.execute("file.new", &json!({"name": name})).unwrap();
|
||||
}
|
||||
session.execute("doc.switch", &json!({"index": 2})).unwrap();
|
||||
let active_uid = session.docs[session.active].uid;
|
||||
let moved_uid = session.docs[0].uid;
|
||||
session.execute("file.moveTab", &json!({"from": 0, "to": 2})).unwrap();
|
||||
assert_eq!(session.docs[2].uid, moved_uid);
|
||||
assert_eq!(session.docs[session.active].uid, active_uid);
|
||||
session.execute("file.close", &json!({"index": 0})).unwrap();
|
||||
assert_eq!(session.docs[session.active].uid, active_uid);
|
||||
assert!(session.execute("file.moveTab", &json!({"from": 0, "to": 99})).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
/// Ctrl+Tab / Ctrl+Shift+Tab: the next or previous open document, wrapping around.
|
||||
fn cycle_doc(s: &mut Session, dir: i64) -> Result<Value> {
|
||||
let n = s.docs.len() as i64;
|
||||
@@ -1563,6 +1941,25 @@ mod rest_import_tests {
|
||||
assert_eq!(s.docs.len(), open, "a refused open adds nothing");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn save_as_data_hands_back_the_file_and_marks_it_saved_once_delivered() {
|
||||
// The browser has no paths: Save as returns the file for a download (#969).
|
||||
let mut s = Session::new();
|
||||
s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap();
|
||||
assert!(s.execute("file.saveAs", &json!({})).is_err(), "no path and no data: still asks for a path");
|
||||
let recent = s.prefs.recent_files.clone();
|
||||
let r = s.execute("file.saveAs", &json!({"data": true})).unwrap();
|
||||
assert_eq!(r["name"], "Untitled.figcraft");
|
||||
assert!(s.ds().unwrap().is_dirty(), "not delivered yet: a refused download must not lose the edits");
|
||||
s.execute("file.markSaved", &json!({})).unwrap();
|
||||
assert!(!s.ds().unwrap().is_dirty());
|
||||
assert_eq!(s.ds().unwrap().path, None, "still no path to save over");
|
||||
assert_eq!(s.prefs.recent_files, recent, "nothing on disk to remember");
|
||||
let mut t = Session::new();
|
||||
t.execute("file.open", &json!({"data": r["data"], "name": r["name"]})).unwrap();
|
||||
assert!(t.doc().unwrap().nodes().any(|n| matches!(n.kind, NodeKind::Rectangle)), "the bytes reopen");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn open_and_import_rest_json() {
|
||||
let dir = std::env::temp_dir().join(format!("figcraft-rest-{}", std::process::id()));
|
||||
@@ -1730,3 +2127,18 @@ mod staging_write_tests {
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod zip_cap_tests {
|
||||
use super::add_member;
|
||||
|
||||
#[test]
|
||||
fn zip_members_stop_at_the_limit_and_keep_what_fit() {
|
||||
let mut members = Vec::new();
|
||||
add_member(&mut members, "a.png", vec![0; 6], 10).unwrap();
|
||||
add_member(&mut members, "b.png", vec![0; 4], 10).unwrap();
|
||||
let err = add_member(&mut members, "c.png", vec![0; 1], 10).unwrap_err().to_string();
|
||||
assert!(err.contains("as one zip"), "{err}");
|
||||
assert_eq!(members.iter().map(|(n, _)| *n).collect::<Vec<_>>(), ["a.png", "b.png"]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,9 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
)]
|
||||
}
|
||||
|
||||
/// Same cap the layout pass applies to grid track counts.
|
||||
const MAX_TRACKS: usize = 1000;
|
||||
|
||||
fn set_track(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const CMD: &str = "layout.setTrack";
|
||||
let ids = req_targets(CMD, s, p)?;
|
||||
@@ -38,7 +41,7 @@ fn set_track(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let Some(l) = n.layout.as_mut().filter(|l| l.mode == LayoutMode::Grid) else {
|
||||
return Err(bad(CMD, format!("node {id} is not a grid auto layout frame")));
|
||||
};
|
||||
let count = tracks_now.len().max(if columns { l.grid_columns } else { l.grid_rows } as usize);
|
||||
let count = tracks_now.len().max(if columns { l.grid_columns } else { l.grid_rows } as usize).min(MAX_TRACKS);
|
||||
if !(0.0..count as f64).contains(&index) {
|
||||
return Err(bad(CMD, format!("`index` must be 0..{count}")));
|
||||
}
|
||||
@@ -149,6 +152,35 @@ mod tests {
|
||||
assert!(s.execute("node.set", &json!({"id": b, "props": {"absolute": "maybe"}})).is_err());
|
||||
}
|
||||
|
||||
/// Each side key writes its own padding slot and leaves the other three alone.
|
||||
#[test]
|
||||
fn each_side_padding_sets_its_side_alone() {
|
||||
let mut s = Session::new();
|
||||
let (f, _, _) = al_frame(&mut s);
|
||||
// One side at a time: the other three stay 0 ([top, right, bottom, left]).
|
||||
for (key, v, want) in [
|
||||
("paddingTop", 8.0, [8.0, 0.0, 0.0, 0.0]),
|
||||
("paddingRight", 12.0, [0.0, 12.0, 0.0, 0.0]),
|
||||
("paddingBottom", 16.0, [0.0, 0.0, 16.0, 0.0]),
|
||||
("paddingLeft", 20.0, [0.0, 0.0, 0.0, 20.0]),
|
||||
] {
|
||||
ex(&mut s, "node.set", json!({"id": f, "props": {"padding": 0}}));
|
||||
ex(&mut s, "node.set", json!({"id": f, "props": {key: v}}));
|
||||
assert_eq!(node(&s, f).layout.as_ref().unwrap().padding, want, "{key}");
|
||||
}
|
||||
// The [t, r, b, l] array and the axis pairs agree with the four sides.
|
||||
ex(&mut s, "node.set", json!({"id": f, "props": {"padding": [8, 12, 16, 20]}}));
|
||||
assert_eq!(node(&s, f).layout.as_ref().unwrap().padding, [8.0, 12.0, 16.0, 20.0]);
|
||||
ex(&mut s, "node.set", json!({"id": f, "props": {"paddingHorizontal": 4}}));
|
||||
assert_eq!(node(&s, f).layout.as_ref().unwrap().padding, [8.0, 4.0, 16.0, 4.0]);
|
||||
ex(&mut s, "node.set", json!({"id": f, "props": {"paddingVertical": 2}}));
|
||||
assert_eq!(node(&s, f).layout.as_ref().unwrap().padding, [2.0, 4.0, 2.0, 4.0]);
|
||||
// Negatives clamp to 0; junk is refused.
|
||||
ex(&mut s, "node.set", json!({"id": f, "props": {"paddingTop": -5}}));
|
||||
assert_eq!(node(&s, f).layout.as_ref().unwrap().padding[0], 0.0);
|
||||
assert!(s.execute("node.set", &json!({"id": f, "props": {"paddingLeft": "wide"}})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn baseline_alignment_lines_up_text() {
|
||||
let mut s = Session::new();
|
||||
@@ -220,6 +252,17 @@ mod tests {
|
||||
assert!(s.execute("node.set", &json!({"id": f, "props": {"rowTracks": 5}})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_track_clamps_hostile_grid_counts() {
|
||||
let mut s = Session::new();
|
||||
let f = id(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 320, "height": 200, "props": {"layout": "grid"}})));
|
||||
// A crafted file can carry a count node.set would have clamped.
|
||||
s.doc_mut().unwrap().get_mut(f).unwrap().layout.as_mut().unwrap().grid_columns = 5_000_000;
|
||||
let p = json!({"ids": [f], "axis": "columns", "index": 4_999_999, "sizing": "hug"});
|
||||
assert!(s.execute("layout.setTrack", &p).is_err());
|
||||
assert!(node(&s, f).layout.unwrap().column_tracks.len() <= 1000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layout_props_survive_hostile_values() {
|
||||
let junk = [json!(null), json!("NaN"), json!(-1e308), json!(1e308), json!([]), json!({}), json!(true), json!("-5fr")];
|
||||
|
||||
@@ -112,7 +112,8 @@ pub fn stop_binding(field: &str) -> Option<usize> {
|
||||
/// Keep the gradient stop bindings of paint `index` of `n`'s fills (or strokes) on their stops after
|
||||
/// the paint changed from `old`: one stop removed shifts the later ones down (the removed one's
|
||||
/// binding goes); a paint that is no longer a gradient drops them. Other edits keep stop indices.
|
||||
pub(crate) fn remap_stop_bindings(n: &mut Node, fills: bool, index: usize, old: &figcraft_color::Paint) {
|
||||
/// Returns the removed stop's index.
|
||||
pub(crate) fn remap_stop_bindings(n: &mut Node, fills: bool, index: usize, old: &figcraft_color::Paint) -> Option<usize> {
|
||||
use figcraft_color::PaintKind;
|
||||
let list = if fills { BoundList::Fills } else { BoundList::Strokes };
|
||||
let new = if fills { n.fills.get(index) } else { n.strokes.get(index) };
|
||||
@@ -122,11 +123,11 @@ pub(crate) fn remap_stop_bindings(n: &mut Node, fills: bool, index: usize, old:
|
||||
// The first stop that differs is the removed one when the rest moved down unchanged.
|
||||
let k = a.stops.iter().zip(&b.stops).position(|(x, y)| x != y).unwrap_or(b.stops.len());
|
||||
if a.stops.get(k + 1..) != b.stops.get(k..) {
|
||||
return;
|
||||
return None;
|
||||
}
|
||||
Some(k)
|
||||
}
|
||||
(_, Some(PaintKind::Gradient(_))) => return,
|
||||
(_, Some(PaintKind::Gradient(_))) => return None,
|
||||
_ => None,
|
||||
};
|
||||
let keys: Vec<(String, Option<String>)> = n
|
||||
@@ -150,6 +151,42 @@ pub(crate) fn remap_stop_bindings(n: &mut Node, fills: bool, index: usize, old:
|
||||
}
|
||||
}
|
||||
n.bound.extend(kept);
|
||||
removed
|
||||
}
|
||||
|
||||
/// After an explicit edit changed paint `index` of `n`'s fills (or strokes) from `old`: its stop
|
||||
/// bindings follow a removed stop, then each binding whose value the edit changed (the colour, the
|
||||
/// opacity, a stop's colour) lets go, so the edit stays instead of being put back from the
|
||||
/// variable. Bindings whose value the edit kept stay; a paint that is gone takes its bindings.
|
||||
pub(crate) fn rebind_edited_paint(n: &mut Node, fills: bool, index: usize, old: &figcraft_color::Paint) {
|
||||
let removed = remap_stop_bindings(n, fills, index, old);
|
||||
let mut old = old.clone();
|
||||
if let (Some(k), figcraft_color::PaintKind::Gradient(g)) = (removed, &mut old.kind)
|
||||
&& k < g.stops.len()
|
||||
{
|
||||
g.stops.remove(k);
|
||||
}
|
||||
let list = if fills { BoundList::Fills } else { BoundList::Strokes };
|
||||
let new = if fills { n.fills.get(index) } else { n.strokes.get(index) }.cloned();
|
||||
n.bound.retain(|key, _| match list_binding(key) {
|
||||
Some((l, i, field)) if l == list && i == index => new.as_ref().is_some_and(|new| paint_field_kept(&old, new, field)),
|
||||
_ => true,
|
||||
});
|
||||
}
|
||||
|
||||
/// Whether a paint's bound `field` (`color`, `opacity`, `stops/<j>/color`, …) has the same value in
|
||||
/// `new` as in `old`.
|
||||
fn paint_field_kept(old: &figcraft_color::Paint, new: &figcraft_color::Paint, field: &str) -> bool {
|
||||
use figcraft_color::PaintKind;
|
||||
match field {
|
||||
"color" => matches!((&old.kind, &new.kind), (PaintKind::Solid { color: a }, PaintKind::Solid { color: b }) if a == b),
|
||||
"opacity" => old.opacity == new.opacity,
|
||||
_ => match (stop_binding(field), &old.kind, &new.kind) {
|
||||
(Some(j), PaintKind::Gradient(a), PaintKind::Gradient(b)) => a.stops.get(j).map(|s| s.color) == b.stops.get(j).map(|s| s.color),
|
||||
(Some(_), _, _) => false,
|
||||
_ => old == new,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Where item `k` lands when the item at `from` moves to `to`.
|
||||
|
||||
@@ -5,6 +5,7 @@ pub mod comment;
|
||||
pub mod component;
|
||||
pub mod create;
|
||||
pub mod edit;
|
||||
pub mod facilitate;
|
||||
pub mod file;
|
||||
pub mod image;
|
||||
pub mod layout;
|
||||
@@ -99,6 +100,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 +111,12 @@ 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());
|
||||
list.extend(facilitate::specs());
|
||||
list.extend(crate::board_table::specs());
|
||||
list.extend(crate::board_templates::specs());
|
||||
let mut index = HashMap::new();
|
||||
for (i, s) in list.iter().enumerate() {
|
||||
if index.insert(s.id, i).is_some() {
|
||||
|
||||
+176
-29
@@ -13,7 +13,7 @@ pub const STROKE_SIDES: [&str; 4] = ["strokeTopWeight", "strokeRightWeight", "st
|
||||
|
||||
/// Keys `node.set` understands (documented for agents).
|
||||
pub const KEYS: &str = "name, visible, locked, opacity (0-1), blend, x, y (relative to the parent frame; groups don't count), width, height, rotation (degrees), \
|
||||
fills (paints or colours; gradients as {type: linearGradient|radialGradient|angularGradient|diamondGradient, stops: [\"#hex\", ...], from?, to?} too), fill (colour), strokes, stroke (colour), strokeWeight, strokeAlign (inside|center|outside), strokeSides [t,r,b,l], strokeTopWeight / strokeRightWeight / strokeBottomWeight / strokeLeftWeight (one side; turns on individual strokes), \
|
||||
fills (paints or colours; gradients as {type: linearGradient|radialGradient|angularGradient|diamondGradient, stops: [\"#hex\", ...], from?, to?} too), fill (colour, or \"none\"/null: no fill), strokes, stroke (colour, or \"none\"/null: no stroke), strokeWeight, strokeAlign (inside|center|outside), strokeSides [t,r,b,l], strokeTopWeight / strokeRightWeight / strokeBottomWeight / strokeLeftWeight (one side; turns on individual strokes), \
|
||||
strokeCap / startCap / endCap, strokeJoin, miterAngle (degrees: sharper joins bevel; or miterLimit as a ratio), dashes [..], widthProfile (preset uniform|wedge|taper|eye|rounded|pinch, [[t, scale], ...] or null), brush (preset id, {preset, spacing, sizeJitter, rotationJitter, scatter, seed} or null), cornerRadius (n or [tl,tr,br,bl]), cornerSmoothing (0-1), effects, \
|
||||
clip, isMask, layout (null|horizontal|vertical|grid|{...}), gap, crossGap, padding (n or [t,r,b,l]), paddingTop/Right/Bottom/Left, \
|
||||
mainAlign (min|center|max, or the auto gaps spaceBetween|spaceAround|spaceEvenly), crossAlign (min|center|max|baseline: first text baselines, horizontal layouts), contentAlign (wrapped lines: auto = packed where crossAlign says | spaceBetween), wrap (rows of a horizontal layout, columns of a vertical one), strokesIncluded (the frame's inside stroke takes up space), reverseZ (canvas stacking: true = first on top), \
|
||||
@@ -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}";
|
||||
|
||||
@@ -61,6 +63,48 @@ pub fn snake(v: &Value) -> Value {
|
||||
}
|
||||
}
|
||||
|
||||
/// `v` as an effect list, refusing keys an effect doesn't read: serde dropped them without a word,
|
||||
/// so a shadow given `offsetY` kept the default offset.
|
||||
pub fn effects_from(cmd: &str, v: &Value) -> Result<Vec<Effect>> {
|
||||
let effects: Vec<Effect> = serde_json::from_value(snake(v)).map_err(|e| bad(cmd, format!("effects: {e}")))?;
|
||||
for (i, given) in v.as_array().into_iter().flatten().enumerate() {
|
||||
refuse_ignored_effect_keys(cmd, &format!("effects[{i}]"), given, |v| serde_json::from_value(snake(v)).ok())?;
|
||||
}
|
||||
Ok(effects)
|
||||
}
|
||||
|
||||
/// `p[key]` as one effect, read as serde reads it (its aliases too), refusing keys it doesn't read.
|
||||
pub fn effect_from(cmd: &str, p: &Value, key: &str) -> Result<Option<Effect>> {
|
||||
let Some(v) = get(p, key) else { return Ok(None) };
|
||||
let effect: Effect = serde_json::from_value(v.clone()).map_err(|e| bad(cmd, format!("`{key}`: {e}")))?;
|
||||
refuse_ignored_effect_keys(cmd, &format!("`{key}`"), v, |v| serde_json::from_value(v.clone()).ok())?;
|
||||
Ok(Some(effect))
|
||||
}
|
||||
|
||||
/// Refuse the keys of effect `given` that `parse` ignores, naming the keys the effect takes. Set to
|
||||
/// null, a key it reads fails to parse or changes the effect; a key it ignores changes nothing.
|
||||
fn refuse_ignored_effect_keys(cmd: &str, at: &str, given: &Value, parse: impl Fn(&Value) -> Option<Effect>) -> Result<()> {
|
||||
let Some(fields) = given.as_object() else { return Ok(()) };
|
||||
let parsed = parse(given);
|
||||
let ignored: Vec<&str> = fields
|
||||
.iter()
|
||||
.filter(|(_, v)| !v.is_null())
|
||||
.filter(|(k, _)| {
|
||||
let mut probe = fields.clone();
|
||||
probe.insert((*k).clone(), Value::Null);
|
||||
parse(&Value::Object(probe)).is_some_and(|e| Some(e) == parsed)
|
||||
})
|
||||
.map(|(k, _)| k.as_str())
|
||||
.collect();
|
||||
if ignored.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let takes = parsed.and_then(|e| serde_json::to_value(e).ok()).map(crate::inspect::camel).unwrap_or_default();
|
||||
let kind = takes.get("type").and_then(Value::as_str).unwrap_or("effect").to_string();
|
||||
let names: Vec<&str> = takes.as_object().into_iter().flatten().map(|(k, _)| k.as_str()).collect();
|
||||
Err(bad(cmd, format!("{at}: unknown properties: {} (a {kind} takes {})", ignored.join(", "), names.join(", "))))
|
||||
}
|
||||
|
||||
pub(crate) fn enum_of<T: serde::de::DeserializeOwned>(v: &Value) -> Option<T> {
|
||||
let s = v.as_str()?;
|
||||
// Accept camelCase, snake/upper-case and spaced variants.
|
||||
@@ -100,7 +144,9 @@ pub(crate) fn sizing(v: &Value) -> Option<Sizing> {
|
||||
pub(crate) fn line_height(v: &Value) -> Option<LineHeight> {
|
||||
match v {
|
||||
Value::String(s) if s.eq_ignore_ascii_case("auto") => Some(LineHeight::Auto),
|
||||
Value::String(s) if s.trim().ends_with('%') => s.trim().trim_end_matches('%').trim().parse().ok().map(LineHeight::Percent),
|
||||
Value::String(s) if s.trim().ends_with('%') => {
|
||||
s.trim().trim_end_matches('%').trim().parse().ok().filter(|f: &f64| f.is_finite()).map(LineHeight::Percent)
|
||||
}
|
||||
Value::Object(_) => serde_json::from_value(v.clone()).ok(),
|
||||
other => f64_of(other).map(LineHeight::Pixels),
|
||||
}
|
||||
@@ -108,7 +154,9 @@ pub(crate) fn line_height(v: &Value) -> Option<LineHeight> {
|
||||
|
||||
pub(crate) fn letter_spacing(v: &Value) -> Option<LetterSpacing> {
|
||||
match v {
|
||||
Value::String(s) if s.trim().ends_with('%') => s.trim().trim_end_matches('%').trim().parse().ok().map(LetterSpacing::Percent),
|
||||
Value::String(s) if s.trim().ends_with('%') => {
|
||||
s.trim().trim_end_matches('%').trim().parse().ok().filter(|f: &f64| f.is_finite()).map(LetterSpacing::Percent)
|
||||
}
|
||||
Value::Object(_) => serde_json::from_value(v.clone()).ok(),
|
||||
other => f64_of(other).map(LetterSpacing::Pixels),
|
||||
}
|
||||
@@ -297,6 +345,12 @@ pub const LAYOUT_KEYS: &[&str] = &[
|
||||
"gridRowTracks",
|
||||
];
|
||||
|
||||
/// Drop the bindings of auto layout fields (gap, padding…): they go with the auto layout, or
|
||||
/// re-applying them would bring it back.
|
||||
pub fn drop_layout_bindings(n: &mut Node) {
|
||||
n.bound.retain(|k, _| !LAYOUT_KEYS.contains(&k.as_str()));
|
||||
}
|
||||
|
||||
/// The auto layout of a frame-like node, created when it has none yet. Other nodes can't hold an
|
||||
/// auto layout: an auto layout key on them is refused, not stored where nothing reads it
|
||||
/// (`node.set` on several layers skips them instead, see `edit::set`).
|
||||
@@ -315,23 +369,63 @@ fn layout_mut<'a>(cmd: &str, k: &str, n: &'a mut Node) -> Result<&'a mut AutoLay
|
||||
Ok(ensure_layout(n))
|
||||
}
|
||||
|
||||
/// Replace a node's fills (or strokes). Bindings on the paints whose bound values the new list
|
||||
/// changed let go, as an explicit edit does (see `lists::rebind_edited_paint`).
|
||||
fn set_paints(n: &mut Node, fills: bool, paints: Vec<Paint>) {
|
||||
let old = std::mem::replace(if fills { &mut n.fills } else { &mut n.strokes }, paints);
|
||||
for (i, p) in old.iter().enumerate() {
|
||||
super::lists::rebind_edited_paint(n, fills, i, p);
|
||||
}
|
||||
}
|
||||
|
||||
/// `"none"` (either case) and null ask for no paint, as an empty `fills`/`strokes` list does.
|
||||
fn paint_none(v: &Value) -> bool {
|
||||
v.is_null() || v.as_str().is_some_and(|s| s.eq_ignore_ascii_case("none"))
|
||||
}
|
||||
|
||||
/// The order [`apply`] takes keys in: geometry first so rotation applies to the final size, style
|
||||
/// links after the values so a link wins over values its style sets, variable bindings last so
|
||||
/// paints set in the same call don't detach them.
|
||||
fn apply_order(k: &str) -> u8 {
|
||||
match k {
|
||||
"path" | "width" | "height" | "w" | "h" => 0,
|
||||
"rotation" => 2,
|
||||
"x" | "y" => 3,
|
||||
"fillStyle" | "strokeStyle" | "effectStyle" | "textStyle" | "gridStyle" => 4,
|
||||
"boundVariables" => 5,
|
||||
_ => 1,
|
||||
}
|
||||
}
|
||||
|
||||
/// 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();
|
||||
// Geometry keys first so rotation applies to the final size.
|
||||
let order = |k: &str| match k {
|
||||
"width" | "height" | "w" | "h" => 0,
|
||||
"rotation" => 2,
|
||||
"x" | "y" => 3,
|
||||
_ => 1,
|
||||
};
|
||||
let mut keys: Vec<(&String, &Value)> = props.iter().collect();
|
||||
keys.sort_by_key(|(k, _)| order(k));
|
||||
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)?;
|
||||
@@ -373,13 +467,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"))?;
|
||||
@@ -398,15 +486,23 @@ pub fn apply(cmd: &str, doc: &mut Document, id: NodeId, props: &Map<String, Valu
|
||||
let b = v.as_str().and_then(figcraft_color::BlendMode::from_name).ok_or_else(|| bad(cmd, "unknown blend mode"))?;
|
||||
doc.get_mut(id)?.blend = b;
|
||||
}
|
||||
"fills" => doc.get_mut(id)?.fills = paints_of(v).ok_or_else(|| bad(cmd, "`fills` must be paints or colours"))?,
|
||||
"fills" => set_paints(doc.get_mut(id)?, true, paints_of(v).ok_or_else(|| bad(cmd, "`fills` must be paints or colours"))?),
|
||||
"fill" => {
|
||||
let c = color_of(v).ok_or_else(|| bad(cmd, "`fill` must be a colour"))?;
|
||||
doc.get_mut(id)?.fills = vec![Paint::solid(c)];
|
||||
if paint_none(v) {
|
||||
set_paints(doc.get_mut(id)?, true, vec![]);
|
||||
} else {
|
||||
let c = color_of(v).ok_or_else(|| bad(cmd, "`fill` must be a colour, \"none\" or null"))?;
|
||||
set_paints(doc.get_mut(id)?, true, vec![Paint::solid(c)]);
|
||||
}
|
||||
}
|
||||
"strokes" => doc.get_mut(id)?.strokes = paints_of(v).ok_or_else(|| bad(cmd, "`strokes` must be paints or colours"))?,
|
||||
"strokes" => set_paints(doc.get_mut(id)?, false, paints_of(v).ok_or_else(|| bad(cmd, "`strokes` must be paints or colours"))?),
|
||||
"stroke" => {
|
||||
let c = color_of(v).ok_or_else(|| bad(cmd, "`stroke` must be a colour"))?;
|
||||
doc.get_mut(id)?.strokes = vec![Paint::solid(c)];
|
||||
if paint_none(v) {
|
||||
set_paints(doc.get_mut(id)?, false, vec![]);
|
||||
} else {
|
||||
let c = color_of(v).ok_or_else(|| bad(cmd, "`stroke` must be a colour, \"none\" or null"))?;
|
||||
set_paints(doc.get_mut(id)?, false, vec![Paint::solid(c)]);
|
||||
}
|
||||
}
|
||||
"strokeWeight" => doc.get_mut(id)?.stroke.weight = f64_of(v).ok_or_else(|| bad(cmd, "`strokeWeight` must be a number"))?.max(0.0),
|
||||
"strokeAlign" => doc.get_mut(id)?.stroke.align = enum_of(v).ok_or_else(|| bad(cmd, "strokeAlign: inside|center|outside"))?,
|
||||
@@ -462,7 +558,7 @@ pub fn apply(cmd: &str, doc: &mut Document, id: NodeId, props: &Map<String, Valu
|
||||
}
|
||||
}
|
||||
"cornerSmoothing" => doc.get_mut(id)?.corners.smoothing = f64_of(v).unwrap_or(0.0).clamp(0.0, 1.0),
|
||||
"effects" => doc.get_mut(id)?.effects = serde_json::from_value(snake(v)).map_err(|e| bad(cmd, format!("effects: {e}")))?,
|
||||
"effects" => doc.get_mut(id)?.effects = effects_from(cmd, v)?,
|
||||
"clip" | "clipsContent" => doc.get_mut(id)?.clip = v.as_bool().ok_or_else(|| bad(cmd, "`clip` must be a boolean"))?,
|
||||
"isMask" => doc.get_mut(id)?.is_mask = v.as_bool().unwrap_or(false),
|
||||
"devStatus" => {
|
||||
@@ -471,8 +567,12 @@ pub fn apply(cmd: &str, doc: &mut Document, id: NodeId, props: &Map<String, Valu
|
||||
} else {
|
||||
v.as_str().and_then(|s| DevStatus::ALL.into_iter().find(|d| d.id().eq_ignore_ascii_case(s)))
|
||||
};
|
||||
let st = st.ok_or_else(|| bad(cmd, "devStatus: none|readyForDev|completed"))?;
|
||||
if !st.is_none() && !crate::devinspect::has_status(doc, id) {
|
||||
return Err(bad(cmd, "devStatus: only sections and frames at the top of a page or section take one"));
|
||||
}
|
||||
let n = doc.get_mut(id)?;
|
||||
n.dev_status = st.ok_or_else(|| bad(cmd, "devStatus: none|readyForDev|completed"))?;
|
||||
n.dev_status = st;
|
||||
// Marking a layer (again) starts from its current contents.
|
||||
n.dev_changed = false;
|
||||
}
|
||||
@@ -486,8 +586,14 @@ pub fn apply(cmd: &str, doc: &mut Document, id: NodeId, props: &Map<String, Valu
|
||||
return Err(bad(cmd, "auto layout needs a frame"));
|
||||
}
|
||||
match v {
|
||||
Value::Null => n.layout = None,
|
||||
Value::String(s) if s.eq_ignore_ascii_case("none") => n.layout = None,
|
||||
Value::Null => {
|
||||
n.layout = None;
|
||||
drop_layout_bindings(n);
|
||||
}
|
||||
Value::String(s) if s.eq_ignore_ascii_case("none") => {
|
||||
n.layout = None;
|
||||
drop_layout_bindings(n);
|
||||
}
|
||||
Value::String(s) => {
|
||||
let mode = match s.to_ascii_lowercase().as_str() {
|
||||
"horizontal" | "row" => LayoutMode::Horizontal,
|
||||
@@ -852,6 +958,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 {
|
||||
@@ -1148,3 +1281,17 @@ pub fn describe(doc: &Document, id: NodeId) -> Value {
|
||||
let _ = Color::BLACK;
|
||||
v
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn non_finite_percent_strings_are_rejected() {
|
||||
for s in ["NaN%", "inf%", "-inf%"] {
|
||||
assert!(line_height(&json!(s)).is_none(), "line_height accepted {s}");
|
||||
assert!(letter_spacing(&json!(s)).is_none(), "letter_spacing accepted {s}");
|
||||
}
|
||||
assert!(matches!(line_height(&json!("150%")), Some(LineHeight::Percent(p)) if p == 150.0));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -155,6 +155,17 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
ok()
|
||||
}
|
||||
),
|
||||
spec!(
|
||||
"proto.linkNotice",
|
||||
"Notice before leaving for a link",
|
||||
"{show? (default true; false skips it)} whether a presented prototype's Open link to a website first says that it leaves the prototype, with Open link and Cancel; kept with the preferences → {shown}",
|
||||
false,
|
||||
|s, p| {
|
||||
let show = boolean(p, "show").unwrap_or(true);
|
||||
s.prefs.link_notice_skipped = !show;
|
||||
Ok(json!({"shown": show}))
|
||||
}
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
spec!(
|
||||
"dev.compare",
|
||||
"Compare changes",
|
||||
"{id? (default the first selected layer), show? (open the compare view in the app)} → {id, name, changed, snapshot, added, removed, modified, truncated, changes: [{id, name, type, change: added|removed|changed, depth, layers, properties?: [{section, property, before, after, text}]}]} what changed in a ready-for-dev frame or section since it was marked (`snapshot` is false when that isn't known: a file from before snapshots were kept)",
|
||||
"{id? (default the first selected layer), version? (a version id from version.list: compare with that saved version instead; any top-level frame or section), show? (open the compare view in the app)} → {id, name, changed, snapshot, added, removed, modified, truncated, version: {id, name, time} | null, changes: [{id, name, type, change: added|removed|changed, depth, layers, properties?: [{section, property, before, after, text}]}]} what changed in a ready-for-dev frame or section since it was marked, or since the version (`snapshot` is false when that isn't known: a file from before snapshots were kept)",
|
||||
false,
|
||||
compare
|
||||
),
|
||||
@@ -104,14 +104,30 @@ fn resolve_change(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
fn compare(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "dev.compare";
|
||||
let id = targets(s, p).first().copied().ok_or_else(|| bad(cmd, "select a frame or section or pass `id`"))?;
|
||||
let doc = s.doc()?;
|
||||
let version = match get(p, "version") {
|
||||
None | Some(Value::Null) => None,
|
||||
Some(Value::String(v)) => Some(v.clone()),
|
||||
Some(_) => return Err(bad(cmd, "version: a version id (version.list)")),
|
||||
};
|
||||
let ds = s.ds()?;
|
||||
let doc = &ds.doc;
|
||||
let n = doc.node(id).ok_or_else(|| bad(cmd, "no such layer"))?;
|
||||
if !has_status(doc, id) || n.dev_status != DevStatus::ReadyForDev {
|
||||
return Err(bad(cmd, "compare a frame or section marked ready for dev"));
|
||||
if !has_status(doc, id) {
|
||||
return Err(bad(cmd, "compare a top-level frame or a section"));
|
||||
}
|
||||
let v = serde_json::to_value(crate::devcompare::compare(doc, id)).unwrap_or(Value::Null);
|
||||
let (cmp, against) = match &version {
|
||||
Some(v) => {
|
||||
let ver = ds.versions.iter().find(|x| x.id == *v).ok_or_else(|| bad(cmd, format!("no version `{v}`")))?;
|
||||
let before = crate::cmd::file::read_version(ver)?;
|
||||
(crate::devcompare::compare_with(&before, doc, id), json!({"id": ver.id, "name": ver.name, "time": ver.time}))
|
||||
}
|
||||
None if n.dev_status != DevStatus::ReadyForDev => return Err(bad(cmd, "compare a frame or section marked ready for dev, or pass `version`")),
|
||||
None => (crate::devcompare::compare(doc, id), Value::Null),
|
||||
};
|
||||
let mut v = serde_json::to_value(cmp).unwrap_or(Value::Null);
|
||||
v["version"] = against;
|
||||
if boolean(p, "show") == Some(true) {
|
||||
s.request_ui("view.devCompare", json!({"id": id}));
|
||||
s.request_ui("view.devCompare", json!({"id": id, "version": version}));
|
||||
}
|
||||
Ok(v)
|
||||
}
|
||||
|
||||
@@ -10,11 +10,13 @@ use super::{CommandSpec, ok};
|
||||
use crate::params::*;
|
||||
use crate::{EngineError, Result, Session, bad, spec};
|
||||
|
||||
/// The layer structure commands: group, frame, mask, boolean operations, flatten, auto layout
|
||||
/// add/remove, move to page and vector path edits.
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
spec!("object.group", "Group selection", "{ids?, name?}", true, |s, p| wrap(s, p, Wrap::Group)),
|
||||
spec!("object.group", "Group selection", "{ids?, name?}", true, |s, p| wrap(s, p, "object.group", Wrap::Group)),
|
||||
spec!("object.ungroup", "Ungroup selection", "{ids?}", true, ungroup),
|
||||
spec!("object.frameSelection", "Frame selection", "{ids?, name?}", true, |s, p| wrap(s, p, Wrap::Frame)),
|
||||
spec!("object.frameSelection", "Frame selection", "{ids?, name?}", true, |s, p| wrap(s, p, "object.frameSelection", Wrap::Frame)),
|
||||
spec!("object.removeFrame", "Remove frame", "{ids?}", true, ungroup),
|
||||
spec!(
|
||||
"object.convertToGroup",
|
||||
@@ -42,7 +44,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Wrap in new section",
|
||||
"{ids?, name?} (layers on the page or in a section; the section wraps them with some room)",
|
||||
true,
|
||||
|s, p| wrap(s, p, Wrap::Section)
|
||||
|s, p| wrap(s, p, "object.wrapInSection", Wrap::Section)
|
||||
),
|
||||
spec!(
|
||||
"object.resizeToFit",
|
||||
@@ -71,7 +73,9 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
// Hugging needs auto layout and the children that filled have nothing to fill: both
|
||||
// become fixed (the frame keeps filling its own auto layout parent).
|
||||
let doc = s.doc_mut()?;
|
||||
doc.get_mut(i)?.layout = None;
|
||||
let n = doc.get_mut(i)?;
|
||||
n.layout = None;
|
||||
super::props::drop_layout_bindings(n);
|
||||
figcraft_layout::coherent_sizing(doc, i);
|
||||
}
|
||||
ok()
|
||||
@@ -103,6 +107,20 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
}
|
||||
ok()
|
||||
}),
|
||||
spec!(
|
||||
"vector.fillRule",
|
||||
"Set fill rule",
|
||||
"{ids? | id?, region? (a filled area of a vector; every area when left out), rule: nonZero|evenOdd}",
|
||||
true,
|
||||
fill_rule
|
||||
),
|
||||
spec!(
|
||||
"vector.reverseLoop",
|
||||
"Reverse loop",
|
||||
"{ids? | id?, region, loop} (a loop of a filled area; its direction flips, which changes how the area fills)",
|
||||
true,
|
||||
reverse_loop
|
||||
),
|
||||
spec!("vector.simplify", "Simplify vector", "{ids?, tolerance?}", true, simplify),
|
||||
spec!("vector.offset", "Offset path", "{ids?, offset}", true, offset_path),
|
||||
spec!("vector.join", "Join selection", "{ids?} (in vector edit mode: joins the selected points)", true, |s, p| {
|
||||
@@ -128,26 +146,27 @@ const SECTION_PAD: f64 = 40.0;
|
||||
const BOOL_PARAMS: &str = "{ids?} — layers inside boolean groups: sets their own operation (how each combines with the operands below it); one boolean group: sets its operation (every operand follows it); otherwise wraps the layers in a new boolean group";
|
||||
|
||||
/// Wrap the selection in a new boolean group doing `op`.
|
||||
pub(crate) fn wrap_boolean(s: &mut Session, p: &Value, op: BoolOp) -> Result<Value> {
|
||||
wrap(s, p, Wrap::Bool(op))
|
||||
pub(crate) fn wrap_boolean(s: &mut Session, p: &Value, cmd: &str, op: BoolOp) -> Result<Value> {
|
||||
wrap(s, p, cmd, Wrap::Bool(op))
|
||||
}
|
||||
|
||||
/// Wrap the selection in a new container at the topmost selected layer's place.
|
||||
fn wrap(s: &mut Session, p: &Value, w: Wrap) -> Result<Value> {
|
||||
let ids = req_targets("object.group", s, p)?;
|
||||
/// Wrap the selection in a new container at the topmost selected layer's place. Errors name `cmd`,
|
||||
/// the command that asked.
|
||||
fn wrap(s: &mut Session, p: &Value, cmd: &str, w: Wrap) -> Result<Value> {
|
||||
let ids = req_targets(cmd, s, p)?;
|
||||
let doc = s.doc_mut()?;
|
||||
let ids = z_sorted(doc, &roots(doc, &ids));
|
||||
let top = *ids.last().ok_or_else(|| bad("object.group", "nothing selected"))?;
|
||||
let parent = doc.get(top)?.parent.ok_or_else(|| bad("object.group", "can't group a page"))?;
|
||||
let top = *ids.last().ok_or_else(|| bad(cmd, "nothing selected"))?;
|
||||
let parent = doc.get(top)?.parent.ok_or_else(|| bad(cmd, "can't group a page"))?;
|
||||
// Sections sit on the page or in sections, never in frames or groups.
|
||||
let board = |doc: &Document, i: NodeId| {
|
||||
doc.node(i).and_then(|n| n.parent).and_then(|p| doc.node(p)).is_some_and(|p| p.is_page() || matches!(p.kind, NodeKind::Section { .. }))
|
||||
};
|
||||
if matches!(w, Wrap::Section) && !ids.iter().all(|i| board(doc, *i)) {
|
||||
return Err(bad("object.wrapInSection", "only layers on the page or in a section can be wrapped in a section"));
|
||||
return Err(bad(cmd, "only layers on the page or in a section can be wrapped in a section"));
|
||||
}
|
||||
if !matches!(w, Wrap::Section) && ids.iter().any(|i| doc.node(*i).is_some_and(|n| matches!(n.kind, NodeKind::Section { .. }))) {
|
||||
return Err(bad("object.group", "sections can't go in groups or frames"));
|
||||
return Err(bad(cmd, "sections can't go in groups or frames"));
|
||||
}
|
||||
let index = doc.index_in_parent(top).map(|i| i + 1);
|
||||
let mut bounds = doc.bounds_of(&ids).unwrap_or_default();
|
||||
@@ -285,7 +304,7 @@ fn convert_to_section(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
n.grids.clear();
|
||||
n.guides.clear();
|
||||
n.styles.grid = None;
|
||||
n.bound.retain(|k, _| !k.starts_with("padding") && !matches!(k.as_str(), "gap" | "counterAxisSpacing"));
|
||||
super::props::drop_layout_bindings(n);
|
||||
let kids = n.children.clone();
|
||||
for k in kids {
|
||||
// Out of the flow there is nothing to fill or to be absolute against.
|
||||
@@ -338,7 +357,7 @@ fn mask(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return ok();
|
||||
}
|
||||
// Group, then the bottom-most child is the mask.
|
||||
let r = wrap(s, &json!({"ids": ids, "name": "Mask group"}), Wrap::Group)?;
|
||||
let r = wrap(s, &json!({"ids": ids, "name": "Mask group"}), "object.mask", Wrap::Group)?;
|
||||
let gid = id_of(&r["id"]).ok_or_else(|| EngineError::Other("group failed".into()))?;
|
||||
let doc = s.doc_mut()?;
|
||||
if let Some(first) = doc.get(gid)?.children.first().copied() {
|
||||
@@ -437,16 +456,19 @@ fn outline_stroke(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
continue;
|
||||
}
|
||||
let closed = figcraft_doc::geometry::is_closed(&n);
|
||||
let Some(geom) = (match &n.kind {
|
||||
NodeKind::Text(_) | NodeKind::BooleanOp { .. } => figcraft_render::node_outline(doc, &n).map(|(p, _)| p),
|
||||
_ => figcraft_doc::geometry::stroke_geometry(&n),
|
||||
let Some((area, rule)) = (match &n.kind {
|
||||
NodeKind::Text(_) => figcraft_render::node_outline(doc, &n)
|
||||
.map(|(p, _)| (figcraft_render::draw::stroke_area(&n, &p, closed), figcraft_render::FillRule::Winding)),
|
||||
_ => figcraft_render::stroke_fill(doc, &n),
|
||||
}) else {
|
||||
continue;
|
||||
};
|
||||
let area = figcraft_render::draw::stroke_area(&n, &geom, closed);
|
||||
let mut path = (*area).clone();
|
||||
// Union the pieces so the outline is one clean shape.
|
||||
if let Ok(u) = figcraft_pathops::simplify(&path) {
|
||||
// Simplify non-zero strokes into even-odd contours. Inside/outside rings already use
|
||||
// even-odd: interpreting their same-direction contours as non-zero would fill the hole.
|
||||
if rule == figcraft_render::FillRule::Winding
|
||||
&& let Ok(u) = figcraft_pathops::simplify(&path)
|
||||
{
|
||||
path = u;
|
||||
}
|
||||
let bb = path.bounding_box();
|
||||
@@ -472,7 +494,7 @@ fn outline_stroke(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let f = doc.get_mut(fill_id)?;
|
||||
f.strokes.clear();
|
||||
}
|
||||
let r = wrap(s, &json!({"ids": [fill_id, vid], "name": n.name}), Wrap::Group)?;
|
||||
let r = wrap(s, &json!({"ids": [fill_id, vid], "name": n.name}), "vector.outlineStroke", Wrap::Group)?;
|
||||
if let Some(g) = id_of(&r["id"]) {
|
||||
out.push(g);
|
||||
}
|
||||
@@ -519,7 +541,7 @@ fn layout_add(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
ids
|
||||
} else {
|
||||
// Wrap non-frames in a new frame without a fill, then add auto layout to it.
|
||||
let r = wrap(s, &json!({"ids": ids}), Wrap::Frame)?;
|
||||
let r = wrap(s, &json!({"ids": ids}), "layout.add", Wrap::Frame)?;
|
||||
let fid = id_of(&r["id"]).ok_or_else(|| EngineError::Other("wrap failed".into()))?;
|
||||
let f = s.doc_mut()?.get_mut(fid)?;
|
||||
f.fills.clear();
|
||||
@@ -724,3 +746,146 @@ fn resize_to_fit(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
Ok(json!({"ids": fitted}))
|
||||
}
|
||||
|
||||
/// The vector network of `id`, when it is a vector layer.
|
||||
fn network_mut(doc: &mut Document, id: NodeId) -> Option<&mut VectorNetwork> {
|
||||
match &mut doc.get_mut(id).ok()?.kind {
|
||||
NodeKind::Vector { network } => Some(network),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The id a fill rule is written as.
|
||||
fn rule_id(rule: WindingRule) -> &'static str {
|
||||
match rule {
|
||||
WindingRule::NonZero => "nonZero",
|
||||
WindingRule::EvenOdd => "evenOdd",
|
||||
}
|
||||
}
|
||||
|
||||
/// `region` as an index: absent (or null) means every area.
|
||||
fn area_index(cmd: &str, p: &Value) -> Result<Option<usize>> {
|
||||
match get(p, "region") {
|
||||
None | Some(Value::Null) => Ok(None),
|
||||
Some(v) => {
|
||||
let k = f64_of(v).ok_or_else(|| bad(cmd, "`region` must be a number"))?;
|
||||
if !(0.0..1e9).contains(&k) || k.fract() != 0.0 {
|
||||
return Err(bad(cmd, "`region` out of range"));
|
||||
}
|
||||
Ok(Some(k as usize))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Segments another one- or two-segment loop lists: a short loop takes its direction from the ends
|
||||
/// its segments are stored with, so re-storing one of those would turn that loop round too. A loop
|
||||
/// of three or more segments is left out: its ring is walked in list order, which the stored ends
|
||||
/// don't decide.
|
||||
fn short_loop_segments(net: &VectorNetwork, skip: (usize, usize)) -> std::collections::HashSet<usize> {
|
||||
let mut out = std::collections::HashSet::new();
|
||||
for (r, region) in net.regions.iter().enumerate() {
|
||||
for (l, lp) in region.loops.iter().enumerate() {
|
||||
if (r, l) != skip && lp.len() <= 2 {
|
||||
out.extend(lp.iter().copied());
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn fill_rule(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ids = req_targets("vector.fillRule", s, p)?;
|
||||
let rule = match string(p, "rule").as_deref().map(str::trim) {
|
||||
Some("evenOdd" | "evenodd" | "EVENODD") => WindingRule::EvenOdd,
|
||||
Some("nonZero" | "nonzero" | "NONZERO") => WindingRule::NonZero,
|
||||
_ => return Err(bad("vector.fillRule", "`rule` is nonZero or evenOdd")),
|
||||
};
|
||||
let only = area_index("vector.fillRule", p)?;
|
||||
let doc = s.doc_mut()?;
|
||||
let mut count = 0usize;
|
||||
for id in ids {
|
||||
let Some(net) = network_mut(doc, id) else { continue };
|
||||
match only {
|
||||
Some(r) => {
|
||||
if let Some(region) = net.regions.get_mut(r) {
|
||||
region.winding = rule;
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
None => {
|
||||
for region in &mut net.regions {
|
||||
region.winding = rule;
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if count == 0 {
|
||||
return Err(EngineError::Disabled("vector.fillRule".into(), "no filled area to set (select a vector)".into()));
|
||||
}
|
||||
Ok(json!({"regions": count, "rule": rule_id(rule)}))
|
||||
}
|
||||
|
||||
/// One loop of a vector, worked out but not yet written back: its segments in the new order and,
|
||||
/// for a loop of one or two segments, the one to store the other way.
|
||||
struct LoopFlip {
|
||||
id: NodeId,
|
||||
turned: Vec<usize>,
|
||||
store: Option<usize>,
|
||||
}
|
||||
|
||||
/// Turn one loop of a filled area round: its segments are walked the other way, so the area it
|
||||
/// bounds counts the other way in the winding (which turns a hole into a filled part and back).
|
||||
///
|
||||
/// How a loop is walked depends on how many segments it has:
|
||||
/// - three or more: the order they are listed in, each segment turned to continue from the point
|
||||
/// reached. Reversing the list is therefore enough, and the stored ends are left alone (a shared
|
||||
/// segment's ends belong to its neighbour too);
|
||||
/// - two: the same ring either way round, so one of the two has to be stored the other way instead;
|
||||
/// - one: only its stored ends say which way it runs, so they are swapped.
|
||||
///
|
||||
/// A short loop can only turn round by re-storing a segment, which is refused when another short
|
||||
/// loop lists that segment too: it would turn that area round as well.
|
||||
fn reverse_loop(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "vector.reverseLoop";
|
||||
let ids = req_targets(cmd, s, p)?;
|
||||
let region = req_index(cmd, p, "region")?;
|
||||
let index = req_index(cmd, p, "loop")?;
|
||||
let doc = s.doc_mut()?;
|
||||
// Work every loop out before touching the document: one that can't turn round refuses the call
|
||||
// and the others stay as they were.
|
||||
let mut flips: Vec<LoopFlip> = Vec::new();
|
||||
for id in ids {
|
||||
let Some(NodeKind::Vector { network }) = doc.node(id).map(|n| &n.kind) else { continue };
|
||||
let Some(loop_) = network.regions.get(region).and_then(|r| r.loops.get(index)) else { continue };
|
||||
if loop_.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let mut store = None;
|
||||
if loop_.len() <= 2 {
|
||||
let busy = short_loop_segments(network, (region, index));
|
||||
let Some(pick) = loop_.iter().copied().filter(|s| !busy.contains(s)).min() else {
|
||||
return Err(EngineError::Disabled(
|
||||
cmd.into(),
|
||||
"the loop shares its segments with another one- or two-segment loop, so turning it round would turn that one round too".into(),
|
||||
));
|
||||
};
|
||||
store = Some(pick);
|
||||
}
|
||||
flips.push(LoopFlip { id, turned: loop_.iter().rev().copied().collect(), store });
|
||||
}
|
||||
if flips.is_empty() {
|
||||
return Err(EngineError::Disabled(cmd.into(), "no such loop (select a vector with filled areas)".into()));
|
||||
}
|
||||
for LoopFlip { id, turned, store } in flips {
|
||||
let Some(net) = network_mut(doc, id) else { continue };
|
||||
if let Some(seg) = store.and_then(|i| net.segments.get_mut(i)) {
|
||||
std::mem::swap(&mut seg.a, &mut seg.b);
|
||||
std::mem::swap(&mut seg.ta, &mut seg.tb);
|
||||
}
|
||||
if let Some(l) = net.regions.get_mut(region).and_then(|r| r.loops.get_mut(index)) {
|
||||
*l = turned;
|
||||
}
|
||||
}
|
||||
Ok(json!({"reversed": true}))
|
||||
}
|
||||
|
||||
+133
-11
@@ -110,16 +110,26 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
))
|
||||
}),
|
||||
spec!("variable.createCollection", "Create variable collection", "{name, modes? [names]}", true, |s, p| {
|
||||
let name = req_string("variable.createCollection", p, "name")?;
|
||||
let doc = s.doc_mut()?;
|
||||
if doc.variables.collections.iter().any(|c| c.name == name) {
|
||||
return Err(bad("variable.createCollection", format!("a collection named `{name}` already exists")));
|
||||
let cmd = "variable.createCollection";
|
||||
let name = req_string(cmd, p, "name")?.trim().to_string();
|
||||
if name.is_empty() {
|
||||
return Err(bad(cmd, "name can't be empty"));
|
||||
}
|
||||
let id = doc.new_collection_id();
|
||||
let modes: Vec<String> = match get(p, "modes") {
|
||||
Some(Value::Array(a)) => a.iter().filter_map(|m| m.as_str().map(str::to_string)).collect(),
|
||||
Some(Value::Array(a)) => a.iter().filter_map(|m| m.as_str().map(|m| m.trim().to_string())).collect(),
|
||||
_ => vec!["Mode 1".into()],
|
||||
};
|
||||
if modes.is_empty() {
|
||||
return Err(bad(cmd, "a collection needs at least one mode"));
|
||||
}
|
||||
if modes.iter().any(String::is_empty) {
|
||||
return Err(bad(cmd, "mode names can't be empty"));
|
||||
}
|
||||
let doc = s.doc_mut()?;
|
||||
if doc.variables.collections.iter().any(|c| c.name == name) {
|
||||
return Err(bad(cmd, format!("a collection named `{name}` already exists")));
|
||||
}
|
||||
let id = doc.new_collection_id();
|
||||
let modes = modes.into_iter().enumerate().map(|(i, m)| Mode { id: format!("{id}/mode{}", i + 1), name: m }).collect();
|
||||
doc.variables.collections.push(Collection { id: id.clone(), name, modes, extends: None });
|
||||
Ok(json!({"id": id}))
|
||||
@@ -794,10 +804,10 @@ pub fn bind(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
ok()
|
||||
}
|
||||
|
||||
/// A colour variable bound to a solid paint owns its whole colour, alpha included. Binding restarts
|
||||
/// the opacity (which holds the alpha a typed colour had, see `Paint::solid`) at 1, so the
|
||||
/// variable's alpha applies once; unbinding moves the colour's alpha back into the opacity, where a
|
||||
/// typed colour keeps it. An opacity set while bound multiplies on top and stays.
|
||||
/// A colour variable bound to a solid paint owns its whole colour, alpha included. Binding takes the
|
||||
/// alpha a typed colour had (see `Paint::solid`) out of the opacity, so the variable's alpha applies
|
||||
/// once; an opacity set on its own, before or while bound, multiplies on top and stays. Unbinding
|
||||
/// moves the colour's alpha back into the opacity, where a typed colour keeps it.
|
||||
pub(crate) fn rebind_paint_alpha(fills: &mut [Paint], strokes: &mut [Paint], field: &str, bind: bool) {
|
||||
use super::lists::{BoundList, list_binding};
|
||||
let Some((list, i, "color")) = list_binding(field) else { return };
|
||||
@@ -809,7 +819,14 @@ pub(crate) fn rebind_paint_alpha(fills: &mut [Paint], strokes: &mut [Paint], fie
|
||||
if let Some(p) = paints.get_mut(i)
|
||||
&& let PaintKind::Solid { color } = &mut p.kind
|
||||
{
|
||||
p.opacity = if bind { 1.0 } else { p.opacity * color.a };
|
||||
if bind {
|
||||
// What's left is the opacity set on its own (unknown under a colour alpha of 0: 1).
|
||||
p.opacity = if p.color_alpha > 0.0 { (p.opacity / p.color_alpha).clamp(0.0, 1.0) } else { 1.0 };
|
||||
p.color_alpha = 1.0;
|
||||
} else {
|
||||
p.opacity *= color.a;
|
||||
p.color_alpha *= color.a;
|
||||
}
|
||||
color.a = 1.0;
|
||||
}
|
||||
}
|
||||
@@ -950,6 +967,104 @@ pub fn apply_bindings_to(doc: &mut Document, only: Option<&std::collections::BTr
|
||||
apply_bindings_matching(doc, only, |_| true);
|
||||
}
|
||||
|
||||
/// The layers `ids` before an explicit edit, for [`detach_edited`] (only those with bindings).
|
||||
pub(crate) fn bound_before(doc: &Document, ids: &[NodeId]) -> Vec<std::sync::Arc<Node>> {
|
||||
ids.iter().filter_map(|i| doc.node_arc(*i)).filter(|n| !n.bound.is_empty()).cloned().collect()
|
||||
}
|
||||
|
||||
/// An explicit edit sticks: each layer's own bound field (not a list item's) whose value the edit
|
||||
/// changed drops its binding, which would otherwise write the variable's value back (#809).
|
||||
/// Compared by value, so keys written unchanged keep their bindings and fields moved along with
|
||||
/// another (proportions, min/max) lose theirs.
|
||||
pub(crate) fn detach_edited(doc: &mut Document, before: &[std::sync::Arc<Node>]) {
|
||||
for old in before {
|
||||
let Some(n) = doc.node(old.id) else { continue };
|
||||
let edited: Vec<String> = n
|
||||
.bound
|
||||
.keys()
|
||||
// A binding the edit itself made (`boundVariables`) stays.
|
||||
.filter(|f| old.bound.get(*f) == n.bound.get(*f))
|
||||
.filter(|f| super::lists::list_binding(f).is_none() && !f.starts_with(crate::components::PROP_BINDING))
|
||||
.filter(|f| bound_field(old, f) != bound_field(n, f))
|
||||
.cloned()
|
||||
.collect();
|
||||
if edited.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if let Ok(n) = doc.get_mut(old.id) {
|
||||
for f in edited {
|
||||
n.bound.remove(&f);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The stored value a layer-level binding writes, comparable (`None`: the layer has no such field).
|
||||
fn bound_field(n: &Node, field: &str) -> Option<String> {
|
||||
let text = |f: &dyn Fn(&TextStyle) -> String| match &n.kind {
|
||||
NodeKind::Text(t) => Some(std::iter::once(&t.style).chain(t.runs.iter().map(|r| &r.style)).map(f).collect::<Vec<_>>().join(",")),
|
||||
_ => None,
|
||||
};
|
||||
let layout = n.layout.as_ref();
|
||||
let cl = &n.child_layout;
|
||||
let pad = |i: usize| layout.and_then(|l| l.padding.get(i).copied());
|
||||
Some(match field {
|
||||
"width" | "w" => format!("{}", n.width),
|
||||
"height" | "h" => format!("{}", n.height),
|
||||
"minWidth" => format!("{:?}", cl.min_w),
|
||||
"maxWidth" => format!("{:?}", cl.max_w),
|
||||
"minHeight" => format!("{:?}", cl.min_h),
|
||||
"maxHeight" => format!("{:?}", cl.max_h),
|
||||
"opacity" => format!("{}", n.opacity),
|
||||
"visible" => format!("{}", n.visible),
|
||||
"gap" | "columnGap" => format!("{:?}", layout.map(|l| l.gap)),
|
||||
"crossGap" | "rowGap" => format!("{:?}", layout.map(|l| l.cross_gap)),
|
||||
"padding" => format!("{:?}", layout.map(|l| l.padding)),
|
||||
"paddingTop" => format!("{:?}", pad(0)),
|
||||
"paddingRight" => format!("{:?}", pad(1)),
|
||||
"paddingBottom" => format!("{:?}", pad(2)),
|
||||
"paddingLeft" => format!("{:?}", pad(3)),
|
||||
"paddingHorizontal" => format!("{:?}", (pad(1), pad(3))),
|
||||
"paddingVertical" => format!("{:?}", (pad(0), pad(2))),
|
||||
"cornerRadius" => format!("{:?}", n.corners.radii),
|
||||
"topLeftRadius" => format!("{:?}", n.corners.radii.first()),
|
||||
"topRightRadius" => format!("{:?}", n.corners.radii.get(1)),
|
||||
"bottomRightRadius" => format!("{:?}", n.corners.radii.get(2)),
|
||||
"bottomLeftRadius" => format!("{:?}", n.corners.radii.get(3)),
|
||||
"strokeWeight" => format!("{:?}", (n.stroke.weight, n.stroke.sides)),
|
||||
"strokeTopWeight" | "strokeRightWeight" | "strokeBottomWeight" | "strokeLeftWeight" => format!("{:?}", n.stroke.sides),
|
||||
"arcStart" | "arcSweep" | "arcInner" => match &n.kind {
|
||||
NodeKind::Ellipse { start, sweep, inner } => format!(
|
||||
"{}",
|
||||
if field == "arcStart" {
|
||||
start
|
||||
} else if field == "arcSweep" {
|
||||
sweep
|
||||
} else {
|
||||
inner
|
||||
}
|
||||
),
|
||||
_ => return None,
|
||||
},
|
||||
"characters" => match &n.kind {
|
||||
NodeKind::Text(t) => t.characters.clone(),
|
||||
_ => return None,
|
||||
},
|
||||
"paragraphSpacing" | "paragraphIndent" => match &n.kind {
|
||||
NodeKind::Text(t) => format!("{}", if field == "paragraphSpacing" { t.paragraph_spacing } else { t.paragraph_indent }),
|
||||
_ => return None,
|
||||
},
|
||||
"fontFamily" => text(&|s| s.font_family.clone())?,
|
||||
"fontStyle" => text(&|s| s.font_style.clone())?,
|
||||
"fontWeight" => text(&|s| s.weight().to_string())?,
|
||||
"fontSize" => text(&|s| s.font_size.to_string())?,
|
||||
"lineHeight" => text(&|s| format!("{:?}", s.line_height))?,
|
||||
"letterSpacing" => text(&|s| format!("{:?}", s.letter_spacing))?,
|
||||
// shortcut: other layer-level fields keep their binding on edits; add a reader when one is bound in practice.
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Imported files (`.fig`, REST) keep a solid paint's alpha in its opacity, a bound paint's too,
|
||||
/// while the bound colour brings its own alpha: take the variable's alpha back out of the opacity
|
||||
/// (styles' paints and layers') before the bindings are first resolved, so it applies once.
|
||||
@@ -1007,6 +1122,8 @@ pub(crate) fn apply_paint_bindings_on_load(doc: &mut Document) {
|
||||
doc.mark_dirty(pending);
|
||||
}
|
||||
|
||||
/// Re-apply the variable bindings whose field passes `include`, on the `only` nodes (every bound
|
||||
/// node when `None`). Auto layout fields wait while their node has no auto layout.
|
||||
fn apply_bindings_matching(doc: &mut Document, only: Option<&std::collections::BTreeSet<NodeId>>, include: impl Fn(&str) -> bool) {
|
||||
use super::lists::{BoundList, list_binding};
|
||||
if doc.variables.variables.is_empty() {
|
||||
@@ -1021,6 +1138,7 @@ fn apply_bindings_matching(doc: &mut Document, only: Option<&std::collections::B
|
||||
let modes = doc.modes_for(id);
|
||||
let Some(n) = doc.node(id) else { continue };
|
||||
let binds = n.bound.clone();
|
||||
let has_layout = n.layout.is_some();
|
||||
let mut sets = serde_json::Map::new();
|
||||
let mut effect_sets: Vec<(usize, String, VariableValue)> = Vec::new();
|
||||
let mut paint_sets: Vec<(bool, usize, Option<usize>, Color)> = Vec::new();
|
||||
@@ -1030,6 +1148,10 @@ fn apply_bindings_matching(doc: &mut Document, only: Option<&std::collections::B
|
||||
if !include(&field) {
|
||||
continue;
|
||||
}
|
||||
// A binding never creates an auto layout: without one it waits for the next.
|
||||
if !has_layout && super::props::LAYOUT_KEYS.contains(&field.as_str()) {
|
||||
continue;
|
||||
}
|
||||
let Some(val) = doc.variables.resolve(&var, &modes) else { continue };
|
||||
// One list item's value: a paint's colour, an effect's or a layout grid's field.
|
||||
if let Some((list, i, prop)) = list_binding(&field) {
|
||||
|
||||
@@ -18,6 +18,7 @@ fn style(s: &mut Session, p: &Value, f: impl Fn(&mut TextStyle)) -> Result<Value
|
||||
let ds = s.ds_mut()?;
|
||||
let before = ds.doc.clone();
|
||||
let doc = ds.doc_mut();
|
||||
let bound = super::styles::bound_before(doc, &[id]);
|
||||
if let NodeKind::Text(t) = &mut doc.get_mut(id)?.kind {
|
||||
if a == b {
|
||||
t.restyle(0, t.char_count(), &f);
|
||||
@@ -25,19 +26,21 @@ fn style(s: &mut Session, p: &Value, f: impl Fn(&mut TextStyle)) -> Result<Value
|
||||
t.restyle(a, b, &f);
|
||||
}
|
||||
}
|
||||
super::styles::detach_edited(doc, &bound);
|
||||
crate::normalize(doc, &before);
|
||||
return ok();
|
||||
}
|
||||
let ids = req_targets("text", s, p)?;
|
||||
let doc = s.doc_mut()?;
|
||||
for i in ids {
|
||||
for d in doc.subtree(i) {
|
||||
if let Some(NodeKind::Text(t)) = doc.node_mut(d).map(|n| &mut n.kind) {
|
||||
let n = t.char_count();
|
||||
t.restyle(0, n, &f);
|
||||
}
|
||||
let ids: Vec<NodeId> = ids.into_iter().flat_map(|i| doc.subtree(i)).collect();
|
||||
let before = super::styles::bound_before(doc, &ids);
|
||||
for d in ids {
|
||||
if let Some(NodeKind::Text(t)) = doc.node_mut(d).map(|n| &mut n.kind) {
|
||||
let n = t.char_count();
|
||||
t.restyle(0, n, &f);
|
||||
}
|
||||
}
|
||||
super::styles::detach_edited(doc, &before);
|
||||
ok()
|
||||
}
|
||||
|
||||
@@ -123,7 +126,9 @@ fn step_weight(s: &mut Session, p: &Value, up: bool) -> Result<Value> {
|
||||
}
|
||||
|
||||
/// Put a text layer on a path: the text takes the path layer's place, geometry and transform, and
|
||||
/// the path layer is removed.
|
||||
/// the path layer is removed. The path's effects move to the text when it has any, and its fills
|
||||
/// when one of them shows, each with its style and variable bindings; otherwise the text keeps
|
||||
/// its own.
|
||||
fn on_path(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const CMD: &str = "text.onPath";
|
||||
let doc = s.doc()?;
|
||||
@@ -141,6 +146,14 @@ fn on_path(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
let (parent, index) = (pn.parent.ok_or_else(|| bad(CMD, "pages have no path"))?, doc.index_in_parent(path));
|
||||
let (transform, w, h) = (pn.transform, pn.width, pn.height);
|
||||
let fills = pn.fills.iter().any(|f| f.visible).then(|| (pn.fills.clone(), pn.styles.fill.clone()));
|
||||
let effects = (!pn.effects.is_empty()).then(|| (pn.effects.clone(), pn.styles.effect.clone()));
|
||||
let moved = |k: &str| match super::lists::list_binding(k) {
|
||||
Some((super::lists::BoundList::Fills, ..)) => fills.is_some(),
|
||||
Some((super::lists::BoundList::Effects, ..)) => effects.is_some(),
|
||||
_ => false,
|
||||
};
|
||||
let bound: Vec<(String, VariableId)> = pn.bound.iter().filter(|(k, _)| moved(k)).map(|(k, v)| (k.clone(), v.clone())).collect();
|
||||
let mut tp = TextPath::new(&geom);
|
||||
tp.start = num(p, "start").filter(|v| v.is_finite()).unwrap_or(0.0).clamp(0.0, 1.0);
|
||||
tp.flip = boolean(p, "flip").unwrap_or(false);
|
||||
@@ -150,6 +163,14 @@ fn on_path(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let n = doc.get_mut(text)?;
|
||||
n.transform = transform;
|
||||
(n.width, n.height) = (w, h);
|
||||
n.bound.retain(|k, _| !moved(k));
|
||||
n.bound.extend(bound);
|
||||
if let Some((fills, style)) = fills {
|
||||
(n.fills, n.styles.fill) = (fills, style);
|
||||
}
|
||||
if let Some((effects, style)) = effects {
|
||||
(n.effects, n.styles.effect) = (effects, style);
|
||||
}
|
||||
if let NodeKind::Text(t) = &mut n.kind {
|
||||
t.path = Some(Box::new(tp));
|
||||
}
|
||||
@@ -162,7 +183,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
spec!(
|
||||
"text.onPath",
|
||||
"Text on path",
|
||||
"{text? (text layer), path? (vector or shape layer; default: the selected text and other layer), start? (0..1 along the path), flip?} the text follows the path and the path layer is removed → {id}",
|
||||
"{text? (text layer), path? (vector or shape layer; default: the selected text and other layer), start? (0..1 along the path), flip?} the text follows the path and takes its effects and its fills when one shows, and the path layer is removed → {id}",
|
||||
true,
|
||||
on_path
|
||||
),
|
||||
@@ -348,11 +369,13 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
let c = string(p, "characters").or_else(|| string(p, "text")).ok_or_else(|| bad("text.set", "`characters` required"))?;
|
||||
let ids = req_targets("text.set", s, p)?;
|
||||
let doc = s.doc_mut()?;
|
||||
let before = super::styles::bound_before(doc, &ids);
|
||||
for i in ids {
|
||||
let mut m = Map::new();
|
||||
m.insert("characters".into(), json!(c));
|
||||
super::props::apply("text.set", doc, i, &m)?;
|
||||
}
|
||||
super::styles::detach_edited(doc, &before);
|
||||
ok()
|
||||
}),
|
||||
spec!(
|
||||
@@ -364,6 +387,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
let ids = req_targets("text.style", s, p)?;
|
||||
let m = p.as_object().cloned().unwrap_or_default();
|
||||
let doc = s.doc_mut()?;
|
||||
let before = super::styles::bound_before(doc, &ids);
|
||||
for i in ids {
|
||||
let n = doc.get_mut(i)?;
|
||||
let NodeKind::Text(t) = &mut n.kind else { return Err(bad("text.style", format!("{i} is not text"))) };
|
||||
@@ -377,6 +401,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
super::props::apply_text_style("text.style", t, a, b, k, v)?;
|
||||
}
|
||||
}
|
||||
super::styles::detach_edited(doc, &before);
|
||||
ok()
|
||||
}
|
||||
),
|
||||
@@ -474,5 +499,40 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
})
|
||||
}
|
||||
),
|
||||
spec!(
|
||||
"text.rescanFonts",
|
||||
"Rescan installed fonts",
|
||||
"{} walks the system font folders again, for fonts installed while the app runs; the open document's auto-sizing text in the families that gained faces (new ones, or styles installed since) fits them (text.refit) → {added: [families that were unknown], refitted: [ids]}",
|
||||
true,
|
||||
|s, _| {
|
||||
let rescan = figcraft_text::rescan_system_fonts();
|
||||
let refitted = crate::fonts::refit_open(s, None, Some(&rescan.changed));
|
||||
Ok(json!({"added": rescan.added, "refitted": refitted}))
|
||||
}
|
||||
),
|
||||
spec!(
|
||||
"text.loadFont",
|
||||
"Load font file",
|
||||
"{path | data (base64)} adds a font file (.ttf, .otf, .ttc, .otc; every face of a collection) to this session's fonts without installing it; the open document's auto-sizing text in its families fits it (text.refit) → {families, refitted: [ids]}",
|
||||
true,
|
||||
|s, p| {
|
||||
let cmd = "text.loadFont";
|
||||
let bytes = if let Some(b64) = string(p, "data") {
|
||||
use base64::Engine as _;
|
||||
let b64 = b64.split_once(',').map(|(_, d)| d.to_string()).unwrap_or(b64);
|
||||
base64::engine::general_purpose::STANDARD.decode(b64.trim()).map_err(|e| bad(cmd, format!("`data`: {e}")))?
|
||||
} else if let Some(path) = string(p, "path") {
|
||||
super::file::read_capped(&path, figcraft_text::fontdb::MAX_FONT_FILE)?
|
||||
} else {
|
||||
return Err(bad(cmd, "pass `path` or `data` (base64)"));
|
||||
};
|
||||
let families = figcraft_text::load_font_bytes(bytes);
|
||||
if families.is_empty() {
|
||||
return Err(bad(cmd, "not a font file"));
|
||||
}
|
||||
let refitted = crate::fonts::refit_open(s, None, Some(&families));
|
||||
Ok(json!({"families": families, "refitted": refitted}))
|
||||
}
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
spec!(
|
||||
"variable.export",
|
||||
"Export variables",
|
||||
"{collections? [ids or names; default every collection of this file, not library copies], format? (json (default): the local variables JSON that variable.import reads | css: custom properties, the default mode on :root and each other mode on [data-<collection>=\"<mode>\"]), path? (write it there)} → {text}, or {path} when written",
|
||||
"{collections? [ids or names; default every collection of this file, not library copies], format? (json (default): the local variables JSON that variable.import reads | css: custom properties, the default mode on :root and each other mode on [data-<collection>=\"<mode>\"]; numbers in px unless layers or scopes use them as opacity or font weight | tailwind: a Tailwind CSS v4 stylesheet, the default mode as an @theme block named the way inspect.code's tailwind utilities read them: --color-*, --radius-*, --text-*, --spacing-*, --font-*, unique per file), path? (write it there)} → {text}, or {path} when written",
|
||||
false,
|
||||
export
|
||||
),
|
||||
@@ -46,8 +46,9 @@ fn export(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let text = match string(p, "format").as_deref().unwrap_or("json") {
|
||||
"json" => serde_json::to_string_pretty(&figcraft_format::rest::export_variables(&doc.variables, &colls))
|
||||
.map_err(|e| EngineError::Other(e.to_string()))?,
|
||||
"css" => css(&doc.variables, &colls),
|
||||
_ => return Err(bad(cmd, "format: json|css")),
|
||||
"css" => css(doc, &colls, false),
|
||||
"tailwind" => css(doc, &colls, true),
|
||||
_ => return Err(bad(cmd, "format: json|css|tailwind")),
|
||||
};
|
||||
match string(p, "path") {
|
||||
Some(path) => {
|
||||
@@ -66,26 +67,40 @@ fn css_safe(text: &str) -> String {
|
||||
/// Each collection's variables as custom properties (named as generated code names them): the
|
||||
/// default mode's values on `:root`, every other mode's on `[data-<collection>="<mode>"]`;
|
||||
/// aliases are `var(--name)`.
|
||||
fn css(vars: &Variables, colls: &[&Collection]) -> String {
|
||||
let mut out = String::new();
|
||||
fn css(doc: &Document, colls: &[&Collection], tailwind: bool) -> String {
|
||||
let vars = &doc.variables;
|
||||
// Tailwind theme names, unique per file and the same ones page code reads.
|
||||
let theme = crate::tailwind::Theme::new(doc);
|
||||
let name = |v: &Variable| if tailwind { theme.name(v) } else { css_var_of(v) };
|
||||
// Lengths need a unit where CSS reads them (`border-radius: var(--radius)`); opacity and
|
||||
// font weight don't.
|
||||
let roles = crate::token_roles::Roles::new(doc);
|
||||
let mut out = if tailwind { "@import \"tailwindcss\";\n\n".to_string() } else { String::new() };
|
||||
for c in colls {
|
||||
let attr = css_var_name(&c.name);
|
||||
for (i, m) in c.modes.iter().enumerate() {
|
||||
let sel = if i == 0 { ":root".to_string() } else { format!("[data-{}=\"{}\"]", attr.trim_start_matches('-'), css_safe(&m.name)) };
|
||||
let sel = match i {
|
||||
// `static`: Tailwind otherwise drops theme variables no utility uses, and pages also
|
||||
// read them through `var()` and `(--name)` utilities.
|
||||
0 if tailwind => "@theme static".to_string(),
|
||||
0 => ":root".to_string(),
|
||||
_ => format!("[data-{}=\"{}\"]", attr.trim_start_matches('-'), css_safe(&m.name)),
|
||||
};
|
||||
out.push_str(&format!("/* {} · {} */\n{sel} {{\n", css_safe(&c.name), css_safe(&m.name)));
|
||||
for v in vars.variables.iter().filter(|v| v.collection == c.id) {
|
||||
let value = match v.values.get(&m.id) {
|
||||
Some(VariableValue::Color(col)) => col.to_css(),
|
||||
Some(VariableValue::Number(x)) => trim_num(*x),
|
||||
Some(VariableValue::Number(x)) if roles.role(v) == crate::token_roles::NumberRole::Unitless => trim_num(*x),
|
||||
Some(VariableValue::Number(x)) => format!("{}px", trim_num(*x)),
|
||||
Some(VariableValue::String(t)) => format!("\"{}\"", css_safe(t)),
|
||||
Some(VariableValue::Boolean(b)) => b.to_string(),
|
||||
Some(VariableValue::Alias(a)) => match vars.get(a) {
|
||||
Some(t) => format!("var({})", css_var_of(t)),
|
||||
Some(t) => format!("var({})", name(t)),
|
||||
None => continue,
|
||||
},
|
||||
None => continue,
|
||||
};
|
||||
out.push_str(&format!(" {}: {value};\n", css_var_of(v)));
|
||||
out.push_str(&format!(" {}: {value};\n", name(v)));
|
||||
}
|
||||
out.push_str("}\n");
|
||||
}
|
||||
|
||||
@@ -200,7 +200,7 @@ fn pref(s: &mut Session, p: &Value, f: fn(&mut crate::Prefs) -> &mut bool) -> Re
|
||||
Ok(json!({"on": *slot}))
|
||||
}
|
||||
|
||||
fn tool(s: &mut Session, t: ToolId) -> Result<Value> {
|
||||
pub(crate) fn tool(s: &mut Session, t: ToolId) -> Result<Value> {
|
||||
// A file you can't edit now (view only, or on its way to a server): moving around only.
|
||||
if let Some(why) = s.active_read_only().filter(|_| !matches!(t, ToolId::Move | ToolId::Hand)) {
|
||||
return Err(EngineError::Disabled(format!("tool.{}", t.id()), why.into()));
|
||||
@@ -370,7 +370,25 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
spec!("pref.showDimensions", "Show dimensions on objects", "{on?}", false, |s, p| pref(s, p, |x| &mut x.show_dimensions)),
|
||||
spec!("pref.numberKeysOpacity", "Use number keys for opacity", "{on?}", false, |s, p| pref(s, p, |x| &mut x.number_keys_opacity)),
|
||||
spec!("pref.reduceMotion", "Reduce motion", "{on?}", false, |s, p| pref(s, p, |x| &mut x.reduce_motion)),
|
||||
spec!(
|
||||
"pref.uiScale",
|
||||
"Interface size",
|
||||
"{scale?: a size from 0.75 to 3 relative to the system's display scale, 1 being the system's own size, so 1.25 is 125 %, not 125 (outside the range it stops at the nearest end; \"auto\" is 1; omit: just read it)} → {scale}: how large the whole interface is drawn; the canvas zoom is separate",
|
||||
false,
|
||||
|s, p| {
|
||||
match get(p, "scale") {
|
||||
None => {}
|
||||
Some(Value::String(v)) if v.trim().eq_ignore_ascii_case("auto") => s.prefs.ui_scale = 1.0,
|
||||
Some(_) => {
|
||||
let scale = req_num("pref.uiScale", p, "scale")?;
|
||||
s.prefs.ui_scale = crate::clamp_ui_scale(scale).ok_or_else(|| bad("pref.uiScale", "`scale` must be a finite number"))?;
|
||||
}
|
||||
}
|
||||
Ok(json!({"scale": s.prefs.ui_scale}))
|
||||
}
|
||||
),
|
||||
spec!("pref.autoSave", "Save files automatically", "{on?}", false, |s, p| pref(s, p, |x| &mut x.auto_save)),
|
||||
spec!("pref.startOnHome", "Start on the Home screen", "{on?}", false, |s, p| pref(s, p, |x| &mut x.start_on_home)),
|
||||
spec!("pref.nudge", "Nudge amount", "{small?, big?}", false, |s, p| {
|
||||
s.prefs.nudge_small = num(p, "small").unwrap_or(s.prefs.nudge_small).clamp(0.01, 1000.0);
|
||||
s.prefs.nudge_big = num(p, "big").unwrap_or(s.prefs.nudge_big).clamp(0.01, 10000.0);
|
||||
@@ -409,7 +427,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
spec!(
|
||||
"pref.language",
|
||||
"Interface language",
|
||||
"{language: system|en|cs|ru|es|es-419|fr|de|pt-BR|ja|ko|zh-Hans|…; else English}",
|
||||
"{language: system|en|cs|ru|es|es-419|fr|de|pt-BR|ja|ka|ko|vi|zh-Hans|…; else English}",
|
||||
false,
|
||||
|s, p| {
|
||||
let lang = req_string("pref.language", p, "language")?;
|
||||
@@ -447,11 +465,14 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
spec!(
|
||||
"tool.set",
|
||||
"Choose tool",
|
||||
"{tool: move|scale|hand|frame|section|slice|rectangle|line|arrow|ellipse|polygon|star|pen|pencil|text|comment|shapeBuilder}",
|
||||
"{tool: move|scale|hand|frame|section|slice|rectangle|line|arrow|ellipse|polygon|star|pen|pencil|text|comment|shapeBuilder | on boards: sticky|shape|connector|marker|highlighter|eraser|stamp}",
|
||||
false,
|
||||
|s, p| {
|
||||
let t = req_string("tool.set", p, "tool")?;
|
||||
let t = ToolId::from_id(&t).ok_or_else(|| bad("tool.set", format!("unknown tool `{t}`")))?;
|
||||
if t.on_boards() {
|
||||
crate::board::require(s, &format!("tool.{}", t.id()))?;
|
||||
}
|
||||
tool(s, t)
|
||||
}
|
||||
),
|
||||
@@ -514,11 +535,42 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
spec!("tool.pen", "Pen", "{}", false, |s, _| tool(s, ToolId::Pen)),
|
||||
spec!("tool.pencil", "Pencil", "{}", false, |s, _| tool(s, ToolId::Pencil)),
|
||||
spec!("tool.text", "Text", "{}", false, |s, _| tool(s, ToolId::Text)),
|
||||
spec!("tool.textOnPath", "Text on a path", "{} a click on a shape's or vector's path puts new text on it", false, |s, _| {
|
||||
tool(s, ToolId::TextOnPath)
|
||||
}),
|
||||
spec!("tool.comment", "Comment", "{}", false, |s, _| tool(s, ToolId::Comment)),
|
||||
spec!("tool.shapeBuilder", "Shape builder", "{}", false, |s, _| tool(s, ToolId::ShapeBuilder)),
|
||||
spec!("edit.pickColor", "Pick color", "{}", false, |s, _| tool(s, ToolId::Eyedropper)),
|
||||
// `apply: false` is for the UI colour picker: the eyedropper only samples (into
|
||||
// `ToolState::picked`) and the picker writes the colour to the paint it edits.
|
||||
spec!("edit.pickColor", "Pick color", "{apply?}", false, |s, p| {
|
||||
let r = tool(s, ToolId::Eyedropper)?;
|
||||
s.tool.pick_only = !boolean(p, "apply").unwrap_or(true);
|
||||
s.tool.picked = None;
|
||||
Ok(r)
|
||||
}),
|
||||
]
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
fn unused(_: NodeId) {}
|
||||
|
||||
#[cfg(test)]
|
||||
mod start_on_home_tests {
|
||||
use serde_json::json;
|
||||
|
||||
use crate::Session;
|
||||
|
||||
#[test]
|
||||
fn start_on_home_is_a_preference_that_persists() {
|
||||
let mut s = Session::new();
|
||||
assert!(!s.prefs.start_on_home);
|
||||
assert_eq!(s.execute("pref.startOnHome", &json!({})).unwrap()["on"], true);
|
||||
assert!(s.prefs.start_on_home);
|
||||
// Saved with the other preferences and read back; older settings files without it read as off.
|
||||
let saved: crate::Prefs = serde_json::from_value(serde_json::to_value(&s.prefs).unwrap()).unwrap();
|
||||
assert!(saved.start_on_home);
|
||||
assert_eq!(s.execute("pref.startOnHome", &json!({"on": false})).unwrap()["on"], false);
|
||||
let old: crate::Prefs = serde_json::from_value(json!({"theme": "dark"})).unwrap();
|
||||
assert!(!old.start_on_home);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user