mirror of
https://github.com/storytold/figcraft.git
synced 2026-11-13 02:34:33 +00:00
Merge main into #2200
This commit is contained in:
@@ -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| {
|
||||
|
||||
@@ -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
|
||||
),
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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!({}));
|
||||
|
||||
@@ -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
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user