mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-31 13:17:19 -06:00
Merge pull request #3479 from mockydevs/fix/drag-artwork-across-tabs
Support dragging editable artwork between document tabs
This commit is contained in:
@@ -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))),
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user