M1: support dragging editable artwork between document tabs

Activate visible tabs after a delayed Selection drag and copy artwork through a document-scoped command without changing the clipboard or source. Cancel stale transfers, preserve pattern alignment and transparency, and insert into the editable destination context. Cover pointer gestures, resource rendering, input bounds and locked layers; translate the new labels and messages.
This commit is contained in:
Don Omondi
2026-10-22 00:48:57 +03:00
parent 0ac1f133b0
commit 84e76c1d70
36 changed files with 932 additions and 15 deletions
+53 -3
View File
@@ -73,6 +73,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 +580,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,6 +623,7 @@ enum PasteMode {
AllArtboards,
/// A graphic from the Libraries panel: centred where it is placed, as Paste centres.
Library,
Drag,
}
impl PasteMode {
@@ -591,6 +635,7 @@ impl PasteMode {
PasteMode::AllArtboards => "Paste on All Artboards",
PasteMode::Offset => "Paste",
PasteMode::Library => "Place from Library",
PasteMode::Drag => "Drop Artwork",
}
}
}
@@ -640,7 +685,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 +701,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 +728,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(),
+128 -3
View File
@@ -85,6 +85,15 @@ fn renamed(v: &Value) -> Vec<(String, String, String)> {
#[test]
fn an_image_symbol_and_pattern_rect_pasted_into_a_new_document_render_the_same() {
resource_transfer(false);
}
#[test]
fn dropped_artwork_preserves_editable_images_symbols_patterns_and_clipboard() {
resource_transfer(true);
}
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 +116,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 +674,103 @@ 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);
}
#[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);
}
#[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);
}
#[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());
}
#[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");
}
}
+12 -2
View File
@@ -101,6 +101,13 @@ enum Drag {
Present,
}
pub(crate) fn end_document_drag(ctx: &egui::Context) {
ctx.data_mut(|d| {
d.remove::<Drag>(drag_id());
d.remove::<Pos2>(drag_pos_id());
});
}
fn drag_id() -> egui::Id {
egui::Id::new("canvas-drag")
}
@@ -114,7 +121,7 @@ fn drag_layer_id() -> egui::Id {
}
/// Whether `p` is over the canvas itself, not a panel or a floating window over it.
fn on_canvas(ui: &Ui, resp: &egui::Response, p: Pos2) -> bool {
ui.clip_rect().contains(p) && ui.ctx().layer_id_at(p).is_none_or(|l| l == resp.layer_id)
resp.rect.contains(p) && ui.clip_rect().contains(p) && ui.ctx().layer_id_at(p).is_none_or(|l| l == resp.layer_id)
}
/// The document point last sent to the tool as a drag during [`Drag::Tool`].
fn tool_drag_doc_id() -> egui::Id {
@@ -274,6 +281,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
let v = *app.view().unwrap_or(&View::default());
let xf = Xf::new(rect, &v);
let origin_preview = if app.ui.view.rulers && app.ui.screen_mode < 3 { ruler_input(app, ui, full, rect, &xf) } else { None };
crate::document_drag::drop_on_canvas(app, &resp, &xf);
panel_drop(app, ui, &resp, &xf);
// In Presentation Mode a right click or a long touch opens no context menu.
if app.ui.screen_mode < 3 {
@@ -911,6 +919,7 @@ fn handle_input(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Response, rect:
// Cmd with another tool drags with the selection tool used last (the session lends it).
let ev = PointerEvent { kind: PointerKind::Down, pos: xf.to_doc(p), mods: mods(m, space), pressure: pen_pressure(ui, true) };
dispatch(app, &ev, view);
crate::document_drag::begin(app, ui.ctx(), ev.pos);
Drag::Tool
};
ui.data_mut(|dd| {
@@ -1476,7 +1485,7 @@ fn drop_press(app: &mut VectorcraftApp, ui: &Ui, view: vectorcraft_engine::ViewI
/// canvas, and a panel it is released on takes it (the Graphic Styles panel makes a style of it).
/// Back on the canvas, [`Drag::Art`] takes the move up again where the pointer is (#2826).
fn drag_art_out(app: &VectorcraftApp, ui: &Ui, resp: &egui::Response, p: Pos2) -> bool {
let off = !ui.clip_rect().contains(p) || ui.ctx().layer_id_at(p).is_some_and(|l| l != resp.layer_id);
let off = !on_canvas(ui, resp, p);
let Some(st) = app.session.active().filter(|_| off && app.session.tool_id() == "selection") else { return false };
// The Selection tool's interaction while it moves (or Alt-copies) the selection.
if !st.interaction.as_ref().is_some_and(|i| i.label == "Move" || i.label == "Copy") {
@@ -1491,6 +1500,7 @@ fn drag_art_out(app: &VectorcraftApp, ui: &Ui, resp: &egui::Response, p: Pos2) -
/// panel that takes the art ([`widgets::PanelDrag::Art`]) takes it where it was, and no move is
/// left open (#2826). The art stays where it last moved while the pointer is over the panel.
pub(crate) fn release_over_panel(app: &mut VectorcraftApp, ctx: &egui::Context) {
crate::document_drag::cleanup(app, ctx);
if !ctx.input(|i| i.pointer.primary_released()) {
return;
}
+3
View File
@@ -448,6 +448,7 @@ pub fn doc_tabs(app: &mut VectorcraftApp, ui: &mut Ui) {
let mut x = strip.left();
let mut activate = None;
let mut close = None;
let mut hovering_art = false;
for (i, ((_, galley), w)) in tabs.iter().zip(&widths).enumerate().skip(first) {
if x + w > strip.left() + room && Some(i) != active {
break;
@@ -468,6 +469,7 @@ pub fn doc_tabs(app: &mut VectorcraftApp, ui: &mut Ui) {
let xresp = ui.interact(xr.expand(3.0), ui.id().with(("tabx", i)), Sense::click());
icons::paint(ui, "x", xr, if xresp.hovered() { t.text_strong } else { t.text });
ui.painter().galley(egui::pos2(title_left, r.center().y - galley.size().y / 2.0), galley.clone(), t.text);
hovering_art |= crate::document_drag::hover_tab(app, &resp, i, xr.expand(3.0));
if xresp.clicked() {
close = Some(i);
} else if resp.clicked() {
@@ -475,6 +477,7 @@ pub fn doc_tabs(app: &mut VectorcraftApp, ui: &mut Ui) {
}
x += w;
}
crate::document_drag::finish_tabs(ui.ctx(), hovering_art);
// The » button: every open document, the active one checked.
if overflow {
let more = egui::Rect::from_min_max(egui::pos2(strip.right() - MORE, strip.top()), egui::pos2(strip.right(), strip.bottom() - 1.0));
+189
View File
@@ -0,0 +1,189 @@
//! A selection dragged across document tabs remains editable and leaves its source intact.
use egui::{Context, Id, Response};
use serde_json::json;
use vectorcraft_doc::NodeId;
use vectorcraft_geom::Point;
use crate::{VectorcraftApp, canvas, widgets};
const HOVER_SECONDS: f64 = 0.4;
#[derive(Clone)]
struct Origin {
uid: u64,
grab: Point,
}
#[derive(Clone)]
struct Artwork {
origin: Origin,
ids: Vec<NodeId>,
revision: u64,
target: u64,
}
#[derive(Clone)]
struct Hover {
uid: u64,
since: f64,
}
fn origin_id() -> Id {
Id::new("document-drag-origin")
}
fn hover_id() -> Id {
Id::new("document-drag-hover")
}
fn activated_at_id() -> Id {
Id::new("document-drag-activated-at")
}
pub(crate) fn begin(app: &VectorcraftApp, ctx: &Context, grab: Point) {
ctx.data_mut(|d| {
d.remove::<Origin>(origin_id());
d.remove::<Hover>(hover_id());
d.remove::<egui::Pos2>(activated_at_id());
if app.session.tool_id() == "selection"
&& let Some(st) = app.session.active()
{
d.insert_temp(origin_id(), Origin { uid: st.uid, grab });
}
});
}
/// Called before panels. Cancelled or abandoned transfers must never reach a later mouse-up.
pub(crate) fn cleanup(app: &VectorcraftApp, ctx: &Context) {
// Activating a tab can scroll the strip or change title widths. A tab moving under a
// stationary pointer must not start another hover; actual pointer movement rearms it.
let at = ctx.input(|i| i.pointer.interact_pos());
ctx.data_mut(|d| {
if d.get_temp::<egui::Pos2>(activated_at_id()) != at {
d.remove::<egui::Pos2>(activated_at_id());
}
});
if let Some(art) = egui::DragAndDrop::payload::<Artwork>(ctx) {
let valid = app.session.documents().iter().any(|d| d.uid == art.origin.uid && d.revision == art.revision)
&& app.session.active().is_some_and(|d| d.uid == art.target);
let cancel = ctx.input(|i| {
!i.focused
|| i.key_pressed(egui::Key::Escape)
|| (!i.pointer.primary_down() && !i.pointer.primary_released())
|| i.multi_touch().is_some()
|| i.events.iter().any(|e| matches!(e, egui::Event::Touch { phase: egui::TouchPhase::Cancel, .. }))
});
let outside_release =
ctx.input(|i| i.pointer.primary_released() && !app.canvas_rect.is_some_and(|r| i.pointer.interact_pos().is_some_and(|p| r.contains(p))));
if !valid || cancel || outside_release || app.ui.dialog.is_some() {
egui::DragAndDrop::clear_payload(ctx);
}
}
if !ctx.input(|i| i.pointer.primary_down()) {
ctx.data_mut(|d| {
d.remove::<Origin>(origin_id());
d.remove::<Hover>(hover_id());
d.remove::<egui::Pos2>(activated_at_id());
});
}
}
/// Hover uses `contains_pointer`: the source canvas still owns egui's dragged widget.
pub(crate) fn hover_tab(app: &mut VectorcraftApp, resp: &Response, index: usize, close: egui::Rect) -> bool {
let ctx = &resp.ctx;
let eligible = resp.contains_pointer()
&& ctx
.input(|i| i.pointer.primary_down() && !i.key_pressed(egui::Key::Escape) && i.pointer.interact_pos().is_some_and(|p| !close.contains(p)))
&& app.ui.dialog.is_none();
if !eligible || app.session.active_index() == Some(index) || ctx.data(|d| d.get_temp::<egui::Pos2>(activated_at_id()).is_some()) {
return false;
}
let transfer = egui::DragAndDrop::has_payload_of_type::<Artwork>(ctx);
let art = egui::DragAndDrop::payload::<widgets::PanelDrag>(ctx).is_some_and(|p| matches!(*p, widgets::PanelDrag::Art(_)));
if !transfer && !art {
return false;
}
let Some(uid) = app.session.documents().get(index).map(|d| d.uid) else {
return false;
};
let now = ctx.input(|i| i.time);
let hover = ctx.data(|d| d.get_temp::<Hover>(hover_id())).filter(|h| h.uid == uid).unwrap_or(Hover { uid, since: now });
ctx.data_mut(|d| d.insert_temp(hover_id(), hover.clone()));
let remaining = HOVER_SECONDS - (now - hover.since);
if remaining > 0.0 {
ctx.request_repaint_after(std::time::Duration::try_from_secs_f64(remaining).unwrap_or(std::time::Duration::from_millis(400)));
return true;
}
if !transfer {
let Some(origin) = ctx.data(|d| d.get_temp::<Origin>(origin_id())) else {
return true;
};
if !app
.session
.active()
.is_some_and(|d| d.uid == origin.uid && d.interaction.as_ref().is_some_and(|i| i.label == "Move" || i.label == "Copy"))
{
return true;
}
if let Err(e) = app.session.cancel_interaction() {
app.status(e.to_string());
return true;
}
// Alt-drag previews use temporary duplicate ids; only the restored originals travel.
let ids = app.session.active().map(|d| d.selection.objects.clone()).unwrap_or_default();
if ids.is_empty() {
return true;
}
canvas::end_document_drag(ctx);
let revision = app.session.active().map(|d| d.revision).unwrap_or_default();
egui::DragAndDrop::set_payload(ctx, Artwork { origin, ids, revision, target: uid });
}
if transfer && let Some(art) = egui::DragAndDrop::payload::<Artwork>(ctx) {
let mut art = (*art).clone();
art.target = uid;
egui::DragAndDrop::set_payload(ctx, art);
}
app.ui.home = None;
app.session.set_active(index);
ctx.request_repaint();
let at = ctx.input(|i| i.pointer.interact_pos());
ctx.data_mut(|d| {
d.remove::<Hover>(hover_id());
if let Some(at) = at {
d.insert_temp(activated_at_id(), at);
}
});
true
}
pub(crate) fn finish_tabs(ctx: &Context, hovering: bool) {
if !hovering {
ctx.data_mut(|d| d.remove::<Hover>(hover_id()));
}
}
pub(crate) fn drop_on_canvas(app: &mut VectorcraftApp, resp: &Response, xf: &canvas::Xf) {
let Some(payload) = egui::DragAndDrop::payload::<Artwork>(&resp.ctx) else {
return;
};
let same = app.session.active().is_some_and(|d| d.uid == payload.origin.uid);
let copy = resp.ctx.input(|i| i.modifiers.alt);
if resp.contains_pointer() {
resp.ctx.set_cursor_icon(if same && !copy { egui::CursorIcon::Move } else { egui::CursorIcon::Copy });
}
if let Some(art) = resp.dnd_release_payload::<Artwork>()
&& let Some(at) = resp.ctx.input(|i| i.pointer.interact_pos()).map(|p| xf.to_doc(p))
{
let result = if same {
// Back in the source tab, resume the ordinary move (Alt copies).
app.run("object.move", json!({"ids": art.ids, "dx": at.x-art.origin.grab.x, "dy": at.y-art.origin.grab.y, "copy": copy}))
} else {
app.run(
"edit.dropSelection",
json!({"sourceDocument": art.origin.uid, "ids": art.ids, "grab": [art.origin.grab.x, art.origin.grab.y], "at": [at.x, at.y]}),
)
};
if let Err(e) = result {
app.status(e);
}
}
}
+5
View File
@@ -5336,3 +5336,8 @@ theme Appearance Външен вид
Ruby Руби
Fresco… Фреска…
Fresco Фреска
# Artwork drag between document tabs.
Drop Artwork Поставяне на графика
@msg {key} must be [x, y] with finite coordinates {key} трябва да е [x, y] с крайни координати
@msg ids must contain 1–10000 object ids ids трябва да съдържа от 1 до 10000 идентификатора на обекти
Can't render this file because it contains an unexpected character in line 2 and column 73.
+5
View File
@@ -5379,3 +5379,8 @@ mark-style Japanese Japonské
Ruby Ruby
Fresco… Freska…
Fresco Freska
# Artwork drag between document tabs.
Drop Artwork Umístit grafiku
@msg {key} must be [x, y] with finite coordinates {key} musí být [x, y] s konečnými souřadnicemi
@msg ids must contain 1–10000 object ids ids musí obsahovat 1–10000 identifikátorů objektů
Can't render this file because it contains an unexpected character in line 2 and column 73.
+5
View File
@@ -5352,3 +5352,8 @@ swatch Bright Violet Kraftig violet
Ruby Ruby
Fresco… Fresko…
Fresco Fresko
# Artwork drag between document tabs.
Drop Artwork Slip illustration
@msg {key} must be [x, y] with finite coordinates {key} skal være [x, y] med endelige koordinater
@msg ids must contain 1–10000 object ids ids skal indeholde 1–10000 objekt-id’er
Can't render this file because it contains an unexpected character in line 2 and column 73.
+5
View File
@@ -4960,3 +4960,8 @@ effect Soft Weich
Ruby Ruby
Fresco… Fresko…
Fresco Fresko
# Artwork drag between document tabs.
Drop Artwork Grafik ablegen
@msg {key} must be [x, y] with finite coordinates {key} muss [x, y] mit endlichen Koordinaten sein
@msg ids must contain 1–10000 object ids ids muss 1–10000 Objekt-IDs enthalten
Can't render this file because it contains an unexpected character in line 2 and column 73.
+5
View File
@@ -5331,3 +5331,8 @@ effect Points Σημεία
Ruby Ruby
Fresco… Νωπογραφία…
Fresco Νωπογραφία
# Artwork drag between document tabs.
Drop Artwork Απόθεση γραφικών
@msg {key} must be [x, y] with finite coordinates Το {key} πρέπει να είναι [x, y] με πεπερασμένες συντεταγμένες
@msg ids must contain 1–10000 object ids Το ids πρέπει να περιέχει 1–10000 αναγνωριστικά αντικειμένων
Can't render this file because it contains an unexpected character in line 2039 and column 19.
+5
View File
@@ -5321,3 +5321,8 @@ mark-style Japanese Japonesas
Ruby Ruby
Fresco… Fresco…
Fresco Fresco
# Artwork drag between document tabs.
Drop Artwork Soltar ilustración
@msg {key} must be [x, y] with finite coordinates {key} debe ser [x, y] con coordenadas finitas
@msg ids must contain 1–10000 object ids ids debe contener entre 1 y 10000 identificadores de objetos
Can't render this file because it contains an unexpected character in line 2 and column 73.
+5
View File
@@ -5410,3 +5410,8 @@ swatch Bright Violet Kirkas violetti
Ruby Ruby
Fresco… Fresko…
Fresco Fresko
# Artwork drag between document tabs.
Drop Artwork Pudota kuvitus
@msg {key} must be [x, y] with finite coordinates {key} on annettava muodossa [x, y] äärellisillä koordinaateilla
@msg ids must contain 1–10000 object ids ids-arvon on sisällettävä 1–10000 objektitunnusta
Can't render this file because it contains an unexpected character in line 2 and column 73.
+5
View File
@@ -5378,3 +5378,8 @@ theme Appearance Apparence
Ruby Ruby
Fresco… Fresque…
Fresco Fresque
# Artwork drag between document tabs.
Drop Artwork Déposer l’illustration
@msg {key} must be [x, y] with finite coordinates {key} doit être [x, y] avec des coordonnées finies
@msg ids must contain 1–10000 object ids ids doit contenir de 1 à 10000 identifiants d’objets
Can't render this file because it contains an unexpected character in line 2 and column 73.
+5
View File
@@ -5365,3 +5365,8 @@ imageTrace Outline Körvonal
Ruby Ruby
Fresco… Freskó…
Fresco Freskó
# Artwork drag between document tabs.
Drop Artwork Grafika elhelyezése
@msg {key} must be [x, y] with finite coordinates A(z) {key} értékének [x, y] alakúnak kell lennie véges koordinátákkal
@msg ids must contain 1–10000 object ids Az ids értékének 1–10000 objektumazonosítót kell tartalmaznia
Can't render this file because it contains an unexpected character in line 2 and column 73.
+5
View File
@@ -5352,3 +5352,8 @@ swatch Bright Violet Violet cerah
Ruby Ruby
Fresco… Fresko…
Fresco Fresko
# Artwork drag between document tabs.
Drop Artwork Letakkan karya
@msg {key} must be [x, y] with finite coordinates {key} harus berupa [x, y] dengan koordinat berhingga
@msg ids must contain 1–10000 object ids ids harus berisi 1–10000 ID objek
Can't render this file because it contains an unexpected character in line 3810 and column 19.
+5
View File
@@ -4994,3 +4994,8 @@ lighting Light Luce
Ruby Ruby
Fresco… Affresco…
Fresco Affresco
# Artwork drag between document tabs.
Drop Artwork Rilascia illustrazione
@msg {key} must be [x, y] with finite coordinates {key} deve essere [x, y] con coordinate finite
@msg ids must contain 1–10000 object ids ids deve contenere da 1 a 10000 identificatori di oggetti
Can't render this file because it contains an unexpected character in line 2 and column 73.
+5
View File
@@ -5041,3 +5041,8 @@ lighting Light ライト
Ruby ルビ
Fresco… フレスコ…
Fresco フレスコ
# Artwork drag between document tabs.
Drop Artwork アートワークをドロップ
@msg {key} must be [x, y] with finite coordinates {key} は有限の座標を持つ [x, y] である必要があります
@msg ids must contain 1–10000 object ids ids には 1~10000 個のオブジェクト ID を指定してください
Can't render this file because it contains an unexpected character in line 2 and column 73.
+5
View File
@@ -5289,3 +5289,8 @@ imageTrace Outline კონტური
Ruby რუბი
Fresco… ფრესკა…
Fresco ფრესკა
# Artwork drag between document tabs.
Drop Artwork ნამუშევრის განთავსება
@msg {key} must be [x, y] with finite coordinates {key} უნდა იყოს [x, y] სასრული კოორდინატებით
@msg ids must contain 1–10000 object ids ids უნდა შეიცავდეს 1–10000 ობიექტის იდენტიფიკატორს
Can't render this file because it contains an unexpected character in line 2 and column 73.
+5
View File
@@ -4957,3 +4957,8 @@ lighting Light 광원
Ruby 루비
Fresco… 프레스코…
Fresco 프레스코
# Artwork drag between document tabs.
Drop Artwork 아트워크 놓기
@msg {key} must be [x, y] with finite coordinates {key}은(는) 유한한 좌표의 [x, y]여야 합니다
@msg ids must contain 1–10000 object ids ids에는 1~10000개의 오브젝트 ID가 있어야 합니다
Can't render this file because it contains an unexpected character in line 2 and column 73.
+5
View File
@@ -5394,3 +5394,8 @@ swatch Bright Violet Felviolet
Ruby Ruby
Fresco… Fresco…
Fresco Fresco
# Artwork drag between document tabs.
Drop Artwork Illustratie neerzetten
@msg {key} must be [x, y] with finite coordinates {key} moet [x, y] met eindige coördinaten zijn
@msg ids must contain 1–10000 object ids ids moet 1–10000 object-id’s bevatten
Can't render this file because it contains an unexpected character in line 2 and column 73.
+5
View File
@@ -5231,3 +5231,8 @@ mark-style Japanese Japońskie
Ruby Ruby
Fresco… Fresk…
Fresco Fresk
# Artwork drag between document tabs.
Drop Artwork Upuść kompozycję
@msg {key} must be [x, y] with finite coordinates {key} musi mieć postać [x, y] ze skończonymi współrzędnymi
@msg ids must contain 1–10000 object ids ids musi zawierać od 1 do 10000 identyfikatorów obiektów
Can't render this file because it contains an unexpected character in line 2 and column 73.
+5
View File
@@ -5296,3 +5296,8 @@ mark-style Japanese Japonesas
Ruby Ruby
Fresco… Afresco…
Fresco Afresco
# Artwork drag between document tabs.
Drop Artwork Soltar arte
@msg {key} must be [x, y] with finite coordinates {key} deve ser [x, y] com coordenadas finitas
@msg ids must contain 1–10000 object ids ids deve conter de 1 a 10000 IDs de objetos
Can't render this file because it contains an unexpected character in line 6 and column 47.
+5
View File
@@ -4990,3 +4990,8 @@ lighting Light Свет
Ruby Руби
Fresco… Фреска…
Fresco Фреска
# Artwork drag between document tabs.
Drop Artwork Поместить графику
@msg {key} must be [x, y] with finite coordinates {key} должен быть [x, y] с конечными координатами
@msg ids must contain 1–10000 object ids ids должен содержать от 1 до 10000 идентификаторов объектов
Can't render this file because it contains an unexpected character in line 2 and column 73.
+5
View File
@@ -5335,3 +5335,8 @@ theme Appearance Utseende
Ruby Ruby
Fresco… Fresk…
Fresco Fresk
# Artwork drag between document tabs.
Drop Artwork Släpp grafik
@msg {key} must be [x, y] with finite coordinates {key} måste vara [x, y] med ändliga koordinater
@msg ids must contain 1–10000 object ids ids måste innehålla 1–10000 objekt-id:n
Can't render this file because it contains an unexpected character in line 5 and column 25.
+5
View File
@@ -4974,3 +4974,8 @@ lighting Light Світло
Ruby Рубі
Fresco… Фреска…
Fresco Фреска
# Artwork drag between document tabs.
Drop Artwork Розмістити графіку
@msg {key} must be [x, y] with finite coordinates {key} має бути [x, y] зі скінченними координатами
@msg ids must contain 1–10000 object ids ids має містити від 1 до 10000 ідентифікаторів об’єктів
Can't render this file because it contains an unexpected character in line 733 and column 40.
+5
View File
@@ -4974,3 +4974,8 @@ theme Appearance Giao diện
Ruby Ruby
Fresco… Bích họa…
Fresco Bích họa
# Artwork drag between document tabs.
Drop Artwork Thả hình đồ họa
@msg {key} must be [x, y] with finite coordinates {key} phải là [x, y] với tọa độ hữu hạn
@msg ids must contain 1–10000 object ids ids phải chứa từ 1 đến 10000 mã định danh đối tượng
Can't render this file because it contains an unexpected character in line 4022 and column 10.
+5
View File
@@ -5218,3 +5218,8 @@ lighting Light 光源
Ruby 注音
Fresco… 壁画…
Fresco 壁画
# Artwork drag between document tabs.
Drop Artwork 放置图稿
@msg {key} must be [x, y] with finite coordinates {key} 必须是具有有限坐标的 [x, y]
@msg ids must contain 1–10000 object ids ids 必须包含 1–10000 个对象 ID
Can't render this file because it contains an unexpected character in line 2 and column 73.
+5
View File
@@ -5013,3 +5013,8 @@ lighting Light 光源
Ruby 注音
Fresco… 壁畫…
Fresco 壁畫
# Artwork drag between document tabs.
Drop Artwork 放置圖稿
@msg {key} must be [x, y] with finite coordinates {key} 必須是具有有限座標的 [x, y]
@msg ids must contain 1–10000 object ids ids 必須包含 1–10000 個物件 ID
Can't render this file because it contains an unexpected character in line 6 and column 47.
+3
View File
@@ -64,6 +64,7 @@ pub mod unsaved;
pub mod widgets;
pub mod workspaces;
mod document_drag;
#[cfg(test)]
mod tests_adjust;
#[cfg(test)]
@@ -83,6 +84,8 @@ mod tests_distortkeys;
#[cfg(test)]
mod tests_docsetup;
#[cfg(test)]
mod tests_document_drag;
#[cfg(test)]
mod tests_font_menu;
#[cfg(test)]
mod tests_fonts;
+346
View File
@@ -0,0 +1,346 @@
//! Real tab-strip and canvas gestures for dragging editable artwork between documents.
use egui::{Event, PointerButton, Pos2};
use serde_json::json;
use vectorcraft_engine::Session;
use vectorcraft_geom::Point;
use crate::{VectorcraftApp, canvas, chrome};
fn frame(app: &mut VectorcraftApp, ctx: &egui::Context, time: f64, events: Vec<Event>) -> Vec<egui::Rect> {
app.sync_views();
let focused = events
.iter()
.rev()
.find_map(|e| match e {
Event::WindowFocused(focused) => Some(*focused),
_ => None,
})
.unwrap_or(true);
let mut out = ctx.run_ui(
egui::RawInput {
screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, egui::vec2(1000.0, 700.0))),
time: Some(time),
focused,
events,
..Default::default()
},
|ui| {
canvas::release_over_panel(app, ui.ctx());
egui::CentralPanel::default().show(ui, |ui| {
chrome::doc_tabs(app, ui);
canvas::show(app, ui);
});
},
);
out.textures_delta.clear();
let mut titles: Vec<_> = out
.shapes
.iter()
.filter_map(|s| match &s.shape {
egui::Shape::Text(t) if t.galley.text().contains(" @ ") => Some(egui::Rect::from_min_size(t.pos, t.galley.size())),
_ => None,
})
.collect();
titles.sort_by(|a, b| a.left().total_cmp(&b.left()));
titles
}
fn button(pos: Pos2, pressed: bool) -> Event {
Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Default::default() }
}
#[test]
fn dragging_artwork_over_a_tab_switches_then_copies_on_drop() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 400, "height": 300, "name": "Logo"})).unwrap();
let id = app.run("shape.rectangle", json!({"x": 50, "y": 50, "width": 100, "height": 100})).unwrap()["id"].as_u64().unwrap();
let source = app.session.active().unwrap().doc.clone();
let source_undo = app.session.active().unwrap().history.undo.len();
app.run("file.new", json!({"width": 400, "height": 300, "name": "Flyer"})).unwrap();
app.run("shape.rectangle", json!({"x": 300, "y": 230, "width": 50, "height": 50})).unwrap();
app.session.execute("edit.copy", &json!({})).unwrap();
let clipboard = app.session.clipboard.clone();
let destination = app.session.active().unwrap().doc.clone();
let dest_undo = app.session.active().unwrap().history.undo.len();
app.session.set_active(0);
app.select_tool("selection");
app.ui.task_bar = false;
app.ui.view.rulers = false;
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
let tabs = frame(&mut app, &ctx, 0.0, vec![]);
assert_eq!(tabs.len(), 2);
let grab = canvas::Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()).to_screen(Point::new(100.0, 100.0));
frame(&mut app, &ctx, 0.05, vec![Event::PointerMoved(grab), button(grab, true)]);
frame(&mut app, &ctx, 0.1, vec![Event::PointerMoved(grab + egui::vec2(20.0, 10.0))]);
assert!(app.session.in_interaction());
let tab = tabs[1].center();
frame(&mut app, &ctx, 0.2, vec![Event::PointerMoved(tab)]);
frame(&mut app, &ctx, 0.25, vec![]);
assert_eq!(app.session.active_index(), Some(0), "crossing a tab briefly must not switch");
frame(&mut app, &ctx, 0.8, vec![]);
assert_eq!(app.session.active_index(), Some(1), "holding the artwork over the target tab activates it");
assert_eq!(app.session.documents()[0].doc, source, "the source preview must be rolled back");
let drop = canvas::Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()).to_screen(Point::new(170.0, 120.0));
frame(&mut app, &ctx, 0.9, vec![Event::PointerMoved(drop)]);
frame(&mut app, &ctx, 1.0, vec![button(drop, false)]);
let st = app.session.active().unwrap();
assert_eq!(st.selection.objects.len(), 1);
let placed = st.doc.node(st.selection.objects[0]).unwrap().geometric_bounds().unwrap();
assert!((placed.center().x - 170.0).abs() < 0.01 && (placed.center().y - 120.0).abs() < 0.01, "{placed:?}");
assert_eq!(st.history.undo.len(), dest_undo + 1);
assert_eq!(app.session.clipboard, clipboard, "a drag must preserve the user's clipboard");
assert_eq!(app.session.documents()[0].doc, source);
assert_eq!(app.session.documents()[0].history.undo.len(), source_undo);
assert!(app.session.documents()[0].doc.node(vectorcraft_doc::NodeId(id)).is_some());
app.run("edit.undo", json!({})).unwrap();
assert_eq!(app.session.active().unwrap().doc, destination);
app.run("edit.redo", json!({})).unwrap();
assert_eq!(app.session.active().unwrap().history.undo.len(), dest_undo + 1);
}
fn drag_fixture() -> (VectorcraftApp, egui::Context, Pos2, Vec<egui::Rect>) {
drag_fixture_modifiers(Default::default())
}
fn drag_fixture_modifiers(modifiers: egui::Modifiers) -> (VectorcraftApp, egui::Context, Pos2, Vec<egui::Rect>) {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width":400,"height":300,"name":"Source"})).unwrap();
app.run("shape.rectangle", json!({"x":50,"y":50,"width":100,"height":100})).unwrap();
app.run("file.new", json!({"width":400,"height":300,"name":"Target"})).unwrap();
app.run("file.new", json!({"width":400,"height":300,"name":"Third"})).unwrap();
app.session.set_active(0);
app.select_tool("selection");
app.ui.task_bar = false;
app.ui.view.rulers = false;
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
frame(&mut app, &ctx, 0.0, vec![]);
// Fit changes the zoom in the title, and therefore the tab widths.
let tabs = frame(&mut app, &ctx, 0.01, vec![]);
let grab = canvas::Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()).to_screen(Point::new(75.0, 80.0));
frame(
&mut app,
&ctx,
0.05,
vec![
Event::ModifiersChanged(modifiers),
Event::PointerMoved(grab),
Event::PointerButton { pos: grab, button: PointerButton::Primary, pressed: true, modifiers },
],
);
frame(&mut app, &ctx, 0.1, vec![Event::PointerMoved(grab + egui::vec2(20.0, 10.0))]);
(app, ctx, grab, tabs)
}
#[test]
fn tab_hover_delay_restarts_after_leaving_and_close_button_does_not_activate() {
let (mut app, ctx, grab, tabs) = drag_fixture();
let tab = tabs[1].center();
frame(&mut app, &ctx, 0.2, vec![Event::PointerMoved(tab)]);
frame(&mut app, &ctx, 0.25, vec![]);
frame(&mut app, &ctx, 0.5, vec![Event::PointerMoved(grab)]);
frame(&mut app, &ctx, 0.6, vec![Event::PointerMoved(tab)]);
frame(&mut app, &ctx, 0.7, vec![]);
frame(&mut app, &ctx, 0.95, vec![]);
assert_eq!(app.session.active_index(), Some(0));
// On Linux the close icon is 16 pt from the right edge, after the title.
let close = egui::pos2(if cfg!(target_os = "macos") { tabs[1].left() - 16.0 } else { tabs[1].right() + 20.0 }, tab.y);
frame(&mut app, &ctx, 1.0, vec![Event::PointerMoved(close)]);
frame(&mut app, &ctx, 2.0, vec![]);
assert_eq!(app.session.active_index(), Some(0));
frame(&mut app, &ctx, 2.1, vec![Event::PointerMoved(tab)]);
frame(&mut app, &ctx, 2.6, vec![]);
assert_eq!(app.session.active_index(), Some(1));
}
#[test]
fn escape_or_release_over_tab_cancels_the_transfer_without_edits() {
for escape in [false, true] {
let (mut app, ctx, _, tabs) = drag_fixture();
let original = app.session.documents()[0].interaction.as_ref().unwrap().doc.clone();
let target = app.session.documents()[1].doc.clone();
let undo = app.session.documents()[1].history.undo.len();
let tab = tabs[1].center();
frame(&mut app, &ctx, 0.2, vec![Event::PointerMoved(tab)]);
frame(&mut app, &ctx, 0.25, vec![]);
frame(&mut app, &ctx, 0.8, vec![]);
assert_eq!(app.session.active_index(), Some(1));
if escape {
frame(
&mut app,
&ctx,
0.9,
vec![Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() }],
);
let drop = canvas::Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()).to_screen(Point::new(170.0, 120.0));
frame(&mut app, &ctx, 1.0, vec![Event::PointerMoved(drop), button(drop, false)]);
} else {
frame(&mut app, &ctx, 1.0, vec![button(tab, false)]);
}
assert_eq!(app.session.documents()[0].doc, original);
assert_eq!(app.session.active().unwrap().doc, target);
assert_eq!(app.session.active().unwrap().history.undo.len(), undo);
assert!(!app.session.in_interaction());
}
}
#[test]
fn tab_drop_preserves_hotspot_across_zoom_and_rotation() {
let (mut app, ctx, _, tabs) = drag_fixture();
app.views[1].fitted = true;
app.views[1].zoom = 0.9;
app.views[1].rotation = 25.0;
app.views[1].center = Point::new(200.0, 150.0);
let tab = tabs[1].center();
frame(&mut app, &ctx, 0.2, vec![Event::PointerMoved(tab)]);
frame(&mut app, &ctx, 0.25, vec![]);
frame(&mut app, &ctx, 0.8, vec![]);
let drop = canvas::Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()).to_screen(Point::new(175.0, 180.0));
frame(&mut app, &ctx, 0.9, vec![Event::PointerMoved(drop)]);
frame(&mut app, &ctx, 1.0, vec![button(drop, false)]);
let st = app.session.active().unwrap();
let bounds = st.doc.node(st.selection.objects[0]).unwrap().geometric_bounds().unwrap();
assert!((bounds.x0 - 150.0).abs() < 0.001 && (bounds.y0 - 150.0).abs() < 0.001, "{bounds:?}");
}
#[test]
fn hovering_back_to_source_resumes_move_instead_of_copying() {
let (mut app, ctx, _, tabs) = drag_fixture();
let id = app.session.active().unwrap().selection.objects[0];
let undo = app.session.active().unwrap().history.undo.len();
frame(&mut app, &ctx, 0.2, vec![Event::PointerMoved(tabs[1].center())]);
frame(&mut app, &ctx, 0.25, vec![]);
frame(&mut app, &ctx, 0.8, vec![]);
frame(&mut app, &ctx, 0.9, vec![Event::PointerMoved(tabs[0].center())]);
frame(&mut app, &ctx, 1.4, vec![]);
assert_eq!(app.session.active_index(), Some(0));
let drop = canvas::Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()).to_screen(Point::new(175.0, 180.0));
frame(&mut app, &ctx, 1.5, vec![Event::PointerMoved(drop)]);
frame(&mut app, &ctx, 1.6, vec![button(drop, false)]);
let st = app.session.active().unwrap();
assert_eq!(st.selection.objects, vec![id]);
assert_eq!(st.history.undo.len(), undo + 1);
let bounds = st.doc.node(id).unwrap().geometric_bounds().unwrap();
assert!((bounds.x0 - 150.0).abs() < 0.001 && (bounds.y0 - 150.0).abs() < 0.001, "{bounds:?}");
}
#[test]
fn alt_drag_can_continue_across_multiple_tabs_without_copying_the_source_preview() {
let (mut app, ctx, _, tabs) = drag_fixture_modifiers(egui::Modifiers::ALT);
assert_eq!(app.session.active().unwrap().interaction.as_ref().unwrap().label, "Copy");
let source = app.session.active().unwrap().interaction.as_ref().unwrap().doc.clone();
frame(&mut app, &ctx, 0.2, vec![Event::PointerMoved(tabs[1].center())]);
frame(&mut app, &ctx, 0.25, vec![]);
frame(&mut app, &ctx, 0.8, vec![]);
assert_eq!(app.session.active_index(), Some(1));
frame(&mut app, &ctx, 0.9, vec![Event::PointerMoved(tabs[2].center())]);
frame(&mut app, &ctx, 1.4, vec![]);
assert_eq!(app.session.active_index(), Some(2));
let drop = canvas::Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()).to_screen(Point::new(175.0, 180.0));
frame(&mut app, &ctx, 1.5, vec![Event::PointerMoved(drop)]);
frame(&mut app, &ctx, 1.6, vec![button(drop, false)]);
assert_eq!(app.session.active().unwrap().selection.objects.len(), 1);
assert_eq!(app.session.documents()[0].doc, source);
assert!(app.session.documents()[1].history.undo.is_empty());
}
#[test]
fn closing_the_source_during_a_transfer_cancels_it() {
let (mut app, ctx, _, tabs) = drag_fixture();
frame(&mut app, &ctx, 0.2, vec![Event::PointerMoved(tabs[1].center())]);
frame(&mut app, &ctx, 0.25, vec![]);
frame(&mut app, &ctx, 0.8, vec![]);
let target = app.session.active().unwrap().doc.clone();
app.session.execute("file.close", &json!({"index":0})).unwrap();
let drop = canvas::Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()).to_screen(Point::new(175.0, 180.0));
frame(&mut app, &ctx, 0.9, vec![Event::PointerMoved(drop)]);
frame(&mut app, &ctx, 1.0, vec![button(drop, false)]);
assert_eq!(app.session.active().unwrap().doc, target);
assert!(app.session.active().unwrap().history.undo.is_empty());
}
#[test]
fn source_edits_target_closure_switches_and_focus_loss_cancel_stale_transfers() {
for change in ["source", "close target", "switch target", "focus", "touch cancel", "two fingers"] {
let (mut app, ctx, _, tabs) = drag_fixture();
let source_id = app.session.active().unwrap().selection.objects[0];
frame(&mut app, &ctx, 0.2, vec![Event::PointerMoved(tabs[1].center())]);
frame(&mut app, &ctx, 0.25, vec![]);
frame(&mut app, &ctx, 0.8, vec![]);
match change {
"source" => {
app.session.set_active(0);
app.session.execute("edit.undo", &json!({})).unwrap();
let replacement = app.session.execute("shape.ellipse", &json!({"x":50,"y":50,"width":100,"height":100})).unwrap();
assert_eq!(replacement["id"].as_u64(), Some(source_id.0), "Undo can reuse a node id for unrelated artwork");
app.session.set_active(1);
}
"close target" => {
app.session.execute("file.close", &json!({"index":1})).unwrap();
}
"switch target" => {
app.session.set_active(2);
}
"touch cancel" | "two fingers" => {
let touch = |id, phase| Event::Touch {
device_id: egui::TouchDeviceId(1),
id: egui::TouchId(id),
phase,
pos: Pos2::new(400.0 + id as f32 * 10.0, 350.0),
force: None,
};
let events = if change == "touch cancel" {
vec![touch(1, egui::TouchPhase::Cancel)]
} else {
vec![touch(1, egui::TouchPhase::Start), touch(2, egui::TouchPhase::Start)]
};
frame(&mut app, &ctx, 0.85, events);
}
_ => {
frame(&mut app, &ctx, 0.85, vec![Event::WindowFocused(false)]);
}
}
let before = app.session.active().unwrap().doc.clone();
let undo = app.session.active().unwrap().history.undo.len();
let drop = canvas::Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()).to_screen(Point::new(175.0, 180.0));
frame(&mut app, &ctx, 0.9, vec![Event::PointerMoved(drop)]);
frame(&mut app, &ctx, 1.0, vec![button(drop, false)]);
assert_eq!(app.session.active().unwrap().doc, before, "{change}");
assert_eq!(app.session.active().unwrap().history.undo.len(), undo, "{change}");
}
}
#[test]
fn scrolling_tabs_under_a_stationary_drag_does_not_cascade_to_other_documents() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
for i in 0..8 {
app.run("file.new", json!({"width":400,"height":300,"name":format!("Document {i}")})).unwrap();
}
app.run("shape.rectangle", json!({"x":50,"y":50,"width":100,"height":100})).unwrap();
app.select_tool("selection");
app.ui.task_bar = false;
app.ui.view.rulers = false;
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
frame(&mut app, &ctx, 0.0, vec![]);
let tabs = frame(&mut app, &ctx, 0.01, vec![]);
assert!(tabs.len() >= 2 && tabs.len() < 8, "must exercise the scrolling tab strip");
let target = 8 - tabs.len();
let grab = canvas::Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()).to_screen(Point::new(75.0, 80.0));
frame(&mut app, &ctx, 0.05, vec![Event::PointerMoved(grab), button(grab, true)]);
frame(&mut app, &ctx, 0.1, vec![Event::PointerMoved(grab + egui::vec2(20.0, 10.0))]);
frame(&mut app, &ctx, 0.2, vec![Event::PointerMoved(tabs[0].center())]);
frame(&mut app, &ctx, 0.25, vec![]);
frame(&mut app, &ctx, 0.8, vec![]);
assert_eq!(app.session.active_index(), Some(target));
for time in [0.9, 1.5, 2.1] {
frame(&mut app, &ctx, time, vec![]);
assert_eq!(app.session.active_index(), Some(target), "layout movement must not act as pointer movement");
}
let drop = canvas::Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()).to_screen(Point::new(175.0, 180.0));
frame(&mut app, &ctx, 2.2, vec![Event::PointerMoved(drop)]);
frame(&mut app, &ctx, 2.3, vec![button(drop, false)]);
assert_eq!(app.session.active().unwrap().selection.objects.len(), 1);
}
+33
View File
@@ -1,5 +1,6 @@
# Development
> **Last updated:** 2026-10-22 · **Change:** document-tab artwork dragging and lossless drop command.
> **Last updated:** 2026-10-21 · **Change:** leaving out the bundled fonts with the egui panels (vectorcraft-ui-egui's and vectorcraft-mcp's `bundled-fonts` features).
> **Last updated:** 2026-10-14 · **Change:** leaving out the bundled fonts (`bundled-fonts` feature).
> **Last updated:** 2026-10-14 · **Change:** document-font registration for all explicitly supplied faces.
@@ -403,10 +404,42 @@ Regression evidence: `tests_eyedropper.rs` covers Unicode, font/paint, Undo/Redo
and atomic rejection of source and target opacity/blend modes; `text_range_and_brush_sampling_options_coexist`
covers upstream brush-key sampling. Scores and estimates unchanged.
## Drag artwork between document tabs (2026-10-22)
Hold a Selection-tool move over another visible document tab for 400 ms to activate it,
then release over its canvas to copy the selected artwork. The original grab point is kept
across different zoom levels and view rotations. The source preview is rolled back before
the switch; source artwork, source history and both clipboards are preserved. Returning
to the source tab resumes a move (Alt copies). Escape or release outside the canvas cancels.
Changing the source revision or unexpectedly changing/closing the destination cancels a stale
transfer; focus loss, touch cancellation and multitouch cancel too. Moving tabs under a stationary
pointer do not activate further tabs until the pointer moves. The close icon does not activate a tab during a drag. Overflow-menu hover activation and
cross-window dragging remain outside this change.
`edit.dropSelection {sourceDocument, ids, grab, at}` uses document-scoped ids
(`sourceDocument` is `document.inspect`’s session-unique `uid`), validates 1–10000 ids
before editing, targets the current editable insertion context (Paste Remembers Layers does
not reroute a drag into a matching source layer), and uses the existing
lossless paste resource pipeline: groups, editable paths/type, images, symbols and patterns
travel together. Same-name conflicting swatches are added under unique names to preserve
source colours; pattern fills/strokes translate with the dropped artwork to preserve their alignment. The target receives one Undo/Redo step. Invalid or closed sources fail before
editing. Existing artwork-to-panel drags retain their original behavior.
Evidence: `tests_document_drag` drives actual tab-strip/canvas pointer frames; `tests_clipboard`
covers resource rendering, id collisions, swatch conflicts, grab-point placement, locked-target
refusal, excessive ids, malformed numeric parameters and atomic invalid-input refusal. A private Linux/Xvfb native build also placed an original logo group
onto a flyer through the command API; its screenshot was inspected, source preservation and
Undo/Redo checked. Physical mouse hover on the desktop is not covered by that native smoke test.
Public behavior reference: [Illustrator public duplication documentation](https://helpx.adobe.com/illustrator/desktop/manage-objects/edit-objects/duplicate-objects.html).
The tab-hover workflow follows the contributor’s requested behavior. All test artwork is original geometry/text generated locally.
The new command and validation messages are translated in all 23 language catalogs.
Scores and hour estimates are unchanged.
## Revision history
| Date | Change | Summary |
|---|---|---|
| 2026-10-22 | minor | Artwork dragged with Selection switches visible document tabs after a 400 ms hover, then copies editable artwork on canvas release; source and clipboard stay intact; scores and hours unchanged |
| 2026-10-21 | minor | M3: highlighted-text Eyedropper sampling preserves ranges and rejects unsupported source/target transparency atomically; regression-tested; scores and estimates unchanged. |
| 2026-10-21 | minor | Fonts › Leaving out the bundled fonts: vectorcraft-ui-egui's and vectorcraft-mcp's `bundled-fonts` features, and the engine declared without default features. |
| 2026-10-14 | minor | Document the `bundled-fonts` feature of vectorcraft-text and vectorcraft-engine (Fonts › Leaving out the bundled fonts). |
+12 -1
View File
File diff suppressed because one or more lines are too long
+11 -2
View File
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+14 -1
View File
File diff suppressed because one or more lines are too long