Merge pull request #3479 from mockydevs/fix/drag-artwork-across-tabs

Support dragging editable artwork between document tabs
This commit is contained in:
BFlat
2026-10-22 08:43:28 +07:00
committed by GitHub
38 changed files with 1012 additions and 12 deletions
+56 -3
View File
@@ -11,6 +11,7 @@ use super::clipboard::SwatchChoices;
use super::*;
use crate::{Clipboard, EngineError, inspect};
/// Register editing commands, including document-to-document artwork drops and their parameter contracts.
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(
@@ -73,6 +74,15 @@ pub fn specs() -> Vec<CommandSpec> {
has_doc,
global_rulers
),
cmd!(
"edit.dropSelection",
"Drop Artwork",
[],
None,
"{sourceDocument: uid, ids: [id…], grab: [x,y], at: [x,y]} copy 1–10000 editable source objects from an open document to the active document, preserving the grab point and clipboard, as one undo step",
has_doc,
drop_selection
),
cmd!("edit.undo", "Undo", ["Edit"], Some("Cmd+Z"), "{}", can_undo, undo),
cmd!("edit.redo", "Redo", ["Edit"], Some("Cmd+Shift+Z"), "{}", can_redo, redo),
cmd!(
@@ -571,6 +581,40 @@ fn clear(s: &mut Session, p: &Value) -> Result<Value> {
ok()
}
/// Document ids scope node ids: two tabs may contain objects with the same node id.
fn drop_selection(s: &mut Session, p: &Value) -> Result<Value> {
const CMD: &str = "edit.dropSelection";
let key = "sourceDocument";
let uid = p[key].as_u64().ok_or_else(|| bad(CMD, format!("missing integer `{key}`")))?;
let point = |key: &str| finite_numbers::<2>(&p[key]).ok_or_else(|| bad(CMD, format!("{key} must be [x, y] with finite coordinates")));
let grab = point("grab")?;
let at = point("at")?;
let source = s.documents().iter().find(|d| d.uid == uid).ok_or_else(|| bad(CMD, "that document is no longer open"))?;
let key = "ids";
let values = p[key].as_array().ok_or_else(|| bad(CMD, format!("{key} must be an array of non-negative integer object ids")))?;
if values.is_empty() || values.len() > 10000 {
return Err(bad(CMD, "ids must contain 1–10000 object ids"));
}
let ids = values
.iter()
.map(|value| value.as_u64().map(NodeId).ok_or_else(|| bad(CMD, format!("invalid object id {value}: expected a non-negative integer"))))
.collect::<Result<Vec<_>>>()?;
// Validate the entire batch in one walk; node ids are local to the source document.
let paths = source.doc.index_paths(ids.iter().copied());
if let Some(id) = ids.iter().find(|id| !paths.contains_key(id)) {
return Err(EngineError::NoNode(*id));
}
let clip = Clipboard::copy(source, &roots_of(&source.doc, ids));
let bounds = clip.bounds().ok_or_else(|| bad(CMD, "the art has no bounds"))?;
let center = bounds.center() + Vec2::new(at[0] - grab[0], at[1] - grab[1]);
if !center.x.is_finite() || !center.y.is_finite() {
return Err(bad(CMD, "result would exceed the canvas (coordinates out of range)"));
}
// A drop preserves the source colours even when the flyer has same-named swatches.
let choices = SwatchChoices::parse(PasteMode::Drag.label(), &json!({"swatchConflict": "add"}))?;
paste_clip(s, &json!({"center": [center.x, center.y]}), PasteMode::Drag, &clip, &choices)
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum PasteMode {
Offset,
@@ -580,9 +624,11 @@ enum PasteMode {
AllArtboards,
/// A graphic from the Libraries panel: centred where it is placed, as Paste centres.
Library,
Drag,
}
impl PasteMode {
/// Return the user-visible action label for this insertion mode.
fn label(self) -> &'static str {
match self {
PasteMode::Front => "Paste in Front",
@@ -591,6 +637,7 @@ impl PasteMode {
PasteMode::AllArtboards => "Paste on All Artboards",
PasteMode::Offset => "Paste",
PasteMode::Library => "Place from Library",
PasteMode::Drag => "Drop Artwork",
}
}
}
@@ -623,6 +670,7 @@ fn onto_artboard(doc: &Document, p: &Value, clip: &Clipboard) -> Affine {
}
}
/// Insert clipboard resources atomically in the active editable context using the requested placement mode.
fn paste_clip(s: &mut Session, p: &Value, mode: PasteMode, clip: &Clipboard, choices: &SwatchChoices) -> Result<Value> {
let off = s.prefs.paste_offset;
let st = s.doc()?;
@@ -640,7 +688,10 @@ fn paste_clip(s: &mut Session, p: &Value, mode: PasteMode, clip: &Clipboard, cho
.map(|all| (&clip.artboards, if mode == PasteMode::Offset { super::panelcmds::beside_artboards(&st.doc, all) } else { Vec2::ZERO }));
// Paste Remembers Layers (not while isolating a group: pastes stay in it); an artboard's art
// always goes back into its layers.
let remember = (st.doc.paste_remembers_layers || boards.is_some()) && parent.is_some_and(|p| st.doc.node(p).is_some_and(Node::is_layer));
// A drop goes into the current editable insertion context, not a same-named source layer.
let remember = mode != PasteMode::Drag
&& (st.doc.paste_remembers_layers || boards.is_some())
&& parent.is_some_and(|p| st.doc.node(p).is_some_and(Node::is_layer));
// Front/back: relative to the selection (top-most / bottom-most selected object); with
// nothing selected, the top / bottom of the current layer.
let anchor = match mode {
@@ -653,7 +704,7 @@ fn paste_clip(s: &mut Session, p: &Value, mode: PasteMode, clip: &Clipboard, cho
};
let placements: Vec<Affine> = match (mode, boards) {
(_, Some((_, dv))) => vec![Affine::translate(dv)],
(PasteMode::Offset | PasteMode::Library, None) => vec![match point_param(p, "center") {
(PasteMode::Offset | PasteMode::Library | PasteMode::Drag, None) => vec![match point_param(p, "center") {
Some(c) => clip.bounds().map_or(Affine::IDENTITY, |b| Affine::translate(c - b.center())),
None => Affine::translate((f64_or(p, "dx", off), f64_or(p, "dy", off))),
}],
@@ -680,7 +731,9 @@ fn paste_clip(s: &mut Session, p: &Value, mode: PasteMode, clip: &Clipboard, cho
let mut c = d.reid(n);
imported.apply(&mut c);
if xf != Affine::IDENTITY {
c.transform(xf, false);
// A cross-document drag carries the graphic's appearance with it,
// including the pattern phase, independently of transform-tool settings.
c.transform(xf, vectorcraft_doc::Scaling { patterns: mode == PasteMode::Drag, ..Default::default() });
}
let layer = match clip.source_layers.get(k) {
Some(Some(name)) if remember => Some(*layers.entry(name).or_insert_with(|| layer_named(d, name))),
+1
View File
@@ -252,6 +252,7 @@ fn document_base(s: &Session) -> Value {
let Some(st) = s.active() else { return Value::Null };
let d = &st.doc;
json!({
"uid": st.uid,
"title": st.title(),
"path": st.path,
"dirty": st.is_dirty(),
+135 -3
View File
@@ -83,8 +83,20 @@ fn renamed(v: &Value) -> Vec<(String, String, String)> {
// ---------- M4.22: resources ----------
/// Verify ordinary paste retains image, symbol and pattern resources and their rendered appearance.
#[test]
fn an_image_symbol_and_pattern_rect_pasted_into_a_new_document_render_the_same() {
resource_transfer(false);
}
/// Verify a translated artwork drop preserves editable resources, pattern phase, opacity and clipboard contents.
#[test]
fn dropped_artwork_preserves_editable_images_symbols_patterns_and_clipboard() {
resource_transfer(true);
}
/// Exercise paste or tab-drop resource transfer and compare source and destination rendering.
fn resource_transfer(drag: bool) {
let mut s = session();
// Pattern "Dots" painting a rectangle.
let dot = rect(&mut s, 0.0, 0.0, 10.0, 10.0);
@@ -107,21 +119,37 @@ fn an_image_symbol_and_pattern_rect_pasted_into_a_new_document_render_the_same()
sel(&mut s, &[e]);
s.execute("object.rasterize", &json!({"ppi": 72})).unwrap();
s.execute("select.all", &json!({})).unwrap();
s.execute("transparency.set", &json!({"opacity":45})).unwrap();
s.execute("edit.copy", &json!({})).unwrap();
assert_eq!(s.clipboard.nodes.len(), 3);
assert_eq!((s.clipboard.symbols.len(), s.clipboard.patterns.len(), s.clipboard.images.len()), (1, 1, 1));
let before = render(&s);
let source = s.doc().unwrap().clone();
let ids = source.selection.objects.clone();
new_doc(&mut s);
assert!(before != render(&s));
let r = s.execute("edit.pasteInPlace", &json!({})).unwrap();
let r = if drag {
s.clipboard = Clipboard::default();
s.execute("edit.dropSelection", &json!({"sourceDocument": source.uid, "ids": ids, "grab": [0,0], "at": [13,17]})).unwrap()
} else {
s.execute("edit.pasteInPlace", &json!({})).unwrap()
};
if drag {
assert_eq!(s.clipboard, Clipboard::default());
}
assert_eq!(s.documents()[0].doc, source.doc);
// The pattern (its swatch comes along), the symbol and the image blob.
assert_eq!((r["added"].as_u64(), r["merged"].as_u64()), (Some(3), Some(0)), "{r}");
let d = doc(&s);
assert!(d.pattern("Dots").is_some() && d.swatch("Dots").is_some());
assert!(d.symbols.iter().any(|x| x.name == "Badge"));
assert_eq!(d.images.len(), 1);
assert!(before == render(&s), "the pasted art renders as it did in its own document");
let after = if drag {
vectorcraft_render::Renderer::new().render_region(doc(&s), Rect::new(13.0, 17.0, 413.0, 317.0), 1.0, true).pixels
} else {
render(&s)
};
assert!(before == after, "the transferred art, its transparency and pattern alignment render as in the source");
}
#[test]
@@ -649,3 +677,107 @@ fn paste_remembers_layers_survives_save_and_old_files_load_without_it() {
assert!(v.get("paste_remembers_layers").is_none(), "off is not written");
assert!(!vectorcraft_format::load(v.to_string().as_bytes()).unwrap().paste_remembers_layers);
}
/// Verify document-scoped object IDs, grab-point placement and one destination Undo/Redo step.
#[test]
fn artwork_drop_uses_source_ids_hotspot_and_one_target_undo_step() {
let mut s = session();
let r = swatched_rect(&mut s, "Brand", "#ff0000");
let source = s.doc().unwrap().clone();
new_doc(&mut s);
let other = swatched_rect(&mut s, "Brand", "#0000ff");
assert_eq!(r, other, "node ids collide across documents");
copy(&mut s, &[other]);
let clip = s.clipboard.clone();
let target = s.doc().unwrap().clone();
let result = s.execute("edit.dropSelection", &json!({"sourceDocument": source.uid, "ids": [r, r], "grab": [25,30], "at": [175,130]})).unwrap();
let ids = ids_of(&result);
assert_eq!(ids.len(), 1);
assert_eq!(fill(&s, ids[0]), linked(hex("#ff0000"), "Brand 2", 1.0));
assert_eq!(fill(&s, other), linked(hex("#0000ff"), "Brand", 1.0));
assert_eq!(node(&s, ids[0]).geometric_bounds().unwrap(), Rect::new(170.0, 120.0, 210.0, 160.0));
assert_eq!(s.doc().unwrap().history.undo.len(), target.history.undo.len() + 1);
assert_eq!(s.clipboard, clip);
assert_eq!(s.documents()[0].doc, source.doc);
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(s.doc().unwrap().doc, target.doc);
s.execute("edit.redo", &json!({})).unwrap();
assert_eq!(s.doc().unwrap().selection.objects, ids);
}
/// Verify malformed, oversized, stale and overflowing drop requests leave document and clipboard state intact.
#[test]
fn invalid_artwork_drop_is_atomic() {
let mut s = session();
let r = rect(&mut s, 10.0, 10.0, 40.0, 40.0);
let uid = s.execute("document.inspect", &json!({})).unwrap()["uid"].as_u64().unwrap();
new_doc(&mut s);
let target = s.doc().unwrap().clone();
let clipboard = s.clipboard.clone();
for patch in [
json!({"sourceDocument": u64::MAX}),
json!({"sourceDocument": -1}),
json!({"sourceDocument": 1.5}),
json!({"sourceDocument": null}),
json!({"ids": vec![r;10001]}),
json!({"ids": null}),
json!({"ids": [r,-1]}),
json!({"ids": [r,1.5]}),
json!({"grab": [0,0,0]}),
json!({"grab": ["0",0]}),
json!({"ids": []}),
json!({"ids": [r, 9999999]}),
json!({"ids": ["1"]}),
json!({"grab": [0]}),
json!({"at": [null, 0]}),
json!({"at": [1e308, 0]}),
] {
let mut p = json!({"sourceDocument": uid, "ids": [r], "grab": [0,0], "at": [50,60]});
p.as_object_mut().unwrap().extend(patch.as_object().unwrap().clone());
assert!(s.execute("edit.dropSelection", &p).is_err(), "{p}");
assert_eq!(s.doc().unwrap().doc, target.doc);
assert_eq!(s.doc().unwrap().history.undo.len(), target.history.undo.len());
assert_eq!(s.doc().unwrap().selection, target.selection);
assert_eq!(s.clipboard, clipboard);
}
s.execute("file.close", &json!({"index":0})).unwrap();
assert!(s.execute("edit.dropSelection", &json!({"sourceDocument": uid,"ids":[r],"grab":[0,0],"at":[50,60]})).is_err());
assert_eq!(s.doc().unwrap().doc, target.doc);
}
/// Verify refusing a locked insertion context preserves the target document and its undo history.
#[test]
fn artwork_drop_into_a_locked_destination_is_atomic() {
let mut s = session();
let r = swatched_rect(&mut s, "Brand", "#ff0000");
let uid = s.doc().unwrap().uid;
new_doc(&mut s);
let layer = s.doc().unwrap().doc.layers[0].id;
s.execute("layer.setProps", &json!({"id":layer,"locked":true})).unwrap();
let target = s.doc().unwrap().clone();
assert!(s.execute("edit.dropSelection", &json!({"sourceDocument":uid,"ids":[r],"grab":[0,0],"at":[100,100]})).is_err());
assert_eq!(s.doc().unwrap().doc, target.doc);
assert_eq!(s.doc().unwrap().selection, target.selection);
assert_eq!(s.doc().unwrap().history.undo.len(), target.history.undo.len());
}
/// Verify tab drops use the editable current layer despite Paste Remembers Layers and source-layer name collisions.
#[test]
fn artwork_drop_uses_the_current_layer_even_when_paste_remembers_hidden_or_locked_source_layers() {
for hidden in [true, false] {
let mut s = session();
let r = rect(&mut s, 10.0, 10.0, 40.0, 40.0);
let uid = s.doc().unwrap().uid;
new_doc(&mut s);
let blocked = doc(&s).layers[0].id;
s.execute("layer.setProps", &json!({"id":blocked,"visible":!hidden,"locked":!hidden})).unwrap();
let current = id_of(&s.execute("layer.new", &json!({"name":"Flyer"})).unwrap());
s.execute("layer.pasteRemembersLayers", &json!({"on":true})).unwrap();
let blocked_before = node(&s, blocked);
let placed = ids_of(&s.execute("edit.dropSelection", &json!({"sourceDocument":uid,"ids":[r],"grab":[0,0],"at":[100,100]})).unwrap());
assert_eq!(doc(&s).parent_of(placed[0]), Some(current));
assert_eq!(node(&s, blocked), blocked_before);
assert_eq!(doc(&s).layers.len(), 2);
assert!(doc(&s).paste_remembers_layers, "the preference still applies to ordinary Paste");
}
}