mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-29 21:06:50 -06:00
558 lines
27 KiB
Rust
558 lines
27 KiB
Rust
//! Edit menu: undo/redo, clipboard, duplicate/delete, selection commands, find, properties.
|
||
|
||
use std::collections::{BTreeMap, BTreeSet};
|
||
|
||
use figcraft_doc::{Document, Effect, ExportSetting, ImageData, LayoutGrid, Node, NodeId, NodeKind};
|
||
use figcraft_geom::Affine;
|
||
use serde_json::{Map, Value, json};
|
||
|
||
use super::{CommandSpec, ok};
|
||
use crate::params::{
|
||
blend, boolean, color, get, id, id_of, num, num_or, opt_index, paint_of, parse, req_id, req_index, req_num, req_string, req_targets, string,
|
||
targets,
|
||
};
|
||
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<CommandSpec> {
|
||
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)} a main component's copy is an instance of it (a variant stays a new variant in its set)",
|
||
true,
|
||
duplicate
|
||
),
|
||
spec!("edit.copy", "Copy", "{ids?}", false, copy),
|
||
spec!("edit.cut", "Cut", "{ids?}", true, cut),
|
||
spec!(
|
||
"edit.paste",
|
||
"Paste",
|
||
"{parent?, x?, y? (page coordinates of the pasted top-left)} a copied main component pastes as an instance of it within its file (cut ones, and pastes into other files, keep the component)",
|
||
true,
|
||
paste
|
||
),
|
||
spec!(
|
||
"edit.pasteDesignLayers",
|
||
"Paste design layers",
|
||
"{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<NodeId> = 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? (final position among parent's children, bottom first; default top)}",
|
||
true,
|
||
reparent
|
||
),
|
||
spec!("node.reorder", "Reorder layer", "{id, index}", true, |s, p| {
|
||
let i = req_id("node.reorder", p, "id")?;
|
||
let idx = req_num("node.reorder", p, "index")?.max(0.0) as usize;
|
||
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.copy", "Copy fill", "{id?, index? (default the top fill)}", false, |s, p| paint_copy(s, p, true)),
|
||
spec!("fill.paste", "Paste fill", "{ids?, index? (replace the fill there; without: add one)}", true, |s, p| paint_paste(s, p, true)),
|
||
spec!(
|
||
"stroke.copy",
|
||
"Copy stroke",
|
||
"{id?, index? (default the top stroke)} its paint and the stroke's weight, alignment, sides, caps, joins and dashes",
|
||
false,
|
||
|s, p| paint_copy(s, p, false)
|
||
),
|
||
spec!(
|
||
"stroke.paste",
|
||
"Paste stroke",
|
||
"{ids?, index? (replace the stroke there; without: add one)} the copied stroke's settings come with its paint",
|
||
true,
|
||
|s, p| paint_paste(s, p, false)
|
||
),
|
||
spec!(
|
||
"fill.pattern",
|
||
"Pattern fill",
|
||
"{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 = super::props::effect_from("effect.set", p, "effect")?.ok_or_else(|| bad("effect.set", "`effect` required"))?;
|
||
for id in req_targets("effect.set", s, p)? {
|
||
let n = s.doc_mut()?.get_mut(id)?;
|
||
if let Some(slot) = n.effects.get_mut(i) {
|
||
*slot = e.clone();
|
||
}
|
||
}
|
||
ok()
|
||
}),
|
||
spec!("effect.copy", "Copy effect", "{id?, index? (default the top effect)}", false, effect_copy),
|
||
spec!("effect.paste", "Paste effect", "{ids?, index? (replace the effect there; without: add one)}", true, effect_paste),
|
||
spec!("grid.add", "Add layout grid", "{ids?, grid? {kind: grid|columns|rows, ..}}", true, |s, p| {
|
||
let g: LayoutGrid = parse("grid.add", p, "grid")?.unwrap_or_default();
|
||
for id in req_targets("grid.add", s, p)? {
|
||
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|xml (Android Vector Drawable), bleed? (PDF: points, or with a unit: \"3mm\", \"0.125in\", \"9pt\"), cropMarks?, bleedMarks?, cmyk? (PDF print options), vdCompat? (XML: bestEffort|exact|ignore)}} (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|xml: Android Vector Drawable), bleed? (PDF: points, or with a unit: \"3mm\", \"0.125in\", \"9pt\"), cropMarks?, bleedMarks?, cmyk? (PDF print options), vdCompat? (XML: bestEffort|exact|ignore)}",
|
||
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, as?}]} (one undo step)", false, |s, p| {
|
||
let label = string(p, "label").unwrap_or_else(|| "Batch".into());
|
||
let cmds: Vec<BatchStep> = match get(p, "commands") {
|
||
Some(Value::Array(a)) => {
|
||
let mut out = Vec::new();
|
||
for (i, c) in a.iter().enumerate() {
|
||
let cmd =
|
||
c.get("command").and_then(Value::as_str).ok_or_else(|| bad("command.batch", format!("step {i} has no `command`")))?;
|
||
let params = c.get("params").cloned().unwrap_or(json!({}));
|
||
out.push(BatchStep { name: crate::batch::step_name(i, c)?, ..BatchStep::new(cmd, params) });
|
||
}
|
||
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<NodeId> {
|
||
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<NodeId>) -> 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<NodeId> {
|
||
let set: BTreeSet<NodeId> = 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<NodeId> {
|
||
let Some(page) = ids.first().and_then(|i| doc.page_of(*i)) else { return ids.to_vec() };
|
||
let order: BTreeMap<NodeId, usize> = 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<Value> {
|
||
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<NodeId>,
|
||
pub copies: Vec<NodeId>,
|
||
}
|
||
|
||
impl Duplicated {
|
||
pub fn is_selection(&self, sel: &[NodeId]) -> bool {
|
||
let set = |v: &[NodeId]| v.iter().copied().collect::<BTreeSet<_>>();
|
||
!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<Value> {
|
||
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 = crate::components::copy_in_file(doc, i, parent, idx)?;
|
||
let (dx, dy) = match (num(p, "dx"), num(p, "dy")) {
|
||
(Some(dx), dy) => (dx, dy.unwrap_or(0.0)),
|
||
(None, Some(dy)) => (0.0, dy),
|
||
// 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;
|
||
}
|
||
}
|
||
// An instance keeps the name instances get by default.
|
||
if rename && !matches!(doc.get(nid)?.kind, NodeKind::Instance { .. }) {
|
||
let nn = doc.get_mut(nid)?;
|
||
nn.name = bump_name(&nn.name);
|
||
}
|
||
out.push(nid);
|
||
}
|
||
// Board connectors copied with what they join join the copies.
|
||
let map = crate::board::copy_map(doc, &ids, &out);
|
||
crate::board::rejoin(doc, &crate::board::copied_items(doc, &map), &map, false);
|
||
s.set_selection(out.clone());
|
||
s.ds_mut()?.last_duplicate = Some(Duplicated { sources: ids, copies: out.clone() });
|
||
Ok(json!({"ids": out}))
|
||
}
|
||
|
||
fn bump_name(n: &str) -> String {
|
||
if let Some((base, num)) = n.rsplit_once(' ')
|
||
&& let Ok(k) = num.parse::<u64>()
|
||
&& let Some(next) = k.checked_add(1)
|
||
{
|
||
return format!("{base} {next}");
|
||
}
|
||
format!("{n} 2")
|
||
}
|