Merge main into #2200

This commit is contained in:
bflatastic
2026-11-12 09:48:58 +07:00
9 changed files with 856 additions and 24 deletions
+1 -1
View File
@@ -638,7 +638,7 @@ fn index(cmd: &str, p: &Value, key: &str) -> Result<usize> {
}
/// Delete the points and segments of `parts` (`heal` keeps paths through deleted points closed).
fn delete_selection(s: &mut Session, parts: Vec<EditPart>, heal: bool) -> Result<()> {
pub(crate) fn delete_selection(s: &mut Session, parts: Vec<EditPart>, heal: bool) -> Result<()> {
let doc = s.doc_mut()?;
for EditPart { id, vertices: verts, segments: segs } in parts {
edit(doc, id, |net| {
+19 -3
View File
@@ -17,6 +17,10 @@ mod clipboard;
mod props;
pub(crate) use props::{edit_region_fills, fill_regions};
mod select;
mod vector_clipboard;
pub use vector_clipboard::clipboard_svg;
#[cfg(test)]
mod vector_clipboard_tests;
pub(crate) use clipboard::apply_copied_props;
pub use clipboard::{CLIP_CLOSE, CLIP_OPEN, copy_to_clipboard, design_clipboard_buffer, paste_into};
@@ -117,12 +121,24 @@ pub fn specs() -> Vec<CommandSpec> {
true,
duplicate
),
spec!("edit.copy", "Copy", "{ids?}", false, copy),
spec!("edit.cut", "Cut", "{ids?}", true, cut),
spec!(
"edit.copy",
"Copy",
"{ids?} in vector edit mode with points or segments selected (and no ids): just those, the segments touching a selected point, as a temporary vector on the clipboard (one per edited layer) with the edited layer's look, kept at its place on the page; points that touch no segment have nothing to copy, so Copy with only those is refused (Disabled). Returns {copied, segments}",
false,
copy
),
spec!(
"edit.cut",
"Cut",
"{ids?} in vector edit mode with points or segments selected (and no ids): copies them as Copy does, then removes them as Delete does; the path splits in the same layer and edit mode stays on. Returns {cut (layers), segments}. With only lone points (nothing to copy) it is refused (Disabled) and changes nothing, as Copy is; Delete removes lone points",
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)",
"{parent?, x?, y? (page coordinates of the pasted top-left; give both or neither, one alone is refused)} in vector edit mode (and no parent): the copied vectors' segments and the outlines of shapes, booleans and lines join the edited vector's network (at x/y when given, else in place), their points selected, as one undo step; layers with no outline (text, images, instances, frames, groups) are skipped and named in `skipped` (present only when some were), and when none has an outline the paste is refused (Disabled), as it is past 500,000 segments in one vector. Returns {ids: [] (always empty: no layer is created), edited (the vector's id), vertices (points added), skipped?}, unlike a paste outside edit mode, which returns the new layers' `ids`; 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
),
+53 -11
View File
@@ -1,6 +1,6 @@
use super::{
Affine, BTreeMap, BTreeSet, Document, EngineError, ImageData, Node, NodeId, NodeKind, Result, Session, Value, bad, delete, id, json, num, ok,
req_targets, roots, string, targets, z_sorted,
Affine, BTreeMap, BTreeSet, Document, EngineError, ImageData, Node, NodeId, NodeKind, Result, Session, Value, bad, delete, get, id, json, num,
ok, req_targets, roots, string, targets, vector_clipboard, z_sorted,
};
/// The copied form of the selection.
@@ -12,6 +12,18 @@ pub fn copy_to_clipboard(s: &mut Session, cmd: &str, ids: &[NodeId]) -> Result<(
return Err(EngineError::Disabled(cmd.into(), "pages can't be copied; use page.duplicate".into()));
}
let (nodes, images, bounds) = copied(&ds.doc, &layers);
store_clipboard(s, cmd, nodes, images, bounds)
}
/// Make `nodes` (the copied layers, each root's transform in page space) the clipboard's layers.
pub(super) fn store_clipboard(
s: &mut Session,
cmd: &str,
nodes: Vec<(NodeId, Vec<Node>)>,
images: BTreeMap<String, ImageData>,
bounds: Option<figcraft_geom::Rect>,
) -> Result<()> {
let ds = s.ds()?;
let (source, source_path, uid) = (ds.doc.clone(), ds.path.clone(), ds.uid);
s.clipboard.source = Some(source);
s.clipboard.source_path = source_path;
@@ -43,21 +55,26 @@ fn copied(doc: &Document, ids: &[NodeId]) -> Copied {
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());
}
}
}
collect_images(doc, &sub, &mut images);
nodes.push((*i, sub));
}
let bounds = doc.bounds_of(&ids);
(nodes, images, bounds)
}
/// The images the paints of `nodes` use, taken from `doc` into `images`.
pub(super) fn collect_images(doc: &Document, nodes: &[Node], images: &mut BTreeMap<String, ImageData>) {
for n in nodes {
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());
}
}
}
}
/// The markers around the copied layers in the clipboard's HTML, spelt as that format spells them.
pub const CLIP_OPEN: &str = "(figma)";
@@ -167,13 +184,34 @@ fn layers_missing_images(doc: &figcraft_doc::Document, ids: &[NodeId]) -> Vec<St
names
}
/// Whether the command names its layers (`ids` / `id`): it then acts on them even in vector edit mode.
fn names_layers(p: &Value) -> bool {
get(p, "ids").is_some() || get(p, "id").is_some()
}
pub(super) fn copy(s: &mut Session, p: &Value) -> Result<Value> {
// In vector edit mode with points or segments selected, Copy takes just those (#1875).
if !names_layers(p)
&& let Some(sel) = vector_clipboard::edit_selection(s)
&& sel.any
{
// Only lone points selected: nothing to copy, and not the whole layer either.
return vector_clipboard::copy_selection(s, "edit.copy", sel);
}
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<Value> {
// In vector edit mode with points or segments selected, Cut takes just those and stays in edit mode (#1875).
if !names_layers(p)
&& let Some(sel) = vector_clipboard::edit_selection(s)
&& sel.any
{
// Lone points with nothing to copy: `cut_selection` refuses, as Copy does.
return vector_clipboard::cut_selection(s, sel);
}
let ids = req_targets("edit.cut", s, p)?;
copy_to_clipboard(s, "edit.cut", &ids)?;
let doc = s.doc()?;
@@ -238,6 +276,10 @@ pub fn paste_into(s: &mut Session, parent: NodeId, at: Option<(f64, f64)>) -> Re
}
pub(super) fn paste(s: &mut Session, p: &Value) -> Result<Value> {
// While editing a vector, the copied layers join its network (#1875); `parent` asks for layers.
if id(p, "parent").is_none() && s.ds()?.vector_edit.is_some() {
return vector_clipboard::paste_into_vector(s, p);
}
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).
@@ -0,0 +1,303 @@
//! Copy, cut and paste in vector edit mode (#1875).
//!
//! With points or segments selected, Copy and Cut take just those: a temporary vector holding the
//! selected segments (and the segments touching a selected point) goes on the ordinary layer
//! clipboard, kept at its place on the page, so a paste outside edit mode is a plain layer paste
//! in place. Cut then removes the selection the way Delete does and stays in edit mode. Paste
//! while editing adds the clipboard's layers to the edited vector's network, in place: a vector
//! with its segments, a shape or boolean with its outline.
use crate::bad;
use figcraft_doc::{NodeKind, Region, VectorNetwork};
use super::clipboard::{collect_images, store_clipboard};
use super::{Affine, BTreeMap, BTreeSet, Document, EngineError, Node, NodeId, Result, Session, Value, json, num};
use crate::canvas::vectoredit::{self, EditPart};
/// The most segments a vector network takes through a paste.
const MAX_SEGMENTS: usize = 500_000;
/// What the edited layers have selected.
pub(super) struct EditSelection {
/// Whether any edited layer has a point or a segment selected.
pub any: bool,
/// Each edited layer with something to copy, and the sub-network that is.
pub parts: Vec<(NodeId, VectorNetwork)>,
/// What Cut removes, per edited layer: the same points and segments the copy was built from.
pub delete: Vec<EditPart>,
}
/// The selection of the vector being edited (and the other layers edited with it), or `None` when
/// not in vector edit mode.
pub(super) fn edit_selection(s: &Session) -> Option<EditSelection> {
let ds = s.ds().ok()?;
let ve = ds.vector_edit.as_ref()?;
let mut sel = EditSelection { any: false, parts: Vec::new(), delete: Vec::new() };
for part in ve.parts() {
if part.vertices.is_empty() && part.segments.is_empty() {
continue;
}
sel.any = true;
let Some(net) = vectoredit::network(&ds.doc, part.id) else { continue };
let active = part.id == ve.id;
// Points selected only through a selected region don't pull in the segments touching
// them: the region's own segments are the selection.
let vertices: Vec<usize> = part.vertices.iter().copied().filter(|v| !active || !ve.region_vertices.contains(v)).collect();
let mut segments = part.segments.clone();
if active {
segments.extend(ve.regions.iter().filter_map(|r| net.regions.get(*r)).flat_map(|r| r.loops.iter().flatten().copied()));
}
sel.delete.push(EditPart { id: part.id, vertices: vertices.clone(), segments: segments.clone() });
if let Some(sub) = sub_network(net, &vertices, &segments) {
sel.parts.push((part.id, sub));
}
}
Some(sel)
}
/// The part of `net` that is the segments `segments` and those touching a point of `vertices`,
/// with the points they join; `None` when there are none (a lone point with no segment draws
/// nothing). The regions whose every segment is kept stay regions.
fn sub_network(net: &VectorNetwork, vertices: &[usize], segments: &[usize]) -> Option<VectorNetwork> {
let picked: BTreeSet<usize> = vertices.iter().copied().collect();
let mut keep: BTreeSet<usize> = segments.iter().copied().filter(|i| *i < net.segments.len()).collect();
for (i, sg) in net.segments.iter().enumerate() {
if picked.contains(&sg.a) || picked.contains(&sg.b) {
keep.insert(i);
}
}
let used: BTreeSet<usize> = keep.iter().filter_map(|i| net.segments.get(*i)).flat_map(|sg| [sg.a, sg.b]).collect();
let vmap: BTreeMap<usize, usize> = used.iter().enumerate().map(|(new, old)| (*old, new)).collect();
let smap: BTreeMap<usize, usize> = keep.iter().enumerate().map(|(new, old)| (*old, new)).collect();
let mut out = VectorNetwork {
vertices: used.iter().filter_map(|v| net.vertices.get(*v).cloned()).collect(),
segments: keep
.iter()
.filter_map(|i| net.segments.get(*i))
.filter_map(|sg| {
let mut sg = *sg;
sg.a = *vmap.get(&sg.a)?;
sg.b = *vmap.get(&sg.b)?;
Some(sg)
})
.collect(),
regions: Vec::new(),
};
if out.segments.is_empty() || out.segments.len() != keep.len() || out.vertices.len() != used.len() {
return None;
}
for r in &net.regions {
let loops: Option<Vec<Vec<usize>>> = r.loops.iter().map(|l| l.iter().map(|i| smap.get(i).copied()).collect::<Option<Vec<usize>>>()).collect();
if let Some(loops) = loops.filter(|l| !l.is_empty()) {
out.regions.push(Region { loops, ..r.clone() });
}
}
Some(out)
}
/// The clipboard's layer for edited vector `id` holding `sub`: it keeps the layer's look (fills,
/// strokes, effects, opacity, blend) and its place on the page. A variable width profile runs
/// along the whole path, so it stays only when every segment is copied.
fn clip_node(doc: &Document, id: NodeId, mut sub: VectorNetwork) -> Option<Node> {
let mut n = doc.node(id)?.clone();
let whole = vectoredit::network(doc, id).is_some_and(|all| all.segments.len() == sub.segments.len());
let b = sub.bounds().filter(|b| b.x0.is_finite() && b.y0.is_finite() && b.width().is_finite() && b.height().is_finite())?;
sub.translate(-b.x0, -b.y0);
n.transform = doc.world_transform(id) * Affine::translate((b.x0, b.y0));
n.width = b.width();
n.height = b.height();
n.kind = NodeKind::Vector { network: sub };
n.parent = None;
n.children.clear();
n.name = "Vector".into();
if !whole {
n.stroke.width_profile = None;
}
Some(n)
}
/// Put the selection's sub-networks on the layer clipboard. Returns how many segments went.
fn copy_parts(s: &mut Session, cmd: &str, sel: &EditSelection) -> Result<usize> {
let doc = s.doc()?;
let mut nodes = Vec::new();
let mut images = BTreeMap::new();
let mut bounds: Option<figcraft_geom::Rect> = None;
let mut segments = 0;
for (id, sub) in &sel.parts {
segments += sub.segments.len();
let Some(n) = clip_node(doc, *id, sub.clone()) else { continue };
let r = figcraft_geom::transform_rect_bbox(n.transform, n.local_rect());
bounds = Some(bounds.map_or(r, |b| b.union(r)));
collect_images(doc, std::slice::from_ref(&n), &mut images);
nodes.push((*id, vec![n]));
}
if nodes.is_empty() {
return Err(EngineError::Disabled(cmd.into(), "the selection has nothing to copy".into()));
}
store_clipboard(s, cmd, nodes, images, bounds)?;
Ok(segments)
}
/// Copy the selected segments (see [`edit_selection`]).
pub(super) fn copy_selection(s: &mut Session, cmd: &str, sel: EditSelection) -> Result<Value> {
let segments = copy_parts(s, cmd, &sel)?;
Ok(json!({"copied": s.clipboard.nodes.len(), "segments": segments}))
}
/// Cut: copy the selected segments, then remove the selection as Delete does (the path splits in
/// the same layer and edit mode stays on). Cut is Copy plus Delete, so it is refused exactly where
/// Copy is: a selection with nothing to copy (only lone points that touch no segment) is
/// `Disabled` and changes nothing, clipboard included. Delete removes such points.
pub(super) fn cut_selection(s: &mut Session, sel: EditSelection) -> Result<Value> {
if sel.parts.is_empty() {
return Err(EngineError::Disabled("edit.cut".into(), "the selection has nothing to copy (Delete removes lone points)".into()));
}
let cut = sel.parts.len();
let segments = copy_parts(s, "edit.cut", &sel)?;
vectoredit::delete_selection(s, sel.delete, false)?;
Ok(json!({"cut": cut, "segments": segments}))
}
/// The SVG of the layers a partial vector-edit copy or cut put on the clipboard, for the system
/// clipboard: they are temporary layers that live only in the clipboard, so they are placed in a
/// scratch document to export. `None` when the clipboard holds none or the export fails.
pub fn clipboard_svg(s: &Session) -> Option<String> {
let mut scratch = Document::new("Clipboard");
let page = scratch.current_page?;
for (k, v) in &s.clipboard.images {
scratch.images.insert(k.clone(), v.clone());
}
let mut ids = Vec::new();
for (root, nodes) in &s.clipboard.nodes {
let map: BTreeMap<NodeId, NodeId> = nodes.iter().map(|n| (n.id, scratch.alloc_id())).collect();
ids.push(scratch.paste_nodes(nodes, &map, *root, page, None).ok()?);
}
let bytes = crate::cmd::file::export_bytes(&scratch, &ids, figcraft_doc::ExportFormat::Svg, 1.0).ok()?;
String::from_utf8(bytes).ok()
}
/// The network of the outline of copied layer `n` in its own coordinates: a vector's segments, a
/// shape's or boolean's outline. `None` for the layers with no outline to add (text, images,
/// frames, groups, instances, …). `doc` is the file the layer was copied from.
fn outline_network(doc: &Document, n: &Node) -> Option<VectorNetwork> {
let net = match &n.kind {
NodeKind::Vector { network } => network.clone(),
k if k.is_shape() || matches!(k, NodeKind::BooleanOp { .. }) => {
let (path, rule) = figcraft_render::node_outline(doc, n)?;
let mut net = VectorNetwork::from_path(&path);
if matches!(n.kind, NodeKind::Line) {
net.regions.clear();
}
if rule == figcraft_render::FillRule::EvenOdd {
for r in &mut net.regions {
r.winding = figcraft_doc::WindingRule::EvenOdd;
}
}
net
}
_ => return None,
};
(!net.segments.is_empty()).then_some(net)
}
/// `net` moved by `m` (points, handles, the regions' paint frames and the points' radii).
fn placed(mut net: VectorNetwork, m: Affine) -> Option<VectorNetwork> {
let c = m.as_coeffs();
if !c.iter().all(|x| x.is_finite()) {
return None;
}
let linear = Affine::new([c[0], c[1], c[2], c[3], 0.0, 0.0]);
let scale = m.determinant().abs().sqrt();
for v in &mut net.vertices {
let p = m * v.point();
(v.x, v.y) = (p.x, p.y);
v.radius *= scale;
}
for sg in &mut net.segments {
let (ta, tb) = (linear * figcraft_geom::Point::new(sg.ta.0, sg.ta.1), linear * figcraft_geom::Point::new(sg.tb.0, sg.tb.1));
sg.ta = (ta.x, ta.y);
sg.tb = (tb.x, tb.y);
}
for r in &mut net.regions {
if let Some(frame) = r.paint_space.as_mut() {
frame.transform = m * frame.transform;
}
}
let finite = net.vertices.iter().all(|v| v.x.is_finite() && v.y.is_finite())
&& net.segments.iter().all(|sg| [sg.ta.0, sg.ta.1, sg.tb.0, sg.tb.1].iter().all(|x| x.is_finite()));
finite.then_some(net)
}
/// Paste while editing a vector: the clipboard's layers join its network in place (with `x`, `y`:
/// the copied top-left moved there), their points selected. Layers with no outline are skipped
/// (named in `skipped`); when none has one, nothing is pasted.
pub(super) fn paste_into_vector(s: &mut Session, p: &Value) -> Result<Value> {
const CMD: &str = "edit.paste";
if s.clipboard.nodes.is_empty() {
return Err(EngineError::Disabled(CMD.into(), "the clipboard is empty".into()));
}
let target = vectoredit::editing(s)?.id;
let shift = match (num(p, "x"), num(p, "y"), s.clipboard.bounds) {
(Some(x), Some(y), Some(b)) => Affine::translate((x - b.x0, y - b.y0)),
(None, None, _) | (Some(_), Some(_), None) => Affine::IDENTITY,
_ => return Err(bad(CMD, "give both x and y, or neither")),
};
let live = s.doc()?;
// Borrow the clipboard (it holds the source document and the images) to build the pieces.
let clip = &s.clipboard;
let source: &Document = clip.source.as_deref().unwrap_or(live);
let to_local = live.world_transform(target).inverse() * shift;
let mut pieces = Vec::new();
let mut skipped: Vec<String> = Vec::new();
for (_, nodes) in &clip.nodes {
let Some(root) = nodes.first() else { continue };
match outline_network(source, root).and_then(|net| placed(net, to_local * root.transform)) {
Some(net) => pieces.push(net),
None => skipped.push(root.name.clone()),
}
}
if pieces.is_empty() {
return Err(EngineError::Disabled(
CMD.into(),
"none of the copied layers has an outline to add to the vector (text, frames and groups have none)".into(),
));
}
let have = vectoredit::network(live, target).map_or(0, |n| n.segments.len());
if have.saturating_add(pieces.iter().map(|n| n.segments.len()).sum::<usize>()) > MAX_SEGMENTS {
return Err(EngineError::Disabled(CMD.into(), format!("a vector takes at most {MAX_SEGMENTS} segments")));
}
let mut pasted: Vec<usize> = Vec::new();
vectoredit::edit(s.doc_mut()?, target, |net| {
for piece in pieces {
let (vo, so) = (net.vertices.len(), net.segments.len());
pasted.extend(vo..vo + piece.vertices.len());
net.vertices.extend(piece.vertices);
net.segments.extend(piece.segments.into_iter().map(|mut sg| {
sg.a += vo;
sg.b += vo;
sg
}));
net.regions.extend(piece.regions.into_iter().map(|mut r| {
for i in r.loops.iter_mut().flatten() {
*i += so;
}
r
}));
}
})?;
let selected = pasted.clone();
vectoredit::set_state(s, |ve| {
ve.clear_selection();
ve.clear_regions();
ve.vertices = selected;
ve.anchor = None;
ve.pen_out = None;
ve.width_point = None;
})?;
// `ids` stays empty: no layer is created, the pasted outlines joined layer `edited`.
let mut out = json!({"ids": [], "edited": target, "vertices": pasted.len()});
if !skipped.is_empty() {
out["skipped"] = json!(skipped);
}
Ok(out)
}
@@ -0,0 +1,442 @@
//! Copy, cut and paste in vector edit mode (#1875), measured in Figma: Copy takes the selected
//! segments (a lone point: the segments touching it) and a paste outside is a new vector in place;
//! Cut removes them, splitting the path in the same layer; Paste while editing adds the clipboard's
//! segments or a copied layer's outline to the vector's network in place, selected.
use serde_json::{Value, json};
use crate::{EngineError, Session, params};
use figcraft_doc::{NodeId, NodeKind, VectorNetwork};
fn ex(s: &mut Session, c: &str, p: Value) -> Value {
s.execute(c, &p).unwrap_or_else(|e| panic!("{c}: {e}"))
}
fn id_of(v: &Value) -> NodeId {
params::id_of(&v["id"]).unwrap()
}
/// Open path A-B-C-D (three segments) with a look of its own, in edit mode.
fn path(s: &mut Session) -> NodeId {
let r = ex(s, "vector.create", json!({"points": [[100, 100], [200, 150], [300, 100], [400, 200]], "closed": false}));
let id = id_of(&r);
ex(s, "node.set", json!({"id": id, "props": {"strokeWeight": 7, "opacity": 0.5}}));
ex(s, "vector.edit.begin", json!({"id": id}));
id
}
fn net(s: &Session, id: NodeId) -> VectorNetwork {
crate::vectoredit::network(s.doc().unwrap(), id).unwrap().clone()
}
fn layers(s: &Session) -> Vec<NodeId> {
s.doc().unwrap().node(s.page().unwrap()).unwrap().children.clone()
}
/// The network's points on the page, rounded, in vertex order.
fn page_points(s: &Session, id: NodeId) -> Vec<(i64, i64)> {
let w = s.doc().unwrap().world_transform(id);
net(s, id).vertices.iter().map(|v| w * v.point()).map(|p| (p.x.round() as i64, p.y.round() as i64)).collect()
}
/// The number of separate paths a network is made of.
fn subpaths(n: &VectorNetwork) -> usize {
let mut group: Vec<usize> = (0..n.vertices.len()).collect();
fn root(g: &[usize], mut i: usize) -> usize {
while g[i] != i {
i = g[i];
}
i
}
for sg in &n.segments {
let (a, b) = (root(&group, sg.a), root(&group, sg.b));
group[a] = b;
}
(0..n.vertices.len()).filter(|i| root(&group, *i) == *i && n.segments.iter().any(|sg| root(&group, sg.a) == *i)).count()
}
fn select(s: &mut Session, vertices: &[usize], segments: &[usize]) {
ex(s, "vector.edit.select", json!({"vertices": vertices, "segments": segments}));
}
fn leave(s: &mut Session) {
ex(s, "vector.edit.end", json!({}));
assert!(s.ds().unwrap().vector_edit.is_none());
}
fn world_rect(s: &Session, id: NodeId) -> (f64, f64, f64, f64) {
let b = s.doc().unwrap().world_bounds(id).unwrap();
(b.x0, b.y0, b.width(), b.height())
}
#[test]
fn a_copied_segment_pastes_outside_as_a_new_vector_in_place() {
let mut s = Session::new();
let id = path(&mut s);
select(&mut s, &[], &[1]);
let r = ex(&mut s, "edit.copy", json!({}));
assert_eq!(r["segments"], 1);
assert_eq!(net(&s, id).segments.len(), 3, "Copy changes nothing");
leave(&mut s);
ex(&mut s, "edit.paste", json!({}));
let all = layers(&s);
assert_eq!(all.len(), 2);
let new = all[1];
let n = net(&s, new);
assert_eq!((n.vertices.len(), n.segments.len()), (2, 1));
assert_eq!(page_points(&s, new), vec![(200, 150), (300, 100)], "at the segment's own page position");
assert_eq!(world_rect(&s, new), (200.0, 100.0, 100.0, 50.0));
// The layer's look comes along (the width profile aside).
let node = s.doc().unwrap().node(new).unwrap();
assert_eq!((node.stroke.weight, node.opacity), (7.0, 0.5));
assert_eq!(s.selection(), vec![new]);
}
#[test]
fn a_copied_point_takes_the_segments_touching_it() {
let mut s = Session::new();
path(&mut s);
select(&mut s, &[1], &[]);
ex(&mut s, "edit.copy", json!({}));
leave(&mut s);
ex(&mut s, "edit.paste", json!({}));
let new = *layers(&s).last().unwrap();
let n = net(&s, new);
assert_eq!((n.vertices.len(), n.segments.len()), (3, 2), "A-B and B-C");
assert_eq!(page_points(&s, new), vec![(100, 100), (200, 150), (300, 100)]);
}
#[test]
fn nothing_selected_still_copies_the_whole_layer() {
let mut s = Session::new();
path(&mut s);
ex(&mut s, "edit.copy", json!({}));
leave(&mut s);
ex(&mut s, "edit.paste", json!({}));
let new = *layers(&s).last().unwrap();
assert_eq!(net(&s, new).segments.len(), 3);
}
#[test]
fn cut_removes_the_segment_in_the_same_layer_and_stays_in_edit_mode() {
let mut s = Session::new();
let id = path(&mut s);
select(&mut s, &[], &[1]);
ex(&mut s, "edit.cut", json!({}));
assert_eq!(layers(&s), vec![id], "no new layer");
let n = net(&s, id);
assert_eq!(n.segments.len(), 2);
assert_eq!(subpaths(&n), 2, "the path split in two");
let ve = s.ds().unwrap().vector_edit.clone().expect("edit mode stays on");
assert!(ve.segments.is_empty() && ve.vertices.is_empty());
leave(&mut s);
ex(&mut s, "edit.paste", json!({}));
let all = layers(&s);
assert_eq!(all.len(), 2);
let new = all[1];
assert_eq!(net(&s, new).segments.len(), 1);
assert_eq!(page_points(&s, new), vec![(200, 150), (300, 100)]);
}
#[test]
fn cut_is_one_undo_step() {
let mut s = Session::new();
let id = path(&mut s);
select(&mut s, &[], &[1]);
ex(&mut s, "edit.cut", json!({}));
assert_eq!(net(&s, id).segments.len(), 2);
s.undo().unwrap();
assert_eq!(net(&s, id).segments.len(), 3);
assert_eq!(page_points(&s, id), vec![(100, 100), (200, 150), (300, 100), (400, 200)]);
}
#[test]
fn a_cut_point_takes_its_segments_with_it() {
let mut s = Session::new();
let id = path(&mut s);
select(&mut s, &[1], &[]);
ex(&mut s, "edit.cut", json!({}));
assert_eq!(net(&s, id).segments.len(), 1, "only C-D is left");
leave(&mut s);
ex(&mut s, "edit.paste", json!({}));
let new = *layers(&s).last().unwrap();
assert_eq!(net(&s, new).segments.len(), 2, "A-B and B-C");
}
#[test]
fn pasting_segments_while_editing_adds_them_to_the_same_network_in_place() {
let mut s = Session::new();
let id = path(&mut s);
select(&mut s, &[], &[1]);
ex(&mut s, "edit.copy", json!({}));
let r = ex(&mut s, "edit.paste", json!({}));
assert_eq!(r["vertices"], 2);
assert_eq!(layers(&s), vec![id], "no new layer");
let n = net(&s, id);
assert_eq!((n.vertices.len(), n.segments.len()), (6, 4));
assert_eq!(page_points(&s, id)[4..], [(200, 150), (300, 100)], "on top of the original");
let ve = s.ds().unwrap().vector_edit.clone().unwrap();
assert_eq!(ve.vertices, vec![4, 5], "the pasted points are selected");
assert!(ve.segments.is_empty());
// One undo reverts it.
s.undo().unwrap();
assert_eq!(net(&s, id).segments.len(), 3);
assert_eq!(net(&s, id).vertices.len(), 4);
}
#[test]
fn pasting_at_a_point_moves_the_copied_top_left_there() {
let mut s = Session::new();
let id = path(&mut s);
select(&mut s, &[], &[1]);
ex(&mut s, "edit.copy", json!({}));
ex(&mut s, "edit.paste", json!({"x": 0, "y": 0}));
assert_eq!(page_points(&s, id)[4..], [(0, 50), (100, 0)]);
}
#[test]
fn pasting_a_rectangle_while_editing_adds_its_outline_in_the_vectors_coordinates() {
let mut s = Session::new();
let rect = id_of(&ex(&mut s, "shape.rectangle", json!({"x": 500, "y": 300, "width": 60, "height": 40})));
ex(&mut s, "select.set", json!({"ids": [rect]}));
ex(&mut s, "edit.copy", json!({}));
let id = path(&mut s);
let before = world_rect(&s, id);
ex(&mut s, "edit.paste", json!({}));
assert_eq!(layers(&s), vec![rect, id], "no new layer");
let n = net(&s, id);
assert_eq!((n.vertices.len(), n.segments.len()), (8, 7), "four corners, four sides, joined to the path");
let mut corners: Vec<(i64, i64)> = page_points(&s, id)[4..].to_vec();
corners.sort_unstable();
assert_eq!(corners, vec![(500, 300), (500, 340), (560, 300), (560, 340)]);
let ve = s.ds().unwrap().vector_edit.clone().unwrap();
assert_eq!(ve.vertices, vec![4, 5, 6, 7]);
let after = world_rect(&s, id);
assert!(after.2 > before.2 && after.3 > before.3, "the vector's bounds grow: {before:?} to {after:?}");
assert_eq!((after.0, after.1), (before.0, before.1));
assert_eq!(after.0 + after.2, 560.0);
s.undo().unwrap();
assert_eq!(net(&s, id).vertices.len(), 4);
assert_eq!(world_rect(&s, id), before);
}
#[test]
fn a_rotated_vector_takes_pasted_points_in_its_own_coordinates() {
let mut s = Session::new();
let id = path(&mut s);
ex(&mut s, "node.set", json!({"id": id, "props": {"rotation": 90}}));
select(&mut s, &[], &[1]);
ex(&mut s, "edit.copy", json!({}));
let before = page_points(&s, id);
assert_ne!(before[0], (100, 100), "the vector is rotated");
ex(&mut s, "edit.paste", json!({}));
let after = page_points(&s, id);
assert_eq!(after[4..], before[1..3]);
}
#[test]
fn layers_with_no_outline_are_skipped_and_named() {
let mut s = Session::new();
let rect = id_of(&ex(&mut s, "shape.rectangle", json!({"x": 500, "y": 300, "width": 60, "height": 40})));
let text = id_of(&ex(&mut s, "text.create", json!({"text": "hello", "x": 500, "y": 400})));
let name = s.doc().unwrap().node(text).unwrap().name.clone();
// Only text: nothing to add, and nothing changes.
ex(&mut s, "select.set", json!({"ids": [text]}));
ex(&mut s, "edit.copy", json!({}));
let id = path(&mut s);
let err = s.execute("edit.paste", &json!({})).unwrap_err();
assert!(matches!(err, EngineError::Disabled(..)), "{err}");
assert_eq!(net(&s, id).vertices.len(), 4);
// The rectangle joins, the text is named as skipped.
leave(&mut s);
ex(&mut s, "edit.copy", json!({"ids": [rect, text]}));
ex(&mut s, "vector.edit.begin", json!({"id": id}));
let r = ex(&mut s, "edit.paste", json!({}));
assert_eq!(r["skipped"], json!([name]));
assert_eq!(net(&s, id).vertices.len(), 8);
}
#[test]
fn a_parent_still_pastes_layers_while_editing() {
let mut s = Session::new();
let id = path(&mut s);
select(&mut s, &[], &[1]);
ex(&mut s, "edit.copy", json!({}));
let page = s.page().unwrap();
ex(&mut s, "edit.paste", json!({"parent": page}));
assert_eq!(layers(&s).len(), 2);
assert_eq!(net(&s, id).segments.len(), 3);
}
#[test]
fn an_empty_clipboard_refuses_to_paste_while_editing() {
let mut s = Session::new();
path(&mut s);
assert!(matches!(s.execute("edit.paste", &json!({})), Err(EngineError::Disabled(..))));
}
#[test]
fn a_selected_face_copies_its_own_outline_with_the_fill() {
let mut s = Session::new();
let r = ex(&mut s, "shape.rectangle", json!({"x": 100, "y": 100, "width": 80, "height": 60}));
let id = id_of(&r);
ex(&mut s, "vector.edit.begin", json!({"id": id}));
select(&mut s, &[], &[0, 1, 2, 3]);
ex(&mut s, "edit.copy", json!({}));
leave(&mut s);
ex(&mut s, "edit.paste", json!({}));
let new = *layers(&s).last().unwrap();
let n = net(&s, new);
assert_eq!((n.segments.len(), n.regions.len()), (4, 1), "the closed shape stays filled");
assert!(matches!(s.doc().unwrap().node(new).unwrap().kind, NodeKind::Vector { .. }));
}
/// Everything junk must not change when a command is refused: the vector, the layers, the edit
/// selection and what the clipboard holds.
fn state(s: &Session, id: NodeId) -> String {
let ve = s.ds().unwrap().vector_edit.clone();
let clip: Vec<String> = s.clipboard.nodes.iter().flat_map(|(_, n)| n).map(|n| format!("{:?}", n.kind)).collect();
format!("{:?}|{:?}|{:?}|{:?}|{}", net(s, id), layers(s), ve.map(|v| (v.vertices, v.segments)), clip, s.clipboard.layers_generation)
}
fn finite(n: &VectorNetwork) -> bool {
n.vertices.iter().all(|v| v.x.is_finite() && v.y.is_finite())
&& n.segments.iter().all(|sg| [sg.ta.0, sg.ta.1, sg.tb.0, sg.tb.1].iter().all(|x| x.is_finite()))
}
#[test]
fn copy_cut_and_paste_survive_junk_in_vector_edit_mode() {
let junk = [
json!({}),
json!(null),
json!([1, 2]),
json!({"ids": ["x", -1, 99999]}),
json!({"id": "nope", "x": "NaN", "y": 1e308}),
json!({"parent": "nope", "x": -1e308, "y": "x"}),
json!({"x": 1e308, "y": 1e308}),
json!({"x": 1e308}),
];
for cmd in ["edit.copy", "edit.cut", "edit.paste", "edit.pasteOver", "edit.pasteReplace"] {
for j in &junk {
// Both a selection with a text layer next to it, and after a copy from the selection.
for copied in [false, true] {
let mut s = Session::new();
let id = path(&mut s);
if copied {
select(&mut s, &[2], &[]);
let _ = s.execute("edit.copy", &json!({}));
} else {
select(&mut s, &[1], &[0]);
ex(&mut s, "text.create", json!({"text": "t"}));
ex(&mut s, "vector.edit.begin", json!({"id": id}));
select(&mut s, &[1], &[0]);
}
let before = state(&s, id);
match s.execute(cmd, j) {
Err(EngineError::Internal { cmd, msg }) => panic!("{cmd} panicked on {j}: {msg}"),
Err(_) => {
if cmd != "edit.pasteOver" && cmd != "edit.pasteReplace" {
assert_eq!(state(&s, id), before, "{cmd} {j} was refused but changed something");
}
}
// Accepted: whatever it made has finite coordinates.
Ok(_) => {
if let Some(n) = crate::vectoredit::network(s.doc().unwrap(), id) {
assert!(finite(n), "{cmd} {j} left non-finite coordinates");
}
}
}
}
}
}
}
#[test]
fn a_huge_paste_position_is_refused_or_stays_finite() {
let mut s = Session::new();
let id = path(&mut s);
select(&mut s, &[], &[0]);
ex(&mut s, "edit.copy", json!({}));
let before = state(&s, id);
match s.execute("edit.paste", &json!({"x": 1e308, "y": 1e308})) {
Err(e) => {
assert!(!matches!(e, EngineError::Internal { .. }), "{e}");
assert_eq!(state(&s, id), before, "a refused paste changed something");
}
// It is accepted here: 1e308 plus a few hundred is still finite, so the points are too.
Ok(_) => assert!(finite(&net(&s, id)), "the points are finite"),
}
}
#[test]
fn cut_with_only_lone_points_is_refused_like_copy_and_changes_nothing() {
let mut s = Session::new();
let id = path(&mut s);
// A point no segment touches.
crate::vectoredit::edit(s.doc_mut().unwrap(), id, |n| n.vertices.push(figcraft_doc::Vertex::new(50.0, 50.0))).unwrap();
// Something on the clipboard first, then only the lone point selected.
select(&mut s, &[0], &[]);
ex(&mut s, "edit.copy", json!({}));
select(&mut s, &[4], &[]);
let before = state(&s, id);
for cmd in ["edit.copy", "edit.cut"] {
let err = s.execute(cmd, &json!({})).expect_err(cmd);
assert!(matches!(err, EngineError::Disabled(..)), "{cmd}: {err}");
assert_eq!(state(&s, id), before, "{cmd} changed something");
}
// Delete is the way to remove it.
ex(&mut s, "vector.edit.delete", json!({}));
assert_eq!(net(&s, id).vertices.len(), 4);
}
#[test]
fn only_one_of_x_and_y_is_refused_and_pastes_nothing() {
let mut s = Session::new();
let id = path(&mut s);
select(&mut s, &[], &[0]);
ex(&mut s, "edit.copy", json!({}));
let before = state(&s, id);
for p in [json!({"x": 10}), json!({"y": 10})] {
let err = s.execute("edit.paste", &p).expect_err("one coordinate");
assert!(err.to_string().contains("give both x and y, or neither"), "{err}");
assert_eq!(state(&s, id), before);
}
ex(&mut s, "edit.paste", json!({"x": 10, "y": 10}));
}
#[test]
fn a_partial_copy_has_an_svg_for_the_system_clipboard() {
let mut s = Session::new();
let r = ex(&mut s, "shape.rectangle", json!({"x": 100, "y": 100, "width": 80, "height": 60}));
assert!(crate::cmd::edit::clipboard_svg(&s).is_none(), "nothing copied yet");
ex(&mut s, "vector.edit.begin", json!({"id": id_of(&r)}));
select(&mut s, &[], &[0, 1, 2, 3]);
ex(&mut s, "edit.copy", json!({}));
let svg = crate::cmd::edit::clipboard_svg(&s).expect("svg");
assert!(svg.contains("<svg") && svg.contains("<path"), "{svg}");
}
#[test]
fn cutting_a_selected_region_deletes_exactly_the_segments_it_copies_and_keeps_a_tail() {
let mut s = Session::new();
let r = ex(&mut s, "vector.create", json!({"points": [[100, 100], [200, 100], [150, 200]], "closed": true}));
let id = id_of(&r);
// A tail: one open segment from the first corner to a new point.
crate::vectoredit::edit(s.doc_mut().unwrap(), id, |n| {
n.vertices.push(figcraft_doc::Vertex::new(20.0, 20.0));
let tail = n.vertices.len() - 1;
crate::vectoredit::ops::connect(n, 0, tail, (0.0, 0.0), (0.0, 0.0));
})
.unwrap();
assert_eq!((net(&s, id).segments.len(), net(&s, id).regions.len()), (4, 1));
ex(&mut s, "vector.edit.begin", json!({"id": id}));
ex(&mut s, "vector.edit.select", json!({"regions": [0]}));
ex(&mut s, "edit.cut", json!({}));
let n = net(&s, id);
assert_eq!(n.segments.len(), 1, "only the tail is left");
assert!(n.regions.is_empty());
leave(&mut s);
ex(&mut s, "edit.paste", json!({}));
let new = *layers(&s).last().unwrap();
assert_eq!(net(&s, new).segments.len(), 3, "the copy holds the region's three segments, not the tail");
}
+14 -3
View File
@@ -197,13 +197,24 @@ pub fn invoke(app: &mut FigApp, id: &str) {
let ids = app.session.selection();
// For other apps: text layers' text, other layers as SVG; taken before a cut removes them.
let doc = app.session.doc().ok();
let mirror = doc.and_then(|d| text_layers_text(d, &ids).or_else(|| figcraft_svg_export(d, &ids)));
app.run(id, json!({}));
// Points or segments selected in vector edit mode copy just those: the whole vector's
// SVG would misdescribe them.
let partial = app
.session
.ds()
.ok()
.and_then(|d| d.vector_edit.as_ref())
.is_some_and(|ve| ve.parts().iter().any(|p| !p.vertices.is_empty() || !p.segments.is_empty()));
let mirror = doc.filter(|_| !partial).and_then(|d| text_layers_text(d, &ids).or_else(|| figcraft_svg_export(d, &ids)));
let ran = app.run(id, json!({}));
// A partial copy put temporary layers on the engine clipboard; mirror their SVG.
let refused = partial && ran.is_none();
let mirror = if partial { ran.and_then(|_| figcraft_engine::cmd::edit::clipboard_svg(&app.session)) } else { mirror };
// Mirror to the system clipboard (text or SVG, else a marker) and remember it, so a
// later paste knows whether the clipboard still holds our layers. Control-channel
// gestures keep the engine's copy but never replace the user's clipboard; `run` then
// invalidates the old mirror because it no longer describes the engine clipboard.
if !app.control_synthetic {
if !app.control_synthetic && !refused {
let text = mirror.unwrap_or_else(|| format!("figcraft://clipboard/{}", ids.len()));
app.clipboard_set_text(&text);
app.ui.last_copy = Some(text);
+9 -4
View File
@@ -178,7 +178,7 @@ pub(crate) fn handle_input(
let props = ctx.input(|i| i.modifiers.alt);
crate::menus::invoke(app, if props { "edit.copyProperties" } else { "edit.copy" });
}
Event::Cut => cut(app),
Event::Cut => cut(app, ctx.input(|i| i.modifiers)),
Event::Paste(text) if !paste.drop_text => crate::menus::paste(app, Some(text), None),
Event::Key { key, physical_key, pressed: true, modifiers, repeat }
if key_event(app, *key, *physical_key, *modifiers, *repeat, now, ctx) =>
@@ -192,13 +192,18 @@ pub(crate) fn handle_input(
/// Cut: usually the layers. Windows sends Shift+Delete (remove the points and heal the path)
/// as a cut command and never as a key, so in vector edit mode the selection is points: they are
/// removed, a heal joining the path through them.
fn cut(app: &mut FigApp) {
/// removed, a heal joining the path through them. Ctrl/Cmd+X is the real Cut: with points or
/// segments selected the engine copies them and removes them, staying in edit mode (#1875).
fn cut(app: &mut FigApp, modifiers: egui::Modifiers) {
let Some(ve) = app.session.ds().ok().and_then(|d| d.vector_edit.clone()) else {
crate::menus::invoke(app, "edit.cut");
return;
};
if ve.parts().iter().any(|p| !p.vertices.is_empty()) {
if modifiers.command {
if ve.parts().iter().any(|p| !p.vertices.is_empty() || !p.segments.is_empty()) {
crate::menus::invoke(app, "edit.cut");
}
} else if ve.parts().iter().any(|p| !p.vertices.is_empty()) {
app.run("vector.deleteHeal", json!({}));
} else if ve.parts().iter().any(|p| !p.segments.is_empty()) {
app.run("vector.edit.delete", json!({}));
+12
View File
@@ -49,6 +49,18 @@ fn a_cut_command_in_edit_mode_takes_the_selected_points_not_the_layer() {
assert_eq!(net(&u, path).vertices.len(), 2, "it takes the selected point");
}
#[test]
fn ctrl_x_on_selected_segments_puts_them_on_the_clipboard_and_keeps_the_layer() {
let mut u = Ui::new(Session::new());
let path = abc(&mut u);
u.app.session.execute("vector.edit.select", &json!({"segments": [0]})).unwrap();
u.frame_with(vec![Event::Cut], Modifiers::COMMAND);
assert!(u.app.session.doc().unwrap().node(path).is_some(), "Ctrl+X keeps the layer");
assert_eq!(net(&u, path).segments.len(), 1, "the segment is gone");
assert_eq!(u.app.session.clipboard.nodes.len(), 1, "and on the clipboard");
assert!(u.app.session.ds().unwrap().vector_edit.is_some(), "edit mode stays on");
}
#[test]
fn a_cut_command_in_edit_mode_with_nothing_selected_keeps_the_layer() {
let mut u = Ui::new(Session::new());
+3 -2
View File
File diff suppressed because one or more lines are too long