mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-25 19:06:47 -06:00
464 lines
20 KiB
Rust
464 lines
20 KiB
Rust
//! Shared styles (paint, text, effect, grid) and variables (collections, modes, bindings).
|
|
|
|
use std::collections::BTreeMap;
|
|
|
|
use figcraft_color::Color;
|
|
use figcraft_doc::*;
|
|
use serde_json::{Value, json};
|
|
|
|
use super::{CommandSpec, ok};
|
|
use crate::params::*;
|
|
use crate::{EngineError, Result, Session, bad, spec};
|
|
|
|
pub fn specs() -> Vec<CommandSpec> {
|
|
vec![
|
|
spec!(
|
|
"style.create",
|
|
"Create style",
|
|
"{type: paint|text|effect|grid, name, from? (node id; default first selected), description?, value? (paints/effects/grids/text style JSON)}",
|
|
true,
|
|
create
|
|
),
|
|
spec!("style.apply", "Apply style", "{ids?, style (id or name), target? (fill|stroke; paint styles)}", true, apply),
|
|
spec!("style.detach", "Detach style", "{ids?, which: fill|stroke|effect|grid|text}", true, |s, p| {
|
|
let which = req_string("style.detach", p, "which")?;
|
|
for i in req_targets("style.detach", s, p)? {
|
|
let n = s.doc_mut()?.get_mut(i)?;
|
|
match which.as_str() {
|
|
"fill" => n.styles.fill = None,
|
|
"stroke" => n.styles.stroke = None,
|
|
"effect" => n.styles.effect = None,
|
|
"grid" => n.styles.grid = None,
|
|
"text" => n.styles.text = None,
|
|
_ => return Err(bad("style.detach", "which: fill|stroke|effect|grid|text")),
|
|
}
|
|
}
|
|
ok()
|
|
}),
|
|
spec!("style.update", "Edit style", "{style, name?, description?, value?, from? (node id: redefine from it)}", true, update),
|
|
spec!("style.delete", "Delete style", "{style}", true, |s, p| {
|
|
let id = req_string("style.delete", p, "style")?;
|
|
let doc = s.doc_mut()?;
|
|
let sid = doc.style(&id).map(|st| st.id.clone()).ok_or_else(|| bad("style.delete", "no such style"))?;
|
|
doc.styles.retain(|st| st.id != sid);
|
|
// Nodes keep their values but lose the link.
|
|
for nid in doc.ids().collect::<Vec<_>>() {
|
|
if let Some(n) = doc.node(nid)
|
|
&& [&n.styles.fill, &n.styles.stroke, &n.styles.effect, &n.styles.grid, &n.styles.text]
|
|
.iter()
|
|
.any(|r| r.as_deref() == Some(sid.as_str()))
|
|
{
|
|
let n = doc.get_mut(nid)?;
|
|
for r in [&mut n.styles.fill, &mut n.styles.stroke, &mut n.styles.effect, &mut n.styles.grid, &mut n.styles.text] {
|
|
if r.as_deref() == Some(sid.as_str()) {
|
|
*r = None;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
ok()
|
|
}),
|
|
spec!("style.list", "List styles", "{type?}", false, |s, p| {
|
|
let t = string(p, "type");
|
|
Ok(Value::Array(
|
|
s.doc()?
|
|
.styles
|
|
.iter()
|
|
.filter(|st| t.as_deref().is_none_or(|t| st.kind.type_name() == t))
|
|
.map(|st| serde_json::to_value(st).unwrap_or(Value::Null))
|
|
.collect(),
|
|
))
|
|
}),
|
|
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()?;
|
|
let id = unique_id("VariableCollectionId", doc.variables.collections.iter().map(|c| c.id.as_str()));
|
|
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(),
|
|
_ => vec!["Mode 1".into()],
|
|
};
|
|
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 });
|
|
Ok(json!({"id": id}))
|
|
}),
|
|
spec!("variable.addMode", "Add mode", "{collection, name}", true, |s, p| {
|
|
let c = req_string("variable.addMode", p, "collection")?;
|
|
let name = req_string("variable.addMode", p, "name")?;
|
|
let doc = s.doc_mut()?;
|
|
let coll = doc
|
|
.variables
|
|
.collections
|
|
.iter_mut()
|
|
.find(|x| x.id == c || x.name == c)
|
|
.ok_or_else(|| bad("variable.addMode", "no such collection"))?;
|
|
let mid = format!("{}/mode{}", coll.id, coll.modes.len() + 1);
|
|
let first = coll.modes.first().map(|m| m.id.clone());
|
|
coll.modes.push(Mode { id: mid.clone(), name });
|
|
let cid = coll.id.clone();
|
|
// New mode starts with the default mode's values.
|
|
for v in doc.variables.variables.iter_mut().filter(|v| v.collection == cid) {
|
|
if let Some(val) = first.as_ref().and_then(|f| v.values.get(f)).cloned() {
|
|
v.values.insert(mid.clone(), val);
|
|
}
|
|
}
|
|
Ok(json!({"id": mid}))
|
|
}),
|
|
spec!(
|
|
"variable.create",
|
|
"Create variable",
|
|
"{collection, name, type: color|number|string|boolean, value (default mode) | values {modeName: value}}",
|
|
true,
|
|
var_create
|
|
),
|
|
spec!("variable.set", "Set variable value", "{variable, mode? (name or id; default first), value}", true, var_set),
|
|
spec!("variable.delete", "Delete variable", "{variable}", true, |s, p| {
|
|
let v = req_string("variable.delete", p, "variable")?;
|
|
let doc = s.doc_mut()?;
|
|
let id = doc.variables.get(&v).map(|x| x.id.clone()).ok_or_else(|| bad("variable.delete", "no such variable"))?;
|
|
doc.variables.variables.retain(|x| x.id != id);
|
|
ok()
|
|
}),
|
|
spec!(
|
|
"variable.bind",
|
|
"Bind variable",
|
|
"{ids?, field (fill|stroke|width|height|opacity|cornerRadius|gap|padding|characters|visible|fontSize…), variable (null unbinds)}",
|
|
true,
|
|
|s, p| {
|
|
let field = req_string("variable.bind", p, "field")?;
|
|
let var = string(p, "variable");
|
|
let ids = req_targets("variable.bind", s, p)?;
|
|
let doc = s.doc_mut()?;
|
|
if let Some(v) = &var
|
|
&& doc.variables.get(v).is_none()
|
|
{
|
|
return Err(bad("variable.bind", "no such variable"));
|
|
}
|
|
let vid = var.and_then(|v| doc.variables.get(&v).map(|x| x.id.clone()));
|
|
for i in ids {
|
|
let n = doc.get_mut(i)?;
|
|
match &vid {
|
|
Some(v) => {
|
|
n.bound.insert(field.clone(), v.clone());
|
|
}
|
|
None => {
|
|
n.bound.remove(&field);
|
|
}
|
|
}
|
|
}
|
|
ok()
|
|
}
|
|
),
|
|
spec!("variable.setMode", "Set variable mode", "{ids? (frames/page), collection, mode}", true, |s, p| {
|
|
let c = req_string("variable.setMode", p, "collection")?;
|
|
let m = req_string("variable.setMode", p, "mode")?;
|
|
let doc = s.doc()?;
|
|
let coll = doc.variables.collection(&c).ok_or_else(|| bad("variable.setMode", "no such collection"))?;
|
|
let mode =
|
|
coll.modes.iter().find(|x| x.id == m || x.name == m).map(|x| x.id.clone()).ok_or_else(|| bad("variable.setMode", "no such mode"))?;
|
|
let cid = coll.id.clone();
|
|
let mut ids = targets(s, p);
|
|
if ids.is_empty() {
|
|
ids = vec![s.page()?];
|
|
}
|
|
let doc = s.doc_mut()?;
|
|
for i in ids {
|
|
doc.get_mut(i)?.var_modes.insert(cid.clone(), mode.clone());
|
|
}
|
|
ok()
|
|
}),
|
|
spec!("variable.list", "List variables", "{}", false, |s, _| Ok(serde_json::to_value(&s.doc()?.variables).unwrap_or(Value::Null))),
|
|
]
|
|
}
|
|
|
|
fn next_style_id(doc: &Document) -> String {
|
|
let mut k = doc.styles.len() + 1;
|
|
loop {
|
|
let id = format!("S:{k}");
|
|
if doc.style(&id).is_none() {
|
|
return id;
|
|
}
|
|
k += 1;
|
|
}
|
|
}
|
|
|
|
fn kind_from_node(t: &str, n: &Node) -> Option<StyleKind> {
|
|
Some(match t {
|
|
"paint" | "color" | "fill" => StyleKind::Paint { paints: n.fills.clone() },
|
|
"stroke" => StyleKind::Paint { paints: n.strokes.clone() },
|
|
"text" => StyleKind::Text { style: n.kind.text()?.style.clone() },
|
|
"effect" => StyleKind::Effect { effects: n.effects.clone() },
|
|
"grid" => StyleKind::Grid { grids: n.grids.clone() },
|
|
_ => return None,
|
|
})
|
|
}
|
|
|
|
fn create(s: &mut Session, p: &Value) -> Result<Value> {
|
|
let cmd = "style.create";
|
|
let t = req_string(cmd, p, "type")?.to_ascii_lowercase();
|
|
let name = req_string(cmd, p, "name")?;
|
|
let from = id(p, "from").or_else(|| s.selection().first().copied());
|
|
let doc = s.doc_mut()?;
|
|
let kind = if let Some(v) = get(p, "value") {
|
|
match t.as_str() {
|
|
"paint" | "color" | "fill" => StyleKind::Paint { paints: paints_of(v).ok_or_else(|| bad(cmd, "value: paints"))? },
|
|
"text" => StyleKind::Text { style: serde_json::from_value(v.clone()).map_err(|e| bad(cmd, e.to_string()))? },
|
|
"effect" => StyleKind::Effect { effects: serde_json::from_value(v.clone()).map_err(|e| bad(cmd, e.to_string()))? },
|
|
"grid" => StyleKind::Grid { grids: serde_json::from_value(v.clone()).map_err(|e| bad(cmd, e.to_string()))? },
|
|
_ => return Err(bad(cmd, "type: paint|text|effect|grid")),
|
|
}
|
|
} else {
|
|
let n = from.and_then(|f| doc.node(f)).ok_or_else(|| bad(cmd, "pass `value` or select a layer to take it from"))?;
|
|
kind_from_node(&t, n).ok_or_else(|| bad(cmd, "that layer has no such property"))?
|
|
};
|
|
let sid = next_style_id(doc);
|
|
doc.styles.push(Style { id: sid.clone(), name, description: string(p, "description").unwrap_or_default(), kind });
|
|
// The source layer becomes linked to the new style.
|
|
if let Some(f) = from
|
|
&& get(p, "value").is_none()
|
|
{
|
|
let field = match t.as_str() {
|
|
"stroke" => "strokeStyle",
|
|
"text" => "textStyle",
|
|
"effect" => "effectStyle",
|
|
"grid" => "gridStyle",
|
|
_ => "fillStyle",
|
|
};
|
|
apply_style_ref(doc, f, field, Some(&sid))?;
|
|
}
|
|
Ok(json!({"id": sid}))
|
|
}
|
|
|
|
/// Link node `id` to style `sid` for `field` (fillStyle, strokeStyle, …) and copy its value in.
|
|
pub fn apply_style_ref(doc: &mut Document, id: NodeId, field: &str, sid: Option<&str>) -> Result<()> {
|
|
let style = match sid {
|
|
Some(s) => Some(doc.style(s).cloned().ok_or_else(|| EngineError::Other(format!("no such style `{s}`")))?),
|
|
None => None,
|
|
};
|
|
let n = doc.get_mut(id)?;
|
|
match (field, &style) {
|
|
("fillStyle", Some(Style { kind: StyleKind::Paint { paints }, id, .. })) => {
|
|
n.fills = paints.clone();
|
|
n.styles.fill = Some(id.clone());
|
|
}
|
|
("strokeStyle", Some(Style { kind: StyleKind::Paint { paints }, id, .. })) => {
|
|
n.strokes = paints.clone();
|
|
n.styles.stroke = Some(id.clone());
|
|
}
|
|
("effectStyle", Some(Style { kind: StyleKind::Effect { effects }, id, .. })) => {
|
|
n.effects = effects.clone();
|
|
n.styles.effect = Some(id.clone());
|
|
}
|
|
("gridStyle", Some(Style { kind: StyleKind::Grid { grids }, id, .. })) => {
|
|
n.grids = grids.clone();
|
|
n.styles.grid = Some(id.clone());
|
|
}
|
|
("textStyle", Some(Style { kind: StyleKind::Text { style }, id, .. })) => {
|
|
if let NodeKind::Text(t) = &mut n.kind {
|
|
let keep_fills = t.style.fills.clone();
|
|
t.style = TextStyle { fills: keep_fills, ..style.clone() };
|
|
t.runs.clear();
|
|
}
|
|
n.styles.text = Some(id.clone());
|
|
}
|
|
("fillStyle", None) => n.styles.fill = None,
|
|
("strokeStyle", None) => n.styles.stroke = None,
|
|
("effectStyle", None) => n.styles.effect = None,
|
|
("gridStyle", None) => n.styles.grid = None,
|
|
("textStyle", None) => n.styles.text = None,
|
|
_ => return Err(EngineError::Other(format!("style doesn't fit `{field}`"))),
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn apply(s: &mut Session, p: &Value) -> Result<Value> {
|
|
let cmd = "style.apply";
|
|
let st = req_string(cmd, p, "style")?;
|
|
let ids = req_targets(cmd, s, p)?;
|
|
let doc = s.doc_mut()?;
|
|
let style = doc.style(&st).cloned().ok_or_else(|| bad(cmd, format!("no style `{st}`")))?;
|
|
let field = match (&style.kind, string(p, "target").as_deref()) {
|
|
(StyleKind::Paint { .. }, Some("stroke")) => "strokeStyle",
|
|
(StyleKind::Paint { .. }, _) => "fillStyle",
|
|
(StyleKind::Text { .. }, _) => "textStyle",
|
|
(StyleKind::Effect { .. }, _) => "effectStyle",
|
|
(StyleKind::Grid { .. }, _) => "gridStyle",
|
|
};
|
|
for i in ids {
|
|
apply_style_ref(doc, i, field, Some(&style.id))?;
|
|
}
|
|
ok()
|
|
}
|
|
|
|
fn update(s: &mut Session, p: &Value) -> Result<Value> {
|
|
let cmd = "style.update";
|
|
let st = req_string(cmd, p, "style")?;
|
|
let doc = s.doc_mut()?;
|
|
let idx = doc.styles.iter().position(|x| x.id == st || x.name == st).ok_or_else(|| bad(cmd, "no such style"))?;
|
|
let from = id(p, "from");
|
|
let new_kind = if let Some(v) = get(p, "value") {
|
|
let cur = doc.styles.get(idx).map(|x| x.kind.type_name()).unwrap_or("paint");
|
|
Some(match cur {
|
|
"paint" => StyleKind::Paint { paints: paints_of(v).ok_or_else(|| bad(cmd, "value: paints"))? },
|
|
"text" => StyleKind::Text { style: serde_json::from_value(v.clone()).map_err(|e| bad(cmd, e.to_string()))? },
|
|
"effect" => StyleKind::Effect { effects: serde_json::from_value(v.clone()).map_err(|e| bad(cmd, e.to_string()))? },
|
|
_ => StyleKind::Grid { grids: serde_json::from_value(v.clone()).map_err(|e| bad(cmd, e.to_string()))? },
|
|
})
|
|
} else if let Some(f) = from {
|
|
let t = doc.styles.get(idx).map(|x| x.kind.type_name()).unwrap_or("paint");
|
|
doc.node(f).and_then(|n| kind_from_node(t, n))
|
|
} else {
|
|
None
|
|
};
|
|
let Some(style) = doc.styles.get_mut(idx) else { return Err(bad(cmd, "no such style")) };
|
|
if let Some(n) = string(p, "name") {
|
|
style.name = n;
|
|
}
|
|
if let Some(d) = string(p, "description") {
|
|
style.description = d;
|
|
}
|
|
if let Some(k) = new_kind {
|
|
style.kind = k;
|
|
}
|
|
let sid = style.id.clone();
|
|
propagate_style(doc, &sid);
|
|
ok()
|
|
}
|
|
|
|
/// Re-apply style `sid` to every node linked to it.
|
|
pub fn propagate_style(doc: &mut Document, sid: &str) {
|
|
let ids: Vec<NodeId> = doc.ids().collect();
|
|
for i in ids {
|
|
let Some(n) = doc.node(i) else { continue };
|
|
let refs = n.styles.clone();
|
|
for (field, r) in [
|
|
("fillStyle", &refs.fill),
|
|
("strokeStyle", &refs.stroke),
|
|
("effectStyle", &refs.effect),
|
|
("gridStyle", &refs.grid),
|
|
("textStyle", &refs.text),
|
|
] {
|
|
if r.as_deref() == Some(sid) {
|
|
let _ = apply_style_ref(doc, i, field, Some(sid));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn var_value(kind: VariableType, v: &Value) -> Option<VariableValue> {
|
|
if let Some(a) = v.get("alias").and_then(Value::as_str) {
|
|
return Some(VariableValue::Alias(a.to_string()));
|
|
}
|
|
Some(match kind {
|
|
VariableType::Color => VariableValue::Color(color_of(v)?),
|
|
VariableType::Number => VariableValue::Number(f64_of(v)?),
|
|
VariableType::String => VariableValue::String(v.as_str().map(str::to_string).unwrap_or_else(|| v.to_string())),
|
|
VariableType::Boolean => VariableValue::Boolean(v.as_bool()?),
|
|
})
|
|
}
|
|
|
|
fn var_create(s: &mut Session, p: &Value) -> Result<Value> {
|
|
let cmd = "variable.create";
|
|
let c = req_string(cmd, p, "collection")?;
|
|
let name = req_string(cmd, p, "name")?;
|
|
let kind = match req_string(cmd, p, "type")?.to_ascii_lowercase().as_str() {
|
|
"color" => VariableType::Color,
|
|
"number" | "float" => VariableType::Number,
|
|
"string" => VariableType::String,
|
|
"boolean" | "bool" => VariableType::Boolean,
|
|
_ => return Err(bad(cmd, "type: color|number|string|boolean")),
|
|
};
|
|
let doc = s.doc_mut()?;
|
|
let coll = doc.variables.collection(&c).cloned().ok_or_else(|| bad(cmd, "no such collection"))?;
|
|
let mut values = BTreeMap::new();
|
|
if let Some(Value::Object(m)) = get(p, "values") {
|
|
for (mode, v) in m {
|
|
let mid = coll
|
|
.modes
|
|
.iter()
|
|
.find(|x| x.id == *mode || x.name == *mode)
|
|
.map(|x| x.id.clone())
|
|
.ok_or_else(|| bad(cmd, format!("no mode `{mode}`")))?;
|
|
values.insert(mid, var_value(kind, v).ok_or_else(|| bad(cmd, "invalid value"))?);
|
|
}
|
|
}
|
|
if let Some(v) = get(p, "value") {
|
|
let val = var_value(kind, v).ok_or_else(|| bad(cmd, "invalid value"))?;
|
|
for m in &coll.modes {
|
|
values.entry(m.id.clone()).or_insert_with(|| val.clone());
|
|
}
|
|
}
|
|
let default = match kind {
|
|
VariableType::Color => VariableValue::Color(Color::BLACK),
|
|
VariableType::Number => VariableValue::Number(0.0),
|
|
VariableType::String => VariableValue::String(String::new()),
|
|
VariableType::Boolean => VariableValue::Boolean(true),
|
|
};
|
|
for m in &coll.modes {
|
|
values.entry(m.id.clone()).or_insert_with(|| default.clone());
|
|
}
|
|
let id = unique_id("VariableID", doc.variables.variables.iter().map(|v| v.id.as_str()));
|
|
doc.variables.variables.push(Variable {
|
|
id: id.clone(),
|
|
name,
|
|
collection: coll.id,
|
|
kind,
|
|
values,
|
|
description: string(p, "description").unwrap_or_default(),
|
|
});
|
|
Ok(json!({"id": id}))
|
|
}
|
|
|
|
fn var_set(s: &mut Session, p: &Value) -> Result<Value> {
|
|
let cmd = "variable.set";
|
|
let v = req_string(cmd, p, "variable")?;
|
|
let doc = s.doc_mut()?;
|
|
let var = doc.variables.get(&v).cloned().ok_or_else(|| bad(cmd, "no such variable"))?;
|
|
let coll = doc.variables.collection(&var.collection).cloned().ok_or_else(|| bad(cmd, "variable has no collection"))?;
|
|
let mode = match string(p, "mode") {
|
|
Some(m) => coll.modes.iter().find(|x| x.id == m || x.name == m).map(|x| x.id.clone()).ok_or_else(|| bad(cmd, "no such mode"))?,
|
|
None => coll.default_mode().cloned().ok_or_else(|| bad(cmd, "collection has no modes"))?,
|
|
};
|
|
let val = get(p, "value").and_then(|x| var_value(var.kind, x)).ok_or_else(|| bad(cmd, "invalid value"))?;
|
|
if let Some(slot) = doc.variables.variables.iter_mut().find(|x| x.id == var.id) {
|
|
slot.values.insert(mode, val);
|
|
}
|
|
ok()
|
|
}
|
|
|
|
/// Write bound variable values into nodes (called during normalisation).
|
|
pub fn apply_bindings(doc: &mut Document) {
|
|
if doc.variables.variables.is_empty() {
|
|
return;
|
|
}
|
|
let bound: Vec<NodeId> = doc.nodes().filter(|n| !n.bound.is_empty()).map(|n| n.id).collect();
|
|
for id in bound {
|
|
let modes = doc.modes_for(id);
|
|
let Some(n) = doc.node(id) else { continue };
|
|
let binds = n.bound.clone();
|
|
let mut sets = serde_json::Map::new();
|
|
for (field, var) in binds {
|
|
let Some(val) = doc.variables.resolve(&var, &modes) else { continue };
|
|
let jv = match &val {
|
|
VariableValue::Color(c) => json!(c.to_css_hex()),
|
|
VariableValue::Number(x) => json!(x),
|
|
VariableValue::String(x) => json!(x),
|
|
VariableValue::Boolean(b) => json!(b),
|
|
VariableValue::Alias(_) => continue,
|
|
};
|
|
let key = match field.as_str() {
|
|
"fill" | "fills" | "fills/0" | "fills/0/color" => "fill",
|
|
"stroke" | "strokes/0/color" => "stroke",
|
|
other => other,
|
|
};
|
|
sets.insert(key.to_string(), jv);
|
|
}
|
|
let _ = super::props::apply("variables", doc, id, &sets);
|
|
}
|
|
}
|
|
|
|
/// `prefix:N` with N one more than the largest existing.
|
|
fn unique_id<'a>(prefix: &str, existing: impl Iterator<Item = &'a str>) -> String {
|
|
let max = existing.filter_map(|e| e.strip_prefix(prefix)?.strip_prefix(':')?.parse::<u64>().ok()).max().unwrap_or(0);
|
|
format!("{prefix}:{}", max + 1)
|
|
}
|