From cea90cf935adfbfd9c4b304b4f9d228098fe8943 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 19:20:45 +0000 Subject: [PATCH] refactor(engine): split cmd/edit.rs into edit/ --- crates/engine/src/cmd/edit.rs | 1067 +---------------------- crates/engine/src/cmd/edit/clipboard.rs | 332 +++++++ crates/engine/src/cmd/edit/props.rs | 458 ++++++++++ crates/engine/src/cmd/edit/select.rs | 267 ++++++ 4 files changed, 1073 insertions(+), 1051 deletions(-) create mode 100644 crates/engine/src/cmd/edit/clipboard.rs create mode 100644 crates/engine/src/cmd/edit/props.rs create mode 100644 crates/engine/src/cmd/edit/select.rs diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 60077efd..7ec63c69 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -10,6 +10,22 @@ use super::{CommandSpec, ok}; use crate::params::*; use crate::{BatchStep, EngineError, Result, Session, bad, spec}; +mod clipboard; +mod props; +mod select; + +pub use clipboard::{CLIP_CLOSE, CLIP_OPEN, copy_to_clipboard, design_clipboard_buffer, paste_into}; +use clipboard::{copy, copy_props, copy_props_of, cut, paste, paste_design_layers, paste_over, paste_props, paste_replace}; +use props::{ + batch_rename, effect_add, effect_copy, effect_paste, find_replace, paint_copy, paint_list, paint_paste, paint_set, pattern_fill, reparent, set, + stroke_brush, width_profile, +}; +pub use select::opacity_from_digits; +use select::{ + hide_others, layers_at, nudge, nudge_size, opacity_keys, select_all, select_area, select_children, select_inverse, select_matching, + select_parent, select_same, select_sibling, toggle, +}; + pub fn specs() -> Vec { vec![ spec!("edit.undo", "Undo", "{}", false, |s, _| Ok(json!({"undone": s.undo()?}))), @@ -536,1054 +552,3 @@ fn bump_name(n: &str) -> String { } format!("{n} 2") } - -/// The copied form of the selection. -pub fn copy_to_clipboard(s: &mut Session, cmd: &str, ids: &[NodeId]) -> Result<()> { - let ds = s.ds()?; - // Pages are not layers: they can't be pasted anywhere. - let layers: Vec = ids.iter().copied().filter(|i| !ds.doc.node(*i).is_some_and(Node::is_page)).collect(); - if layers.is_empty() && !ids.is_empty() { - return Err(EngineError::Disabled(cmd.into(), "pages can't be copied; use page.duplicate".into())); - } - let (nodes, images, bounds) = copied(&ds.doc, &layers); - 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.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(()) -} - -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) -} - -/// 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 { - 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); - // 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. - 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); - } - Ok(out) -} - -fn copy(s: &mut Session, p: &Value) -> Result { - let ids = req_targets("edit.copy", s, p)?; - copy_to_clipboard(s, "edit.copy", &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, "edit.cut", &ids)?; - let doc = s.doc()?; - let layers: Vec = ids.into_iter().filter(|i| !doc.node(*i).is_some_and(Node::is_page)).collect(); - delete(s, &json!({"ids": layers})) -} - -/// 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 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()); - } - // 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 { - 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()); - } - 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); - } - // 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)?; - } - 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 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 { .. }) && !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")) { - (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"))?; - 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})) -} - -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})) -} - -/// 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 { - 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 mut warnings = font_style_warnings(&s.ds()?.doc, &ids, &props); - // 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 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> = 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; - } - let others: Vec = 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 = - 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::>().join(", "), - names.join(", ") - )); - } - let doc = s.doc_mut()?; - let before = super::styles::bound_before(doc, &ids); - for i in ids { - 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; - if d.hypot().is_finite() { - super::arrange::move_world(doc, i, d.x, d.y)?; - } - } - } - if warnings.is_empty() { ok() } else { Ok(json!({"ok": true, "warnings": warnings})) } -} - -/// A `fontStyle` draws in the family's face nearest to it, as a text layer resolves it. When that -/// face has another weight or slant than the name asks for ("Black" on a family whose heaviest is -/// Bold), say which face it is. Families that aren't installed, and names that match a style or a -/// named instance (spelled any way: "SemiBold", "Semi Bold"), say nothing. -fn font_style_warnings(doc: &figcraft_doc::Document, ids: &[NodeId], props: &serde_json::Map) -> Vec { - let Some(style) = props.get("fontStyle").and_then(Value::as_str) else { return Vec::new() }; - let mut families: Vec = match props.get("fontFamily").and_then(Value::as_str) { - Some(f) => vec![f.to_string()], - None => ids - .iter() - .filter_map(|i| doc.node(*i)) - .filter_map(|n| n.kind.text()) - .flat_map(|t| t.spans().into_iter().map(|(_, _, st)| st.font_family.clone()).collect::>()) - .collect(), - }; - families.sort(); - families.dedup(); - let norm = |s: &str| s.to_lowercase().replace([' ', '-', '_'], ""); - let (want_w, want_i) = (i32::from(figcraft_doc::style_weight(style)), style.to_lowercase().contains("italic")); - families - .into_iter() - .filter_map(|family| { - let styles = figcraft_text::family_styles(&family); - if styles.is_empty() || styles.iter().any(|x| norm(x) == norm(style)) { - return None; - } - let face = figcraft_text::find_face(&family, style).filter(|f| f.family.eq_ignore_ascii_case(&family))?; - let w = face.axis("wght").map_or(i32::from(face.weight), |a| a.clamp(f64::from(want_w)).round() as i32); - if face.instance(style).is_some() || (w == want_w && face.italic == want_i) { - return None; - } - Some(format!("{family} has no `{style}` style: it draws in {}, the nearest (it has: {})", face.style, styles.join(", "))) - }) - .collect() -} - -/// `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)); - // `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)); - 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 = opt_index(cmd, p, "index")?; - 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; - 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; - } - if let Some(b) = blend(p, "blend") { - slot.blend = b; - } - super::lists::rebind_edited_paint(n, fills, at, &old); - if fills { - n.styles.fill = None; - } else { - n.styles.stroke = None; - } - } - 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 { - 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 { - 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 { - const CMD: &str = "fill.pattern"; - let src = req_id(CMD, p, "source")?; - let doc = s.doc()?; - if doc.node(src).is_none_or(|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 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}`")))? - } - }; - for i in req_targets("effect.add", s, p)? { - s.doc_mut()?.get_mut(i)?.effects.push(e.clone()); - } - 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 { - 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 { - 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() -} diff --git a/crates/engine/src/cmd/edit/clipboard.rs b/crates/engine/src/cmd/edit/clipboard.rs new file mode 100644 index 00000000..d0e7b2dc --- /dev/null +++ b/crates/engine/src/cmd/edit/clipboard.rs @@ -0,0 +1,332 @@ +use super::*; + +/// The copied form of the selection. +pub fn copy_to_clipboard(s: &mut Session, cmd: &str, ids: &[NodeId]) -> Result<()> { + let ds = s.ds()?; + // Pages are not layers: they can't be pasted anywhere. + let layers: Vec = ids.iter().copied().filter(|i| !ds.doc.node(*i).is_some_and(Node::is_page)).collect(); + if layers.is_empty() && !ids.is_empty() { + return Err(EngineError::Disabled(cmd.into(), "pages can't be copied; use page.duplicate".into())); + } + let (nodes, images, bounds) = copied(&ds.doc, &layers); + 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.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(()) +} + +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) +} + +/// 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. +pub(super) 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 = crate::cmd::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); + // 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. + 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); + } + Ok(out) +} + +pub(super) fn copy(s: &mut Session, p: &Value) -> Result { + let ids = req_targets("edit.copy", s, p)?; + copy_to_clipboard(s, "edit.copy", &ids)?; + Ok(json!({"copied": s.clipboard.nodes.len()})) +} + +pub(super) fn cut(s: &mut Session, p: &Value) -> Result { + let ids = req_targets("edit.cut", s, p)?; + copy_to_clipboard(s, "edit.cut", &ids)?; + let doc = s.doc()?; + let layers: Vec = ids.into_iter().filter(|i| !doc.node(*i).is_some_and(Node::is_page)).collect(); + delete(s, &json!({"ids": layers})) +} + +/// 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 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()); + } + // 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 { + 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()); + } + 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); + } + // 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)?; + } + Ok(out) +} + +pub(super) 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 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 { .. }) && !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")) { + (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})) +} + +pub(super) 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})) +} + +pub(super) 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})) +} + +pub(super) 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 +} + +pub(super) 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) +} + +pub(super) 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"); + } + crate::cmd::props::apply("edit.pasteProperties", doc, i, &m)?; + } + ok() +} diff --git a/crates/engine/src/cmd/edit/props.rs b/crates/engine/src/cmd/edit/props.rs new file mode 100644 index 00000000..f793b50c --- /dev/null +++ b/crates/engine/src/cmd/edit/props.rs @@ -0,0 +1,458 @@ +use super::*; + +pub(super) 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())))) +} + +pub(super) 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| crate::cmd::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})) +} + +/// 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); + +pub(super) 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 mut warnings = font_style_warnings(&s.ds()?.doc, &ids, &props); + // 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| crate::cmd::props::LAYOUT_KEYS.contains(k)).collect(); + 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> = 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; + } + let others: Vec = 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 = + 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::>().join(", "), + names.join(", ") + )); + } + let doc = s.doc_mut()?; + let before = crate::cmd::styles::bound_before(doc, &ids); + for i in ids { + crate::cmd::props::apply("node.set", doc, i, skipped.get(&i).unwrap_or(&props))?; + } + crate::cmd::styles::detach_edited(doc, &before); + if let Some(o) = pivot { + for (i, q) in pinned { + let d = o - doc.world_transform(i) * q; + if d.hypot().is_finite() { + crate::cmd::arrange::move_world(doc, i, d.x, d.y)?; + } + } + } + if warnings.is_empty() { ok() } else { Ok(json!({"ok": true, "warnings": warnings})) } +} + +/// A `fontStyle` draws in the family's face nearest to it, as a text layer resolves it. When that +/// face has another weight or slant than the name asks for ("Black" on a family whose heaviest is +/// Bold), say which face it is. Families that aren't installed, and names that match a style or a +/// named instance (spelled any way: "SemiBold", "Semi Bold"), say nothing. +fn font_style_warnings(doc: &figcraft_doc::Document, ids: &[NodeId], props: &serde_json::Map) -> Vec { + let Some(style) = props.get("fontStyle").and_then(Value::as_str) else { return Vec::new() }; + let mut families: Vec = match props.get("fontFamily").and_then(Value::as_str) { + Some(f) => vec![f.to_string()], + None => ids + .iter() + .filter_map(|i| doc.node(*i)) + .filter_map(|n| n.kind.text()) + .flat_map(|t| t.spans().into_iter().map(|(_, _, st)| st.font_family.clone()).collect::>()) + .collect(), + }; + families.sort(); + families.dedup(); + let norm = |s: &str| s.to_lowercase().replace([' ', '-', '_'], ""); + let (want_w, want_i) = (i32::from(figcraft_doc::style_weight(style)), style.to_lowercase().contains("italic")); + families + .into_iter() + .filter_map(|family| { + let styles = figcraft_text::family_styles(&family); + if styles.is_empty() || styles.iter().any(|x| norm(x) == norm(style)) { + return None; + } + let face = figcraft_text::find_face(&family, style).filter(|f| f.family.eq_ignore_ascii_case(&family))?; + let w = face.axis("wght").map_or(i32::from(face.weight), |a| a.clamp(f64::from(want_w)).round() as i32); + if face.instance(style).is_some() || (w == want_w && face.italic == want_i) { + return None; + } + Some(format!("{family} has no `{style}` style: it draws in {}, the nearest (it has: {})", face.style, styles.join(", "))) + }) + .collect() +} + +/// `stroke.widthProfile`: a preset or points (through `node.set`'s `widthProfile`, so instances +/// record the override). +pub(super) 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 { + crate::cmd::props::apply(cmd, doc, i, &props)?; + } + ok() +} + +pub(super) 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 = crate::cmd::props::brush_of(&v).ok_or_else(|| bad(cmd, crate::cmd::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() +} + +pub(super) 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)); + // `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)); + doc.reparent(*i, parent, at)?; + } + ok() +} + +pub(super) 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. + crate::cmd::lists::remove_item(n, if fills { crate::cmd::lists::BoundList::Fills } else { crate::cmd::lists::BoundList::Strokes }, idx); + } + } + ok() +} + +pub(super) 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 = opt_index(cmd, p, "index")?; + 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; + 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; + } + if let Some(b) = blend(p, "blend") { + slot.blend = b; + } + crate::cmd::lists::rebind_edited_paint(n, fills, at, &old); + if fills { + n.styles.fill = None; + } else { + n.styles.stroke = None; + } + } + 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. +pub(super) fn paint_copy(s: &mut Session, p: &Value, fills: bool) -> Result { + 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. +pub(super) fn paint_paste(s: &mut Session, p: &Value, fills: bool) -> Result { + 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) +} + +pub(super) 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_none_or(|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) +} + +pub(super) fn effect_add(s: &mut Session, p: &Value) -> Result { + let e: Effect = match crate::cmd::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}`")))? + } + }; + for i in req_targets("effect.add", s, p)? { + s.doc_mut()?.get_mut(i)?.effects.push(e.clone()); + } + ok() +} + +/// Copy effect `index` (the top one by default) of the first target into the clipboard. +pub(super) fn effect_copy(s: &mut Session, p: &Value) -> Result { + 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. +pub(super) fn effect_paste(s: &mut Session, p: &Value) -> Result { + 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() +} diff --git a/crates/engine/src/cmd/edit/select.rs b/crates/engine/src/cmd/edit/select.rs new file mode 100644 index 00000000..03bba890 --- /dev/null +++ b/crates/engine/src/cmd/edit/select.rs @@ -0,0 +1,267 @@ +use super::*; + +pub(super) 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})) +} + +pub(super) 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). +pub(super) 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. +pub(super) 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})) +} + +pub(super) 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})) +} + +pub(super) 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})) +} + +pub(super) 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]})) +} + +pub(super) 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})) +} + +pub(super) 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, + } +} + +pub(super) 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"))?; + let ids = req_targets("object.opacityKeys", s, p)?; + let doc = s.doc_mut()?; + let before = crate::cmd::styles::bound_before(doc, &ids); + for i in ids { + doc.get_mut(i)?.opacity = o; + } + crate::cmd::styles::detach_edited(doc, &before); + Ok(json!({"opacity": o})) +} + +pub(super) 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() +} + +pub(super) 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))); + } + crate::cmd::arrange::resize_cmd(s, &Value::Object(q))?; + } + ok() +} + +pub(super) 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})) +} + +pub(super) 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() +}