//! Edit menu: undo/redo, clipboard, duplicate/delete, selection commands, find, properties. use std::collections::{BTreeMap, BTreeSet}; use figcraft_doc::*; use figcraft_geom::Affine; use serde_json::{Value, json}; use super::{CommandSpec, ok}; use crate::params::*; use crate::{EngineError, Result, Session, bad, spec}; pub fn specs() -> Vec { vec![ spec!("edit.undo", "Undo", "{}", false, |s, _| Ok(json!({"undone": s.undo()?}))), spec!("edit.redo", "Redo", "{}", false, |s, _| Ok(json!({"redone": s.redo()?}))), spec!("edit.delete", "Delete", "{ids?}", true, delete), 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)}", 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.pasteDesignLayers", "Paste design layers", "{html (the clipboard's HTML) | data (base64 of the clipboard's design data, at most 256 MB), parent?, x?, y?} layers copied from another design app (a .fig canvas in the clipboard's HTML), read like a .fig file (best effort, with `warnings`), pasted as edit.paste pastes", true, paste_design_layers ), spec!("edit.pasteOver", "Paste over selection", "{}", true, paste_over), spec!("edit.pasteReplace", "Paste to replace", "{ids?}", true, paste_replace), spec!("edit.copyProperties", "Copy properties", "{id?}", false, copy_props), spec!("edit.pasteProperties", "Paste properties", "{ids?}", true, paste_props), spec!( "edit.findReplace", "Find and replace", "{find, replace? (text layers), layerNames? (bool), select? (default true)}", true, find_replace ), spec!("edit.setDefaultProperties", "Set default properties", "{id?}", false, |s, p| { let id = targets(s, p).first().copied().ok_or_else(|| bad("edit.setDefaultProperties", "select a layer"))?; let v = copy_props_of(s.doc()?, id); s.clipboard.props = Some(v); ok() }), spec!("select.set", "Select", "{ids: [..]} (replace the selection)", false, |s, p| { let ids = match get(p, "ids") { Some(Value::Array(a)) => a.iter().filter_map(id_of).collect(), _ => id(p, "id").into_iter().collect(), }; select_and_page(s, ids)?; Ok(json!({"selection": s.selection()})) }), spec!("select.add", "Add to selection", "{ids}", false, |s, p| { let mut sel = s.selection(); for i in targets_param(p) { if !sel.contains(&i) { sel.push(i); } } s.set_selection(sel); Ok(json!({"selection": s.selection()})) }), spec!("select.remove", "Remove from selection", "{ids}", false, |s, p| { let rm = targets_param(p); let sel: Vec = s.selection().into_iter().filter(|i| !rm.contains(i)).collect(); s.set_selection(sel); Ok(json!({"selection": s.selection()})) }), spec!( "select.layersAt", "Layers at a point", "{x, y (page coordinates)} → {layers: [{id, name, type, depth}]} (every layer under the point, front-most first; what the context menu's Select layer lists)", false, layers_at ), spec!("select.all", "Select all", "{}", false, select_all), spec!("select.none", "Select none", "{}", false, |s, _| { s.set_selection(vec![]); if let Ok(ds) = s.ds_mut() { ds.text_edit = None; ds.vector_edit = None; } ok() }), spec!("select.inverse", "Select inverse", "{}", false, select_inverse), spec!( "select.area", "Select area", "{x, y, width, height (page units; a marquee from x, y), add? (toggle like a Shift marquee), deep? (nested layers, like Cmd/Ctrl)} → {selection}; with no box: draw one from the keyboard in the app (arrows move it, Cmd/Ctrl+arrows or Shift+arrows size it, Enter selects, Escape cancels)", false, select_area ), spec!("select.children", "Select children", "{}", false, select_children), spec!("select.parent", "Select parent", "{}", false, select_parent), spec!("select.next", "Select next sibling", "{}", false, |s, _| select_sibling(s, 1)), spec!("select.previous", "Select previous sibling", "{}", false, |s, _| select_sibling(s, -1)), spec!("select.matching", "Select matching layers", "{}", false, select_matching), spec!("select.sameFill", "Select same fill", "{}", false, |s, _| select_same(s, |a, b| a.fills == b.fills && !a.fills.is_empty())), spec!("select.sameStroke", "Select same stroke", "{}", false, |s, _| select_same(s, |a, b| a.strokes == b.strokes && a.stroke == b.stroke && !a.strokes.is_empty())), spec!("select.sameEffect", "Select same effect", "{}", false, |s, _| select_same(s, |a, b| a.effects == b.effects && !a.effects.is_empty())), spec!("select.sameText", "Select same text properties", "{}", false, |s, _| { select_same(s, |a, b| matches!((&a.kind, &b.kind), (NodeKind::Text(x), NodeKind::Text(y)) if x.style == y.style)) }), spec!("select.sameFont", "Select same font", "{}", false, |s, _| { select_same( s, |a, b| matches!((&a.kind, &b.kind), (NodeKind::Text(x), NodeKind::Text(y)) if x.style.font_family == y.style.font_family && x.style.font_style == y.style.font_style), ) }), spec!("select.sameInstance", "Select same instance", "{}", false, |s, _| { select_same( s, |a, b| matches!((&a.kind, &b.kind), (NodeKind::Instance { component: x, .. }, NodeKind::Instance { component: y, .. }) if x == y), ) }), spec!("select.sameProperties", "Select same properties", "{}", false, |s, _| { select_same(s, |a, b| { a.kind.type_name() == b.kind.type_name() && a.fills == b.fills && a.strokes == b.strokes && a.effects == b.effects && a.corners == b.corners }) }), spec!("select.byName", "Select layers by name", "{query, page? (default current; \"all\" for every page)}", false, |s, p| { let q = req_string("select.byName", p, "query")?; let page = if string(p, "page").as_deref() == Some("all") { None } else { Some(s.page()?) }; let ids = s.doc()?.find_by_name(&q, page); select_and_page(s, ids.clone())?; Ok(json!({"selection": ids})) }), spec!( "transform.nudge", "Nudge", "{dx, dy, big? (use the big nudge amount)} (auto layout children move one place instead: along the flow, between wrapped rows, to the next grid cell, or into the neighbouring nested auto layout frame)", true, nudge ), spec!( "transform.nudgeSize", "Resize by nudge", "{dw, dh, big?} (grow or shrink the selection by the nudge amount, top-left fixed)", true, nudge_size ), spec!("object.rename", "Rename", "{ids?, name?} (without a name: rename the layer inline in the layers panel)", true, |s, p| { let ids = req_targets("object.rename", s, p)?; if p.get("name").is_none() { s.request_ui("layers.rename", json!({"ids": ids})); return ok(); } let name = req_string("object.rename", p, "name")?; let doc = s.doc_mut()?; for i in ids { doc.get_mut(i)?.name = name.clone(); } ok() }), spec!( "object.batchRename", "Batch rename", "{ids?, find? (match; empty: none), regex? (find is a regular expression), replace? (for each match), name? (pattern for the whole name), start? (where $n starts and $N stops; default 1)} codes in replace and name: $& current name (or the match), $` and $' text before and after the match, $1…$9 groups, $n up, $N down, $nnn padded; layers are numbered in stacking order, bottom first", true, batch_rename ), spec!("object.toggleVisible", "Show/Hide selection", "{ids?, visible?}", true, |s, p| toggle(s, p, "visible")), spec!("object.toggleLock", "Lock/Unlock selection", "{ids?, locked?}", true, |s, p| toggle(s, p, "locked")), spec!("object.hideOthers", "Hide other layers", "{}", true, hide_others), spec!( "object.opacityKeys", "Opacity from number keys", "{digits (\"1\"…\"9\": 10%…90%, \"0\": 100%; two digits: that percentage, \"45\" is 45%), ids?}", true, opacity_keys ), spec!("object.removeFill", "Remove fill", "{ids?}", true, |s, p| { for i in req_targets("object.removeFill", s, p)? { s.doc_mut()?.get_mut(i)?.fills.clear(); } ok() }), spec!("object.removeStroke", "Remove stroke", "{ids?}", true, |s, p| { for i in req_targets("object.removeStroke", s, p)? { s.doc_mut()?.get_mut(i)?.strokes.clear(); } ok() }), spec!("object.swapFillStroke", "Swap fill and stroke", "{ids?}", true, |s, p| { for i in req_targets("object.swapFillStroke", s, p)? { let n = s.doc_mut()?.get_mut(i)?; std::mem::swap(&mut n.fills, &mut n.strokes); } ok() }), spec!("layers.collapseAll", "Collapse layers", "{}", false, |s, _| { s.request_ui("layers.collapseAll", json!({})); ok() }), spec!("node.set", "Set properties", super::props::KEYS, true, set), spec!("node.get", "Get layer", "{id}", false, |s, p| { 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.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; s.doc_mut()?.reorder(i, idx)?; ok() }), spec!("fill.add", "Add fill", "{ids?, paint? (default #D9D9D9 or white for frames)}", true, |s, p| paint_list(s, p, true, true)), spec!("stroke.add", "Add stroke", "{ids?, paint? (default black)}", true, |s, p| paint_list(s, p, false, true)), spec!("fill.remove", "Remove fill", "{ids?, index}", true, |s, p| paint_list(s, p, true, false)), 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.pattern", "Pattern fill", "{source (layer id), ids?, index? (default the top fill), tile? (grid|horizontalHex|verticalHex), scale? (1 = the source's size), spacing? [x, y], align? (start|center|end), opacity?} the source layer tiled; it updates as the source changes", true, pattern_fill ), spec!( "stroke.widthProfile", "Width profile", "{ids?, preset? (uniform|wedge|taper|eye|rounded|pinch) | points? [[t (0-1 along each subpath), scale (× stroke weight)], ...]} — none = uniform", true, width_profile ), spec!( "stroke.brush", "Brush", "{ids?, preset (taper|ink|ribbon|bristle: stretched along the path; dots|stars|confetti|leaves: scattered along it | null: plain), spacing? (× weight), sizeJitter? (0-1), rotationJitter? (0-1), scatter? (0-4 × weight), seed?} layers without a stroke get a black one", true, stroke_brush ), spec!( "effect.add", "Add effect", "{ids?, type? (dropShadow|innerShadow|layerBlur|backgroundBlur|noise|texture|glass), effect? {..}}", true, effect_add ), spec!("effect.remove", "Remove effect", "{ids?, index}", true, |s, p| { let i = req_index("effect.remove", p, "index")?; for id in req_targets("effect.remove", s, p)? { // Its bindings go with it; later effects' move down one. super::lists::remove_item(s.doc_mut()?.get_mut(id)?, super::lists::BoundList::Effects, i); } ok() }), 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"))?; 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) { *slot = e.clone(); } } ok() }), 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)? { s.doc_mut()?.get_mut(id)?.grids.push(g.clone()); } ok() }), spec!("grid.remove", "Remove layout grid", "{ids?, index}", true, |s, p| { let i = req_index("grid.remove", p, "index")?; for id in req_targets("grid.remove", s, p)? { super::lists::remove_item(s.doc_mut()?.get_mut(id)?, super::lists::BoundList::Grids, i); } ok() }), spec!( "export.add", "Add export setting", "{ids?, setting? {size: 2x|512w|512h|{by: scale|width|height, value}, suffix?, format?: png|jpg|svg|pdf|webp, bleed? (PDF: points, or with a unit: \"3mm\", \"0.125in\", \"9pt\"), cropMarks?, bleedMarks?, cmyk? (PDF print options)}} (without a setting: 1x, then one scale step up with an \"@2x\"-style suffix)", true, |s, p| { let cmd = "export.add"; let setting = get(p, "setting").filter(|v| !v.is_null()); // Check the setting once before touching any layer. let given = setting.map(|v| super::file::export_setting(cmd, ExportSetting::default(), v)).transpose()?; for id in req_targets(cmd, s, p)? { let n = s.doc_mut()?.get_mut(id)?; let e = given.clone().unwrap_or_else(|| ExportSetting::next_after(n.exports.last())); n.exports.push(e); } ok() } ), spec!( "export.update", "Edit export setting", "{ids?, index, size? (2x|512w|512h|{by, value}), suffix?, format? (png|jpg|svg|pdf|webp), bleed? (PDF: points, or with a unit: \"3mm\", \"0.125in\", \"9pt\"), cropMarks?, bleedMarks?, cmyk? (PDF print options)}", true, |s, p| { let cmd = "export.update"; let i = req_index(cmd, p, "index")?; super::file::export_setting(cmd, ExportSetting::default(), p)?; for id in req_targets(cmd, s, p)? { let n = s.doc_mut()?.get_mut(id)?; if let Some(e) = n.exports.get_mut(i) { *e = super::file::export_setting(cmd, e.clone(), p)?; } } ok() } ), spec!("export.remove", "Remove export setting", "{ids?, index}", true, |s, p| { let i = req_index("export.remove", p, "index")?; for id in req_targets("export.remove", s, p)? { let n = s.doc_mut()?.get_mut(id)?; if i < n.exports.len() { n.exports.remove(i); } } ok() }), spec!("command.batch", "Run several commands", "{label?, commands: [{command, params}]} (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") { 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!({})))); } out } _ => return Err(bad("command.batch", "`commands` must be an array")), }; Ok(Value::Array(s.batch(&label, &cmds)?)) }), spec!("edit.repeat", "Repeat last command", "{}", true, |s, _| { let Some((id, p)) = s.last_command.clone().filter(|(id, _)| id != "edit.repeat") else { return Err(EngineError::Disabled("edit.repeat".into(), "nothing to repeat".into())); }; let spec = crate::find_command(&id).ok_or_else(|| EngineError::UnknownCommand(id.clone()))?; (spec.run)(s, &p) }), ] } fn targets_param(p: &Value) -> Vec { match get(p, "ids") { Some(Value::Array(a)) => a.iter().filter_map(id_of).collect(), _ => id(p, "id").into_iter().collect(), } } /// Select `ids`, switching to their page if they're elsewhere. pub fn select_and_page(s: &mut Session, ids: Vec) -> Result<()> { let doc = s.doc()?; for i in &ids { if !doc.contains(*i) { return Err(EngineError::NoNode(*i)); } } if let Some(first) = ids.first() && let Some(pg) = doc.page_of(*first) && pg != s.page()? { // Library copies live outside the pages. if !doc.pages.contains(&pg) { return Err(EngineError::Disabled("select".into(), format!("{first} isn't on a page (it is a library component)"))); } s.ds_mut()?.page = pg; } s.set_selection(ids); Ok(()) } /// Top-level selection: drop ids whose ancestor is also selected. pub fn roots(doc: &Document, ids: &[NodeId]) -> Vec { let set: BTreeSet = ids.iter().copied().collect(); ids.iter().copied().filter(|i| !doc.ancestors(*i).iter().any(|a| set.contains(a))).collect() } /// Ids sorted in document (z) order. pub fn z_sorted(doc: &Document, ids: &[NodeId]) -> Vec { let Some(page) = ids.first().and_then(|i| doc.page_of(*i)) else { return ids.to_vec() }; let order: BTreeMap = doc.subtree(page).into_iter().enumerate().map(|(i, id)| (id, i)).collect(); let mut v = ids.to_vec(); v.sort_by_key(|i| order.get(i).copied().unwrap_or(usize::MAX)); v } fn delete(s: &mut Session, p: &Value) -> Result { let ids = req_targets("edit.delete", s, p)?; let doc = s.doc_mut()?; let ids = roots(doc, &ids); let mut parents = Vec::new(); for i in &ids { if doc.node(*i).is_some_and(Node::is_page) { continue; } if let Some(pn) = doc.node(*i).and_then(|n| n.parent) { parents.push(pn); } doc.remove(*i)?; } // Select the parent frame like Figma? Figma clears the selection. s.set_selection(vec![]); Ok(json!({"deleted": ids})) } /// Layers duplicated last (Duplicate or Alt-drag) and their copies, in the same order. While the /// copies are the selection, Duplicate places the next copies as far from them as they are from /// their sources, measured then: so moving the copies first sets the step. #[derive(Clone, Debug)] pub struct Duplicated { pub sources: Vec, pub copies: Vec, } impl Duplicated { pub fn is_selection(&self, sel: &[NodeId]) -> bool { let set = |v: &[NodeId]| v.iter().copied().collect::>(); !self.copies.is_empty() && set(&self.copies) == set(sel) } /// The page offset from the sources to the copies, if both are still there and the copies move /// freely (an auto layout places its own). fn offset(&self, doc: &Document) -> Option<(f64, f64)> { let free = self .copies .iter() .all(|c| doc.node(*c).is_some_and(|n| n.child_layout.absolute || n.parent.and_then(|p| doc.node(p)).is_none_or(|p| p.layout.is_none()))); if !free || self.sources.iter().chain(&self.copies).any(|i| !doc.contains(*i)) { return None; } let (a, b) = (doc.bounds_of(&self.sources)?, doc.bounds_of(&self.copies)?); let d = (b.x0 - a.x0, b.y0 - a.y0); (d.0.is_finite() && d.1.is_finite() && (d.0 != 0.0 || d.1 != 0.0)).then_some(d) } } fn duplicate(s: &mut Session, p: &Value) -> Result { let ids = req_targets("edit.duplicate", s, p)?; let rename = s.prefs.rename_duplicates; let explicit = num(p, "dx").is_some() || num(p, "dy").is_some(); let ds = s.ds()?; let roots_now = z_sorted(&ds.doc, &roots(&ds.doc, &ids)); let repeat = ds.last_duplicate.as_ref().filter(|d| !explicit && d.is_selection(&roots_now)).and_then(|d| d.offset(&ds.doc)); let doc = s.doc_mut()?; let ids = roots_now; let mut out = Vec::new(); for i in ids.iter().copied() { let n = doc.get(i)?; let parent = n.parent.ok_or_else(|| bad("edit.duplicate", "pages are duplicated with page.duplicate"))?; 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 (dx, dy) = match (num(p, "dx"), num(p, "dy")) { (Some(dx), dy) => (dx, dy.unwrap_or(0.0)), (None, Some(dy)) => (0.0, dy), // Top-level frames duplicate to the right with a gap, everything else in place. _ if top_frame => (w + 100.0, 0.0), _ => (0.0, 0.0), }; match repeat { Some((rx, ry)) => super::arrange::move_world(doc, nid, rx, ry)?, None => { let nn = doc.get_mut(nid)?; nn.transform = Affine::translate((dx, dy)) * nn.transform; } } if rename { let nn = doc.get_mut(nid)?; nn.name = bump_name(&nn.name); } out.push(nid); } s.set_selection(out.clone()); s.ds_mut()?.last_duplicate = Some(Duplicated { sources: ids, copies: out.clone() }); Ok(json!({"ids": out})) } fn bump_name(n: &str) -> String { if let Some((base, num)) = n.rsplit_once(' ') && let Ok(k) = num.parse::() && let Some(next) = k.checked_add(1) { return format!("{base} {next}"); } format!("{n} 2") } /// The copied form of the selection. pub fn copy_to_clipboard(s: &mut Session, ids: &[NodeId]) -> Result<()> { let ds = s.ds()?; let (nodes, images, bounds) = copied(&ds.doc, ids); let (source, source_path, uid) = (ds.doc.clone(), ds.path.clone(), ds.uid); s.clipboard.source = Some(source); s.clipboard.source_path = source_path; s.clipboard.source_doc = uid; s.clipboard.nodes = nodes; s.clipboard.images = images; s.clipboard.bounds = bounds; Ok(()) } type Copied = (Vec<(NodeId, Vec)>, BTreeMap, Option); fn copied(doc: &Document, ids: &[NodeId]) -> Copied { let ids = z_sorted(doc, &roots(doc, ids)); let mut nodes = Vec::new(); let mut images = BTreeMap::new(); for i in &ids { let world = doc.world_transform(*i); let mut sub: Vec = doc.subtree(*i).into_iter().filter_map(|x| doc.node(x).cloned()).collect(); // Store the root's transform in page space so pastes land where it was. if let Some(r) = sub.first_mut() { r.transform = world; } for n in &sub { for p in n.fills.iter().chain(n.strokes.iter()) { if let figcraft_color::PaintKind::Image(img) = &p.kind && let Some(d) = doc.images.get(&img.image) { images.insert(img.image.clone(), d.clone()); } } } nodes.push((*i, sub)); } let bounds = doc.bounds_of(&ids); (nodes, images, bounds) } /// The markers around the copied layers in the clipboard's HTML, spelt as that format spells them. pub const CLIP_OPEN: &str = "(figma)"; pub const CLIP_CLOSE: &str = "(/figma)"; /// Largest design data read from the clipboard: the `.fig` import's own canvas cap (256 MB). Its /// base64 is checked first, so neither the text nor the decoded bytes can pass it. const MAX_CLIP_DATA: usize = 256 * 1024 * 1024; const MAX_CLIP_BASE64: usize = MAX_CLIP_DATA.div_ceil(3) * 4; /// Base64 `text` (whitespace ignored, padding optional) when it fits [`MAX_CLIP_DATA`]. fn clip_base64(text: &str) -> Option> { use base64::Engine as _; if text.bytes().filter(|b| !b.is_ascii_whitespace()).count() > MAX_CLIP_BASE64 { return None; } let b64: String = text.chars().filter(|c| !c.is_ascii_whitespace()).collect(); base64::engine::general_purpose::STANDARD.decode(&b64).or_else(|_| base64::engine::general_purpose::STANDARD_NO_PAD.decode(&b64)).ok() } /// Layers copied from another design app travel in the clipboard's HTML as a `.fig` canvas in /// base64 between [`CLIP_OPEN`] and [`CLIP_CLOSE`]. This gives those bytes, or `None` when the HTML /// holds no readable design data (it may just mention the marker). pub fn design_clipboard_buffer(html: &str) -> Option> { let start = html.find(CLIP_OPEN)? + CLIP_OPEN.len(); let len = html.get(start..)?.find(CLIP_CLOSE)?; let bytes = clip_base64(html.get(start..start + len)?)?; figcraft_format::fig::is_fig(&bytes).then_some(bytes) } fn paste_design_layers(s: &mut Session, p: &Value) -> Result { const CMD: &str = "edit.pasteDesignLayers"; let bytes = match (string(p, "html"), string(p, "data")) { (Some(html), _) => design_clipboard_buffer(&html).ok_or_else(|| bad(CMD, "the HTML holds no copied design layers"))?, (None, Some(d)) => clip_base64(&d).ok_or_else(|| bad(CMD, "`data` is not base64 of at most 256 MB"))?, (None, None) => return Err(bad(CMD, "pass `html` or `data`")), }; let other = super::file::load_fig(&bytes, "Pasted layers").ok_or_else(|| bad(CMD, "the data is not copied design layers"))??; let doc = std::sync::Arc::new(other.doc); let tops: Vec = doc.pages.iter().filter_map(|pg| doc.node(*pg)).flat_map(|pg| pg.children.iter().copied()).collect(); if tops.is_empty() { return Err(EngineError::Disabled(CMD.into(), "the copied layers came in empty".into())); } let (nodes, images, bounds) = copied(&doc, &tops); let props = s.clipboard.props.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 mut out = paste(s, p)?; if !other.issues.is_empty() { out["warnings"] = json!(other.issues); } Ok(out) } fn copy(s: &mut Session, p: &Value) -> Result { let ids = req_targets("edit.copy", s, p)?; copy_to_clipboard(s, &ids)?; Ok(json!({"copied": s.clipboard.nodes.len()})) } fn cut(s: &mut Session, p: &Value) -> Result { let ids = req_targets("edit.cut", s, p)?; copy_to_clipboard(s, &ids)?; delete(s, &json!({"ids": ids})) } /// Paste the clipboard under `parent` with the copied top-left moved to `at` (page coords). pub fn paste_into(s: &mut Session, parent: NodeId, at: Option<(f64, f64)>) -> Result> { let clip = s.clipboard.clone(); if clip.nodes.is_empty() { return Err(EngineError::Disabled("edit.paste".into(), "the clipboard is empty".into())); } // 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 doc = s.doc_mut()?; for (k, v) in &clip.images { doc.images.entry(k.clone()).or_insert_with(|| v.clone()); } // Sections go on the page (or in sections), never in the frame being pasted into. let page = doc.page_of(parent).unwrap_or(parent); let shift = match (at, clip.bounds) { (Some((x, y)), Some(b)) => Affine::translate((x - b.x0, y - b.y0)), _ => Affine::IDENTITY, }; let mut out = Vec::new(); let mut all = BTreeMap::new(); for (root, nodes) in &clip.nodes { let mut map = BTreeMap::new(); for n in nodes { map.insert(n.id, doc.alloc_id()); } let mut nodes = nodes.clone(); let section = nodes.first().is_some_and(|r| matches!(r.kind, NodeKind::Section { .. })); let into = if section && !doc.takes_sections(parent) { page } else { parent }; if let Some(r) = nodes.first_mut() { r.transform = doc.world_transform(into).inverse() * shift * r.transform; } out.push(doc.paste_nodes(&nodes, &map, *root, into, None)?); all.extend(map); } if let Some(src) = foreign { crate::library::adopt_pasted(doc, src, clip.source_path.as_deref(), &all)?; } Ok(out) } fn paste(s: &mut Session, p: &Value) -> Result { let page = s.page()?; // Figma pastes into the selected frame (if one is selected), at the same position when it fits, // 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 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 { .. }))) }); let parent = target.unwrap_or(page); let at = match (num(p, "x"), num(p, "y")) { (Some(x), Some(y)) => Some((x, y)), _ => { let b = s.clipboard.bounds; match (target, b) { (Some(t), Some(b)) => { let tb = doc.world_bounds(t); match tb { Some(tb) if tb.contains(b.origin()) && tb.contains(figcraft_geom::Point::new(b.x1, b.y1)) => None, Some(tb) => Some((tb.center().x - b.width() / 2.0, tb.center().y - b.height() / 2.0)), None => None, } } (None, Some(b)) => { // Off-screen originals paste at the viewport centre. let v = &s.ds()?.view; let (ox, oy) = v.origin(); let vr = figcraft_geom::Rect::new(ox, oy, ox + v.vw / v.zoom.max(1e-9), oy + v.vh / v.zoom.max(1e-9)); if vr.intersect(b).is_zero_area() { Some((v.cx - b.width() / 2.0, v.cy - b.height() / 2.0)) } else { None } } _ => None, } } }; let ids = paste_into(s, parent, at)?; s.set_selection(ids.clone()); Ok(json!({"ids": ids})) } fn paste_over(s: &mut Session, _p: &Value) -> Result { let sel = s.selection(); let page = s.page()?; let doc = s.doc()?; let (parent, at) = match sel.first() { Some(i) => { let parent = doc.node(*i).and_then(|n| n.parent).unwrap_or(page); (parent, doc.world_bounds(*i).map(|b| (b.x0, b.y0))) } None => (page, None), }; let ids = paste_into(s, parent, at)?; s.set_selection(ids.clone()); Ok(json!({"ids": ids})) } fn paste_replace(s: &mut Session, p: &Value) -> Result { let targets = req_targets("edit.pasteReplace", s, p)?; let mut out = Vec::new(); for t in targets { let doc = s.doc()?; let Some(n) = doc.node(t) else { continue }; let parent = n.parent.ok_or_else(|| bad("edit.pasteReplace", "can't replace a page"))?; let idx = doc.index_in_parent(t); let at = doc.world_bounds(t).map(|b| (b.x0, b.y0)); let ids = paste_into(s, parent, at)?; let doc = s.doc_mut()?; for (k, i) in ids.iter().enumerate() { if let Some(ix) = idx { let _ = doc.reorder(*i, ix + k); } } doc.remove(t)?; out.extend(ids); } s.set_selection(out.clone()); Ok(json!({"ids": out})) } fn copy_props_of(doc: &Document, id: NodeId) -> Value { let Some(n) = doc.node(id) else { return Value::Null }; let mut v = json!({ "fills": n.fills, "strokes": n.strokes, "strokeWeight": n.stroke.weight, "strokeAlign": n.stroke.align, "effects": n.effects, "opacity": n.opacity, "blend": n.blend, "cornerRadius": n.corners.radii, "cornerSmoothing": n.corners.smoothing, }); if let NodeKind::Text(t) = &n.kind { v["fontFamily"] = json!(t.style.font_family); v["fontStyle"] = json!(t.style.font_style); v["fontSize"] = json!(t.style.font_size); v["fontVariations"] = json!(t.style.variations); v["lineHeight"] = serde_json::to_value(t.style.line_height).unwrap_or(Value::Null); v["letterSpacing"] = serde_json::to_value(t.style.letter_spacing).unwrap_or(Value::Null); } v } fn copy_props(s: &mut Session, p: &Value) -> Result { let id = targets(s, p).first().copied().ok_or_else(|| bad("edit.copyProperties", "select one layer"))?; let v = copy_props_of(s.doc()?, id); s.clipboard.props = Some(v.clone()); Ok(v) } fn paste_props(s: &mut Session, p: &Value) -> Result { let props = s.clipboard.props.clone().ok_or_else(|| EngineError::Disabled("edit.pasteProperties".into(), "no copied properties".into()))?; let ids = req_targets("edit.pasteProperties", s, p)?; let doc = s.doc_mut()?; for i in ids { let mut m = props.as_object().cloned().unwrap_or_default(); let is_text = matches!(doc.get(i)?.kind, NodeKind::Text(_)); if !is_text { m.retain(|k, _| !k.starts_with("font") && k != "lineHeight" && k != "letterSpacing"); } if !doc.get(i)?.kind.has_corner_radius() { m.remove("cornerRadius"); m.remove("cornerSmoothing"); } super::props::apply("edit.pasteProperties", doc, i, &m)?; } ok() } fn layers_at(s: &mut Session, p: &Value) -> Result { let (x, y) = (req_num("select.layersAt", p, "x")?, req_num("select.layersAt", p, "y")?); let doc = s.doc()?; let zoom = s.ds()?.view.zoom.max(1e-6); let sel = s.selection(); let result = crate::booleans::result_outline(doc, &sel); let layers: Vec = figcraft_doc::geometry::layers_at(doc, s.page()?, figcraft_geom::Point::new(x, y), 2.0 / zoom, Some(&result)) .into_iter() .filter_map(|(id, depth)| doc.node(id).map(|n| json!({"id": id, "name": n.name, "type": n.kind.type_name(), "depth": depth}))) .collect(); Ok(json!({"layers": layers})) } fn select_all(s: &mut Session, _p: &Value) -> Result { let doc = s.doc()?; let sel = s.selection(); // Select all siblings of the current selection, else the page's top level. let parent = sel.first().and_then(|i| doc.node(*i)).and_then(|n| n.parent).unwrap_or(s.page()?); let ids: Vec = doc.get(parent)?.children.iter().copied().filter(|c| doc.node(*c).is_some_and(|n| n.visible && !n.locked)).collect(); s.set_selection(ids.clone()); Ok(json!({"selection": ids})) } /// What Select all would select, less the selection (hidden and locked layers neither). fn select_inverse(s: &mut Session, _p: &Value) -> Result { let doc = s.doc()?; let sel = s.selection(); let parent = sel.first().and_then(|i| doc.node(*i)).and_then(|n| n.parent).unwrap_or(s.page()?); let ids: Vec = doc.get(parent)?.children.iter().copied().filter(|c| !sel.contains(c) && doc.node(*c).is_some_and(|n| n.visible && !n.locked)).collect(); s.set_selection(ids.clone()); Ok(json!({"selection": ids})) } /// `select.area`: what a marquee from (x, y) over the box selects; no box asks the app for one. fn select_area(s: &mut Session, p: &Value) -> Result { let cmd = "select.area"; let keys = ["x", "y", "width", "height"]; if keys.iter().all(|k| p.get(*k).is_none()) { s.request_ui("selectArea", json!({})); return ok(); } let [x, y, w, h] = keys.map(|k| num(p, k)); let (Some(x), Some(y), Some(w), Some(h)) = (x, y, w, h) else { return Err(bad(cmd, "pass x, y, width and height (numbers)")) }; if ![x, y, w, h].iter().all(|v| v.is_finite() && v.abs() < 1e9) { return Err(bad(cmd, "the box is out of range")); } let start = figcraft_geom::Point::new(x, y); let r = figcraft_geom::Rect::from_points(start, figcraft_geom::Point::new(x + w, y + h)); let add = boolean(p, "add") == Some(true); let base = if add { s.selection() } else { Vec::new() }; let within = s.marquee_within(start)?; let sel = s.marquee_selection(r, within, boolean(p, "deep") == Some(true), &base, add)?; s.set_selection(sel.clone()); Ok(json!({"selection": sel})) } fn select_children(s: &mut Session, _p: &Value) -> Result { let doc = s.doc()?; let mut ids = Vec::new(); for i in s.selection() { if let Some(n) = doc.node(i) { ids.extend(n.children.iter().copied()); } } if ids.is_empty() { return Ok(json!({"selection": s.selection()})); } s.set_selection(ids.clone()); Ok(json!({"selection": ids})) } fn select_parent(s: &mut Session, _p: &Value) -> Result { let doc = s.doc()?; let mut ids: Vec = Vec::new(); for i in s.selection() { if let Some(p) = doc.node(i).and_then(|n| n.parent) && !doc.node(p).is_some_and(Node::is_page) && !ids.contains(&p) { ids.push(p); } } if ids.is_empty() { return Ok(json!({"selection": s.selection()})); } s.set_selection(ids.clone()); Ok(json!({"selection": ids})) } fn select_sibling(s: &mut Session, dir: i64) -> Result { let doc = s.doc()?; let sel = s.selection(); let Some(cur) = sel.last().copied() else { let first = doc.get(s.page()?)?.children.last().copied(); s.set_selection(first.into_iter().collect()); return Ok(json!({"selection": s.selection()})); }; let parent = doc.get(cur)?.parent.ok_or_else(|| bad("select.next", "no parent"))?; let kids = &doc.get(parent)?.children; let idx = kids.iter().position(|c| *c == cur).unwrap_or(0) as i64; let n = kids.len() as i64; // Layers panel order is reversed (top of the list = last child): Tab goes down the list. let next = (idx - dir).rem_euclid(n.max(1)) as usize; let id = kids.get(next).copied().unwrap_or(cur); s.set_selection(vec![id]); Ok(json!({"selection": [id]})) } fn select_matching(s: &mut Session, _p: &Value) -> Result { let doc = s.doc()?; let sel = s.selection(); let Some(first) = sel.first().and_then(|i| doc.node(*i)) else { return Ok(json!({"selection": []})) }; let (name, ty) = (first.name.clone(), first.kind.type_name()); let page = s.page()?; let ids: Vec = doc.subtree(page).into_iter().filter(|i| doc.node(*i).is_some_and(|n| n.name == name && n.kind.type_name() == ty)).collect(); s.set_selection(ids.clone()); Ok(json!({"selection": ids})) } fn select_same(s: &mut Session, same: impl Fn(&Node, &Node) -> bool) -> Result { let doc = s.doc()?; let sel = s.selection(); let Some(first) = sel.first().and_then(|i| doc.node(*i)).cloned() else { return Ok(json!({"selection": []})) }; let page = s.page()?; let ids: Vec = doc.subtree(page).into_iter().filter(|i| *i != page && doc.node(*i).is_some_and(|n| same(&first, n))).collect(); s.set_selection(ids.clone()); Ok(json!({"selection": ids})) } /// The opacity number keys give: one digit is tens of percent (0 is 100%), two digits typed in /// quick succession the exact percentage. pub fn opacity_from_digits(digits: &str) -> Option { let mut n = 0u32; let mut count = 0; for c in digits.chars() { // Input-derived: a number that outgrows `u32` stops the scan (None); it never wraps. n = n.checked_mul(10)?.checked_add(c.to_digit(10)?)?; count += 1; } match count { 1 if n == 0 => Some(1.0), 1 => Some(n as f32 / 10.0), 2 => Some(n as f32 / 100.0), _ => None, } } fn opacity_keys(s: &mut Session, p: &Value) -> Result { 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; } Ok(json!({"opacity": o})) } fn nudge(s: &mut Session, p: &Value) -> Result { let big = boolean(p, "big").unwrap_or(false); let k = if big { s.prefs.nudge_big } else { s.prefs.nudge_small }; let (ux, uy) = (num_or(p, "dx", 0.0), num_or(p, "dy", 0.0)); let (dx, dy) = (ux * k, uy * k); let ids = req_targets("transform.nudge", s, p)?; // Edit a copy and keep it only if something moved, so an arrow that can't move anything adds // no undo step. let mut work = s.doc()?.clone(); let doc = &mut work; let ids = roots(doc, &ids); let mut changed = false; // Children in an auto layout flow move one place instead (see `al_keys`). for i in &ids { if crate::al_keys::flowing_parent(doc, *i).is_none() && (dx, dy) != (0.0, 0.0) { let n = doc.get_mut(*i)?; n.transform = Affine::translate((dx, dy)) * n.transform; changed = true; } } for dir in crate::al_keys::Dir::from_units(ux, uy) { // Grouped per arrow: a move into a neighbouring frame changes the parent. let mut groups: BTreeMap> = BTreeMap::new(); for i in &ids { if let Some(parent) = crate::al_keys::flowing_parent(doc, *i) { groups.entry(parent).or_default().push(*i); } } for (parent, block) in groups { changed |= crate::al_keys::arrow_move(doc, parent, &block, dir)?; } } if changed { s.ds_mut()?.doc = std::sync::Arc::new(work); } ok() } fn nudge_size(s: &mut Session, p: &Value) -> Result { let big = boolean(p, "big").unwrap_or(false); let k = if big { s.prefs.nudge_big } else { s.prefs.nudge_small }; let (dw, dh) = (num_or(p, "dw", 0.0) * k, num_or(p, "dh", 0.0) * k); let ids = req_targets("transform.nudgeSize", s, p)?; let ids = roots(s.doc()?, &ids); for i in ids { let Some((w, h)) = s.doc()?.node(i).map(|n| (n.width, n.height)) else { continue }; let mut q = serde_json::Map::new(); q.insert("ids".into(), json!([i])); // Only the axis that changes becomes fixed. if dw != 0.0 { q.insert("width".into(), json!((w + dw).max(0.0))); } if dh != 0.0 { q.insert("height".into(), json!((h + dh).max(0.0))); } super::arrange::resize_cmd(s, &Value::Object(q))?; } ok() } fn toggle(s: &mut Session, p: &Value, what: &str) -> Result { let ids = req_targets("object.toggle", s, p)?; let doc = s.doc_mut()?; let explicit = boolean(p, what); // Figma: if any is visible, hide all (and vice versa). let value = explicit.unwrap_or_else(|| match what { "visible" => !ids.iter().all(|i| doc.node(*i).is_some_and(|n| n.visible)), _ => !ids.iter().all(|i| doc.node(*i).is_some_and(|n| n.locked)), }); for i in &ids { let n = doc.get_mut(*i)?; if what == "visible" { n.visible = value; } else { n.locked = value; } } if what == "locked" && value { s.set_selection(vec![]); } Ok(json!({what: value})) } fn hide_others(s: &mut Session, _p: &Value) -> Result { let sel = s.selection(); let page = s.page()?; let doc = s.doc_mut()?; let keep: BTreeSet = sel .iter() .flat_map(|i| { let mut v = doc.ancestors(*i); v.push(*i); v }) .collect(); let tops: Vec = doc.get(page)?.children.clone(); for t in tops { if !keep.contains(&t) { doc.get_mut(t)?.visible = false; } } ok() } fn batch_rename(s: &mut Session, p: &Value) -> Result { let cmd = "object.batchRename"; let ids = req_targets(cmd, s, p)?; let find = string(p, "find"); let replace = string(p, "replace").unwrap_or_default(); let pattern = string(p, "name"); let rename = crate::rename::BatchRename::new(find.as_deref(), boolean(p, "regex").unwrap_or(false), &replace, pattern.as_deref(), num(p, "start")) .map_err(|e| bad(cmd, e))?; let doc = s.doc_mut()?; let names = rename.plan(doc, &ids); for (i, name) in &names { doc.get_mut(*i)?.name = name.clone(); } Ok(json!({"names": names.iter().map(|(i, n)| json!({"id": i, "name": n})).collect::>()})) } /// Char index of the first case-insensitive occurrence of `needle` in `hay` at or after `from`. /// Works on char indices (what `replace_range` takes), so lowercasing never shifts offsets. fn find_ci(hay: &[char], needle: &[char], from: usize) -> Option { if needle.is_empty() { return None; } (from..hay.len()) .find(|&i| hay.get(i..i + needle.len()).is_some_and(|w| w.iter().zip(needle).all(|(a, b)| a == b || a.to_lowercase().eq(b.to_lowercase())))) } fn find_replace(s: &mut Session, p: &Value) -> Result { let find = req_string("edit.findReplace", p, "find")?; if find.is_empty() { return Err(bad("edit.findReplace", "`find` is empty")); } // Line breaks stored as the editor stores them (CRLF, CR, U+2028 and U+2029 become LF). let replace = string(p, "replace").map(|r| super::props::normalize_line_endings(&r)); let names = boolean(p, "layerNames").unwrap_or(false); let page = s.page()?; let doc = s.doc_mut()?; let mut hits = Vec::new(); let needle: Vec = find.chars().collect(); let r_chars: Vec = replace.as_deref().map(|r| r.chars().collect()).unwrap_or_default(); for i in doc.subtree(page) { let Some(n) = doc.node(i) else { continue }; let text: Option> = match &n.kind { NodeKind::Text(t) => Some(t.characters.chars().collect()), _ => None, }; let text_hit = text.as_deref().is_some_and(|c| find_ci(c, &needle, 0).is_some()); let name_hit = names && find_ci(&n.name.chars().collect::>(), &needle, 0).is_some(); if !(text_hit || name_hit) || n.is_page() { continue; } hits.push(i); if let Some(r) = &replace { let n = doc.get_mut(i)?; if names && name_hit { let chars: Vec = n.name.chars().collect(); let mut out = String::new(); let mut at = 0; while let Some(pos) = find_ci(&chars, &needle, at) { out.extend(chars.get(at..pos).unwrap_or_default()); out.push_str(r); at = pos + needle.len(); } out.extend(chars.get(at..).unwrap_or_default()); n.name = out; } if let (NodeKind::Text(t), Some(mut chars)) = (&mut n.kind, text) { // Replace occurrence by occurrence so styles shift correctly. The search resumes // after each inserted replacement, so a replacement that matches `find` (in any // case) neither loops forever nor stops the other occurrences. let mut cursor = 0; for _ in 0..10_000 { let Some(start) = find_ci(&chars, &needle, cursor) else { break }; t.replace_range(start, start + needle.len(), r); chars.splice(start..start + needle.len(), r_chars.iter().copied()); cursor = start + r_chars.len(); } } } } if boolean(p, "select").unwrap_or(true) { s.set_selection(hits.clone()); } Ok(json!({"matches": hits})) } fn set(s: &mut Session, p: &Value) -> Result { let ids = req_targets("node.set", s, p)?; let props = match get(p, "props") { Some(Value::Object(m)) => m.clone(), _ => { // Allow flat params: {"id": 3, "fill": "#f00"}. let mut m = p.as_object().cloned().unwrap_or_default(); m.remove("id"); m.remove("ids"); m } }; if props.is_empty() { return Err(bad("node.set", "no properties given")); } // A new rotation for the selection turns it about its moved rotation origin: each layer's // point under the origin stays put. let ds = s.ds()?; let pivot = ds.moved_origin().filter(|_| props.contains_key("rotation") && !props.contains_key("x") && !props.contains_key("y") && ds.selection == ids); let pinned: Vec<(NodeId, figcraft_geom::Point)> = match pivot { Some(o) => roots(&ds.doc, &ids) .into_iter() .map(|i| (i, ds.doc.world_transform(i))) .filter(|(_, w)| w.determinant().abs() > 1e-12) .map(|(i, w)| (i, w.inverse() * o)) .collect(), None => Vec::new(), }; let doc = s.doc_mut()?; for i in ids { super::props::apply("node.set", doc, i, &props)?; } if let Some(o) = pivot { for (i, q) in pinned { let d = o - doc.world_transform(i) * q; if d.hypot().is_finite() { super::arrange::move_world(doc, i, d.x, d.y)?; } } } ok() } /// `stroke.widthProfile`: a preset or points (through `node.set`'s `widthProfile`, so instances /// record the override). fn width_profile(s: &mut Session, p: &Value) -> Result { let cmd = "stroke.widthProfile"; let v = get(p, "preset").or_else(|| get(p, "points")).cloned().unwrap_or(Value::Null); let ids = req_targets(cmd, s, p)?; let props = serde_json::Map::from_iter([("widthProfile".to_string(), v)]); let doc = s.doc_mut()?; for i in ids { super::props::apply(cmd, doc, i, &props)?; } ok() } fn stroke_brush(s: &mut Session, p: &Value) -> Result { let cmd = "stroke.brush"; let v = if get(p, "preset").is_none_or(Value::is_null) { Value::Null } else { p.clone() }; let brush = super::props::brush_of(&v).ok_or_else(|| bad(cmd, super::props::BRUSH_HELP))?; let ids = req_targets(cmd, s, p)?; let doc = s.doc_mut()?; for i in ids { let n = doc.get_mut(i)?; if brush.is_some() && n.strokes.is_empty() { n.strokes.push(figcraft_color::Paint::solid(figcraft_color::Color::BLACK)); } n.stroke.brush = brush.clone(); } ok() } fn reparent(s: &mut Session, p: &Value) -> Result { let ids = req_targets("node.reparent", s, p)?; let parent = req_id("node.reparent", p, "parent")?; 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()); 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)); doc.reparent(*i, parent, at)?; } ok() } fn paint_list(s: &mut Session, p: &Value, fills: bool, add: bool) -> Result { let cmd = if fills { "fill" } else { "stroke" }; let ids = req_targets(cmd, s, p)?; let index = if add || get(p, "index").is_none() { None } else { Some(req_index(cmd, p, "index")?) }; let doc = s.doc_mut()?; for i in ids { let n = doc.get_mut(i)?; let frameish = n.kind.is_frame_like(); let list = if fills { &mut n.fills } else { &mut n.strokes }; if add { let paint = match get(p, "paint").or_else(|| get(p, "color")) { Some(v) => paint_of(v).ok_or_else(|| bad(cmd, "invalid paint"))?, None if fills && frameish => figcraft_color::Paint::solid(figcraft_color::Color::WHITE), None if fills => figcraft_color::Paint::solid(figcraft_color::Color::DEFAULT_SHAPE), None => figcraft_color::Paint::solid(figcraft_color::Color::BLACK), }; list.push(paint); } else { let idx = index.unwrap_or(list.len().saturating_sub(1)); // The paint's colour binding goes with it; later paints' move down one. super::lists::remove_item(n, if fills { super::lists::BoundList::Fills } else { super::lists::BoundList::Strokes }, idx); } } ok() } fn paint_set(s: &mut Session, p: &Value, fills: bool) -> Result { let cmd = if fills { "fill.set" } else { "stroke.set" }; let ids = req_targets(cmd, s, p)?; let idx = num(p, "index").map(|x| x.max(0.0) as usize); let new_paint = match get(p, "paint") { Some(v) => Some(paint_of(v).ok_or_else(|| bad(cmd, "invalid paint"))?), None => None, }; let col = color(p, "color"); let doc = s.doc_mut()?; for i in ids { let n = doc.get_mut(i)?; let list = if fills { &mut n.fills } else { &mut n.strokes }; let at = idx.unwrap_or(list.len().saturating_sub(1)); if list.is_empty() { list.push(figcraft_color::Paint::solid(if fills { figcraft_color::Color::DEFAULT_SHAPE } else { figcraft_color::Color::BLACK })); } let Some(slot) = list.get_mut(at) else { return Err(bad(cmd, format!("no paint at index {at}"))) }; let old = slot.clone(); if let Some(np) = &new_paint { *slot = np.clone(); } if let Some(c) = col { slot.kind = figcraft_color::PaintKind::Solid { color: c.with_alpha(1.0) }; if c.a < 1.0 { slot.opacity = 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; } if let Some(v) = boolean(p, "visible") { slot.visible = v; } if let Some(b) = blend(p, "blend") { slot.blend = b; } super::lists::remap_stop_bindings(n, fills, at, &old); if fills { n.styles.fill = None; } else { n.styles.stroke = None; } } ok() } fn pattern_fill(s: &mut Session, p: &Value) -> Result { const CMD: &str = "fill.pattern"; let src = req_id(CMD, p, "source")?; let doc = s.doc()?; if !doc.node(src).is_some_and(|n| !n.is_page()) { return Err(bad(CMD, "`source` must be a layer")); } if req_targets(CMD, s, p)?.iter().any(|t| doc.is_ancestor(*t, src)) { return Err(bad(CMD, "a layer can't be filled with itself or a layer inside it")); } let mut pt = figcraft_color::PatternPaint::new(src.0); pt.tile = parse(CMD, p, "tile")?.unwrap_or_default(); pt.align = parse(CMD, p, "align")?.unwrap_or_default(); pt.spacing = parse(CMD, p, "spacing")?.unwrap_or_default(); let (lo, hi) = figcraft_color::PatternPaint::SCALE_RANGE; pt.scale = num(p, "scale").filter(|v| v.is_finite()).unwrap_or(1.0).clamp(lo, hi); let mut q = if p.is_object() { p.clone() } else { json!({}) }; q["paint"] = serde_json::to_value(figcraft_color::Paint { kind: figcraft_color::PaintKind::Pattern(pt), ..figcraft_color::Paint::pattern(src.0) }) .map_err(|e| bad(CMD, e.to_string()))?; paint_set(s, &q, true) } fn effect_add(s: &mut Session, p: &Value) -> Result { let e: Effect = match get(p, "effect") { Some(v) => serde_json::from_value(v.clone()).map_err(|e| bad("effect.add", e.to_string()))?, 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}`")))? } }; for i in req_targets("effect.add", s, p)? { s.doc_mut()?.get_mut(i)?.effects.push(e.clone()); } ok() }