diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index f6f889916..dd583e7b8 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -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 { vec![ cmd!( @@ -73,6 +74,15 @@ pub fn specs() -> Vec { 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 { 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 { + 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::>>()?; + // 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 { 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 = 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))), diff --git a/crates/engine/src/inspect.rs b/crates/engine/src/inspect.rs index 0d34748d8..8747ad925 100644 --- a/crates/engine/src/inspect.rs +++ b/crates/engine/src/inspect.rs @@ -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(), diff --git a/crates/engine/src/tests_clipboard.rs b/crates/engine/src/tests_clipboard.rs index b373a7973..b32b1c44f 100644 --- a/crates/engine/src/tests_clipboard.rs +++ b/crates/engine/src/tests_clipboard.rs @@ -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"); + } +} diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index bcfe660cd..8c0555ea9 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -101,6 +101,14 @@ enum Drag { Present, } +/// Clear canvas interaction state before a tab transfer takes ownership of the artwork drag. +pub(crate) fn end_document_drag(ctx: &egui::Context) { + ctx.data_mut(|d| { + d.remove::(drag_id()); + d.remove::(drag_pos_id()); + }); +} + fn drag_id() -> egui::Id { egui::Id::new("canvas-drag") } @@ -114,7 +122,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 +282,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 { @@ -813,6 +822,7 @@ fn cursor_icon(c: Cursor) -> egui::CursorIcon { } } +/// Dispatch canvas pointer, keyboard and drop events through the active document view. fn handle_input(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Response, rect: egui::Rect) { let line = ui.ctx().options(|o| o.input_options.line_scroll_speed); let wheel_zooms = app.session.prefs.zoom_with_mouse_wheel; @@ -929,6 +939,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| { @@ -1494,7 +1505,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") { @@ -1509,6 +1520,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; } diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index b744ed420..f4e4741d2 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -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)); diff --git a/crates/ui-egui/src/dialogs/effect_gallery.rs b/crates/ui-egui/src/dialogs/effect_gallery.rs index c2bfd76b4..92af1125f 100644 --- a/crates/ui-egui/src/dialogs/effect_gallery.rs +++ b/crates/ui-egui/src/dialogs/effect_gallery.rs @@ -42,7 +42,7 @@ struct StackEntry { /// Open the gallery on the current selection. pub fn open(app: &mut VectorcraftApp, _p: &Value) -> Result { - if !app.session.active().is_some_and(|s| !s.selection.is_empty()) { + if app.session.active().is_none_or(|s| s.selection.is_empty()) { return Err(tl!("Select artwork to apply effects").into()); } let selected = effects_in_category(0).first().map(|e| e.id).unwrap_or(""); diff --git a/crates/ui-egui/src/document_drag.rs b/crates/ui-egui/src/document_drag.rs new file mode 100644 index 000000000..2ca57ce48 --- /dev/null +++ b/crates/ui-egui/src/document_drag.rs @@ -0,0 +1,195 @@ +//! 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, + revision: u64, + target: u64, +} + +#[derive(Clone)] +struct Hover { + uid: u64, + since: f64, +} + +/// Key for the source document and original canvas grab point. +fn origin_id() -> Id { + Id::new("document-drag-origin") +} +/// Key for the tab identity and dwell start time of the current hover. +fn hover_id() -> Id { + Id::new("document-drag-hover") +} +/// Key for the pointer position latched after tab activation to prevent cascading switches. +fn activated_at_id() -> Id { + Id::new("document-drag-activated-at") +} + +/// Record the Selection drag source and grab point, clearing any previous tab-hover timer. +pub(crate) fn begin(app: &VectorcraftApp, ctx: &Context, grab: Point) { + ctx.data_mut(|d| { + d.remove::(origin_id()); + d.remove::(hover_id()); + d.remove::(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::(activated_at_id()) != at { + d.remove::(activated_at_id()); + } + }); + if let Some(art) = egui::DragAndDrop::payload::(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_id()); + d.remove::(hover_id()); + d.remove::(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::(activated_at_id()).is_some()) { + return false; + } + let transfer = egui::DragAndDrop::has_payload_of_type::(ctx); + let art = egui::DragAndDrop::payload::(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_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_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::(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_id()); + if let Some(at) = at { + d.insert_temp(activated_at_id(), at); + } + }); + true +} + +/// Reset the dwell timer when no eligible inactive tab is under the artwork. +pub(crate) fn finish_tabs(ctx: &Context, hovering: bool) { + if !hovering { + ctx.data_mut(|d| d.remove::(hover_id())); + } +} + +/// Release artwork into the active canvas; returning to the source resumes Move or Alt-copy. +pub(crate) fn drop_on_canvas(app: &mut VectorcraftApp, resp: &Response, xf: &canvas::Xf) { + let Some(payload) = egui::DragAndDrop::payload::(&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::() + && 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); + } + } +} diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 0ab57b7a3..7aa9d517d 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -1049,6 +1049,11 @@ Fidelity: Точност: Figure Space Цифрово разстояние Figure: Фигура: +# 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 идентификатора на обекти + File Файл File Dimensions Размери на файла File Format: Файлов формат: diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 2e94229f2..50fc2b5de 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -34,6 +34,11 @@ Medium Light Středně světlý Light Světlý Quit VectorCraft Ukončit VectorCraft +# 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ů + File Soubor New… Nový… New Without Dialog Nový bez dialogového okna diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index 14bdad93a..189d3ea7c 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -20,6 +20,11 @@ Medium Light Mellemlys Light Lys Quit VectorCraft Slut VectorCraft +# 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 + File Filer New… Ny… New Without Dialog Ny uden dialog diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index d1d0185cb..76b7a6f22 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -1046,6 +1046,11 @@ Fidelity: Genauigkeit: Figure Space Ziffernleerzeichen Figure: Ziffern: +# 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 + File Datei File Dimensions Dateiabmessungen File Format: Dateiformat: diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index c17cdda94..a91f0a8ae 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -1015,6 +1015,11 @@ Fidelity: Πιστότητα: Figure Space Κενό ψηφίου Figure: Σχήμα: +# 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 αναγνωριστικά αντικειμένων + File Αρχείο File Dimensions Διαστάσεις αρχείου File Format: Μορφή αρχείου: diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 97dd0d381..210c66742 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -1069,6 +1069,11 @@ Fidelity: Fidelidad: Figure Space Espacio de cifra Figure: Cifras: +# 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 + File Archivo File Dimensions Dimensiones del archivo File Format: Formato de archivo: diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index f93764862..0d359afd5 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -42,6 +42,11 @@ Medium Light Keskivaalea Light Vaalea Quit VectorCraft Lopeta VectorCraft +# 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 + File Tiedosto New… Uusi… New Without Dialog Uusi ilman valintaikkunaa diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index bc687ba3a..901913321 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -1069,6 +1069,11 @@ Fidelity: Fidélité : Figure Space Espace numérique Figure: Chiffres : +# 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 + File Fichier File Dimensions Dimensions du fichier File Format: Format de fichier : diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 7ec409f11..30045f3d6 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -1355,6 +1355,11 @@ Fidelity: Pontosság: Figure Space Számjegyszóköz Figure: Számjegyek: +# 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 + File Fájl File Dimensions Fájl méretei File Format: Fájlformátum: diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 917c48816..a4e2fc343 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -20,6 +20,11 @@ Medium Light Agak terang Light Terang Quit VectorCraft Keluar dari VectorCraft +# 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 + File Berkas New… Baru… New Without Dialog Baru tanpa dialog diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index f32fb2d07..4d68d2dc1 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -1069,6 +1069,11 @@ Fidelity: Fedeltà: Figure Space Spazio cifra Figure: Cifre: +# 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 + File File File Dimensions Dimensioni file File Format: Formato file: diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 14a040be2..2271dc549 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -75,6 +75,11 @@ Medium Light やや明るい Light 明るい Quit VectorCraft VectorCraft を終了 +# 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 を指定してください + File ファイル New… 新規… New Without Dialog ダイアログなしで新規 diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index 4fbddc362..89575604d 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -27,6 +27,11 @@ Medium Light საშუალოდ ღია Light ღია Quit VectorCraft VectorCraft-იდან გასვლა +# 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 ობიექტის იდენტიფიკატორს + File ფაილი New… ახალი… New Without Dialog ახალი დიალოგის გარეშე diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 130c42491..e02bffcca 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -71,6 +71,11 @@ Medium Light 약간 밝게 Light 밝게 Quit VectorCraft VectorCraft 종료 +# 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가 있어야 합니다 + File 파일 New… 새로 만들기… New Without Dialog 대화상자 없이 새로 만들기 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index d6e4572f4..9cd5ef0fa 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -20,6 +20,11 @@ Medium Light Gemiddeld licht Light Licht Quit VectorCraft VectorCraft afsluiten +# 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 + File Bestand New… Nieuw… New Without Dialog Nieuw zonder dialoogvenster diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index d8765edc7..2872b1580 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -38,6 +38,11 @@ Medium Light Średnio jasny Light Jasny Quit VectorCraft Zakończ VectorCraft +# 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 + File Plik New… Nowy… New Without Dialog Nowy bez okna dialogowego diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 026abe5d5..8f41de5d8 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -947,6 +947,11 @@ Fidelity: Fidelidade: Figure Space Espaço de Figura Figure: Figura: +# 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 + File Arquivo File Format: Formato do Arquivo: File Formats Formatos de Arquivo diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 6cb399014..63e06c6ef 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -1067,6 +1067,11 @@ Fidelity: Точность: Figure Space Цифровая шпация Figure: Цифры: +# 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 идентификаторов объектов + File Файл File Dimensions Размеры файла File Format: Формат файла: diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index fda86c8c6..71fe3e8d9 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -1051,6 +1051,11 @@ Fidelity: Återgivning: Figure Space Siffermellanrum Figure: Figur: +# 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 + File Fil File Dimensions Fildimensioner File Format: Filformat: diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 6bc70c894..7c5f56dce 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -5361,3 +5361,8 @@ theme Appearance Görünüm Fresco Fresk Effect Gallery… Efekt Galerisi… Effect Gallery Dialog Efekt Galerisi İletişim Kutusu + +# Artwork drag between document tabs. + Drop Artwork Çizimi Bırak +@msg {key} must be [x, y] with finite coordinates {key}, sonlu koordinatlı [x, y] olmalı +@msg ids must contain 1–10000 object ids ids 1–10000 nesne kimliği içermeli diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index ef7daebb2..1c87f1269 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -1063,6 +1063,11 @@ Fidelity: Точність: Figure Space Цифрова шпація Figure: Цифри: +# 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 ідентифікаторів об’єктів + File Файл File Dimensions Розміри файлу File Format: Формат файлу: diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 5a22e739b..3673b3f77 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -1232,6 +1232,11 @@ Fidelity: Độ trung thực: Figure Space Khoảng trắng chữ số Figure: Chữ số: +# 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 + File Tệp File Dimensions Kích thước tệp File Format: Định dạng tệp: diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 1ffd5a84b..93a8a8e5a 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -1010,6 +1010,11 @@ Fidelity: 精确度: Figure Space 数字空格 Figure: 数字: +# 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 + File 文件 File Dimensions 文件尺寸 File Format: 文件格式: diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 380583bbf..42cd32aba 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -1033,6 +1033,11 @@ Fidelity: 精確度: Figure Space 數字空格 Figure: 數字: +# 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 + File 檔案 File Dimensions 檔案尺寸 File Format: 檔案格式: diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 4f2cd7ecb..7553e67d7 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -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; diff --git a/crates/ui-egui/src/tests_document_drag.rs b/crates/ui-egui/src/tests_document_drag.rs new file mode 100644 index 000000000..7001e13be --- /dev/null +++ b/crates/ui-egui/src/tests_document_drag.rs @@ -0,0 +1,363 @@ +//! 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}; + +/// Run a real tab-strip/canvas frame and return title bounds for pointer-driven gestures. +fn frame(app: &mut VectorcraftApp, ctx: &egui::Context, time: f64, events: Vec) -> Vec { + 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 +} + +/// Build a primary-pointer event without keyboard modifiers. +fn button(pos: Pos2, pressed: bool) -> Event { + Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Default::default() } +} + +/// Verify dwell activation, editable placement, source and clipboard preservation, and target Undo/Redo. +#[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()); + assert!(app.session.tool_busy(), "the Selection tool owns the source drag"); + 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!(!app.session.tool_busy(), "document activation resets the source Selection tool before drop"); + 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!(!app.session.tool_busy(), "a completed tab transfer leaves the Selection tool idle"); + 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); +} + +/// Begin a Selection move in the first of three documents with no keyboard modifiers. +fn drag_fixture() -> (VectorcraftApp, egui::Context, Pos2, Vec) { + drag_fixture_modifiers(Default::default()) +} + +/// Create three document tabs and start dragging source artwork with the supplied modifiers. +fn drag_fixture_modifiers(modifiers: egui::Modifiers) -> (VectorcraftApp, egui::Context, Pos2, Vec) { + 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) +} + +/// Verify that leaving a tab restarts its dwell timer and hovering its close button never activates it. +#[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)); +} + +/// Verify Escape and release outside the canvas preserve both documents and their undo histories. +#[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()); + assert!(!app.session.tool_busy(), "Escape and outside release leave the Selection tool idle"); + } +} + +/// Verify destination view transforms preserve the original artwork grab point during placement. +#[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:?}"); +} + +/// Verify returning to the source tab moves its original object instead of inserting a duplicate. +#[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:?}"); +} + +/// Verify Alt-drag transfers restored originals across tabs without retaining preview duplicates. +#[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()); +} + +/// Verify closing the originating document cancels a transfer before its eventual mouse release. +#[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()); +} + +/// Verify document changes, reused IDs, focus loss and touch cancellation cannot release stale artwork. +#[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}"); + } +} + +/// Verify tab-strip relayout under a stationary pointer cannot trigger another document switch. +#[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); +} diff --git a/docs/development.md b/docs/development.md index 983493ff4..4fc518178 100644 --- a/docs/development.md +++ b/docs/development.md @@ -403,6 +403,41 @@ 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 the internal clipboard 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 does 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. +The cited regressions assert the internal clipboard; they do not exercise the native +system-clipboard adapter. Tab activation resets the Selection tool through +`Session::set_active`; gesture regressions verify it is idle after switching, dropping +and cancelling a transfer. + +`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 24 language catalogs, including Turkish. +Scores and hour estimates are unchanged. + ## Revision history | Date | Change | Summary | @@ -412,3 +447,12 @@ covers upstream brush-key sampling. Scores and estimates unchanged. | 2026-10-14 | minor | Document the `bundled-fonts` feature of vectorcraft-text and vectorcraft-engine (Fonts › Leaving out the bundled fonts). | | 2026-10-14 | minor | Document registration of every explicitly supplied craft-fonts face after the existing script-specific prefix; empty input and web selection remain unchanged. | | 2026-10-13 | minor | Document pinned PngSuite files, integrity checks and download budgets (#1729). | + +> **Tab-drag status (2026-10-22):** updated from upstream, including the Turkish catalog and all three tab-drag translations requested in #3479; Selection-tool reset regression-checked and clipboard evidence scoped to internal state; drag lifecycle, insertion and regression contracts documented; behavior, scores and estimates unchanged. + +| Date | Change | Tab-drag history | +|---|---|---| +| 2026-10-22 | minor | Verify Selection-tool reset after tab activation, drop and cancellation; narrow clipboard evidence to the tested internal clipboard and correct documentation grammar; behavior, scores and estimates unchanged. | +| 2026-10-22 | minor | Merge current main and complete the Turkish tab-drag labels and validation messages requested by the maintainer; all 24 catalogs covered; scores and estimates unchanged. | +| 2026-10-22 | minor | Tab-drag follow-up: reconcile upstream progress updates and document drag lifecycle, insertion behavior and regression contracts; behavior, scores and estimates unchanged. | +| 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 | diff --git a/docs/gaps.md b/docs/gaps.md index 70a30158e..87f36378f 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -310,6 +310,8 @@ from G14 and the budget run of G15: **~170–270 h** one agent, ~50–80 h wall ## G7 Views and windows +- **Done:** Selection artwork drag across visible document tabs (400 ms hover); lossless target copy, source/clipboard preservation and cancellation regressions. Cross-window dragging remains separate. + - **Done:** custom document ruler origin (#1212): corner drag with preview, double-click reset to the active artboard, relative X/Y inputs and native save/reopen. - **Done:** View › Rulers › Change to Global / Artboard Rulers (`view.globalRulers`, Cmd+Alt+R): artboard rulers, a new document's, count from the active artboard, each artboard with its own zero point; rulers, X/Y fields and guide readouts follow the mode, which is saved with the document. Estimate unchanged. - **Missing (noted for reporters, 2026-10-14):** custom toolbars (Window › Toolbars › New Toolbar, #1759); @@ -393,6 +395,14 @@ atomically; disable Transparency to preserve object transparency while sampling `tests_eyedropper.rs` covers ranges, Unicode, history and transparency; tool tests cover brush-key sampling coexistence. Scores and estimates unchanged. See [development.md](development.md#highlighted-text-eyedropper-2026-10-21). +## Drag artwork between document tabs (2026-10-22) + +Selection drags activate another visible document tab after 400 ms, then copy editable artwork +on canvas release with the original grab point. Source and clipboard preservation, resource +rendering, swatch conflicts, cancellation, rotated/zoomed views and target Undo/Redo have regression +coverage (`tests_document_drag`, `tests_clipboard`). Overflow-menu and cross-window drag remain +separate. Scores and hours unchanged. [Behavior and command details](development.md#drag-artwork-between-document-tabs-2026-10-22). + ## Revision history | Date | Change | Summary | @@ -510,3 +520,12 @@ sampling coexistence. Scores and estimates unchanged. See [development.md](devel | 2026-10-10 | minor | G9 evidence counted from the tracker (28 `.ai`/EPS issues, 20 fixed); impact narrowed to handing files back | | 2026-10-10 | minor | Alpha blockers checked against the core-workflow gate: none | | 2026-10-10 | major | Moved from the ROADMAP's "Where we're lacking"; kept ids G1–G8, added G9–G19 (file formats, stability, bugs, preferences, 30.x additions, pen hardware, performance, localization, ecosystem, AI, accessibility) and re-ranked | + +> **Tab-drag status (2026-10-22):** updated from upstream, including the Turkish catalog and all three tab-drag translations requested in #3479; Selection-tool reset regression-checked and clipboard evidence scoped to internal state; drag lifecycle, insertion and regression contracts documented; behavior, scores and estimates unchanged. + +| Date | Change | Tab-drag history | +|---|---|---| +| 2026-10-22 | minor | Verify Selection-tool reset after tab activation, drop and cancellation; narrow clipboard evidence to the tested internal clipboard and correct documentation grammar; behavior, scores and estimates unchanged. | +| 2026-10-22 | minor | Merge current main and complete the Turkish tab-drag labels and validation messages requested by the maintainer; all 24 catalogs covered; scores and estimates unchanged. | +| 2026-10-22 | minor | Tab-drag follow-up: reconcile upstream progress updates and document drag lifecycle, insertion behavior and regression contracts; behavior, scores and estimates unchanged. | +| 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 | diff --git a/docs/roadmap.md b/docs/roadmap.md index ab2face36..0ced22399 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -50,7 +50,7 @@ Checked 2026-10-10 from the code, tests and open issues; no workflow fails, so V | # | Milestone | Status | Est. remaining, one agent (h) | |---|---|---|---| | M0 | Skeleton + vertical slice | ✅ done | — | -| M1 | Selection, transform, layers, MCP (common command tools, strict keys, session resources and version-aware result hints) | ✅ mostly done (malformed MCP app replies discard the connection without replay, #1761; bounded control socket waits and connection-slot recovery, #1726; bounded synthetic control input, #1709; no-op anchor Align keeps Undo, Redo and saved state (#1180 follow-up); transform reference point snaps to anchors/centres; rotated objects keep a rotated bounding box and their angle; Free Transform, Scale and Reflect along a rotated box pending) | 4–6 | +| M1 | Selection, transform, layers, MCP (common command tools, strict keys, session resources and version-aware result hints) | ✅ mostly done (document-tab artwork drag with lossless resource transfer and one target Undo; malformed MCP app replies discard the connection without replay, #1761; bounded control socket waits and connection-slot recovery, #1726; bounded synthetic control input, #1709; no-op anchor Align keeps Undo, Redo and saved state (#1180 follow-up); transform reference point snaps to anchors/centres; rotated objects keep a rotated bounding box and their angle; Free Transform, Scale and Reflect along a rotated box pending) | 4–6 | | M2 | Drawing tools + smart guides | ✅ mostly done (Polygon/Star exact-size count persistence, with repeated-drag, tool-switch and Cancel regression coverage; Curvature point markers without direction handles (#1236, in part); Direct Selection segment click/Delete preserves neighboring edges, #1278; Flare, Reshape, Live Corners widget dragging and per-corner radii and kinds landed, on any path's corners; Shaper rough-shape recognition and orientation steps, the remaining Pen modifier nuances; Shaper Groups with editable sources and separate fill/stroke edits landed; custom ruler origin with drag preview, active-artboard reset, relative X/Y fields and native round-trip (#1212); construction widget/refinements pending) | 12–18 | | M3 | Paint & appearance (swatches, color, gradient, stroke, appearance, transparency, styles) | ✅ done (M3.7–M3.98): swatches, groups, libraries, tints, Lab spots and Registration; Eyedropper styles highlighted text only (atomic transparency rejection); Eyedropper reads visible locked and outside-isolation sources (#1251); Color Picker, Color panel, Color Guide, Color Themes, Edit Colors and Recolor Artwork; gradients that follow every transform, the annotator, linked stops, freeform gradients, focal points, gradients on strokes and Expand; visible strokes when paint is added to inherited None/zero-width art (#1253); stroke geometry, arrowheads, dashes fitted to corners, stroke on type, width profiles and the Width tool (Shift-double-click enables adjoining adjustment in Width Point Edit; finite-arithmetic follow-up regressions and full CI passed ([Validation evidence](target-app-parity.md#evidence-what-ships-today-by-area))), Scale Strokes and preview bounds; Appearance targeting, container appearance, target circles, Expand Appearance; knockout, isolation, blend accuracy, CMYK blending, overprint, Attributes, masks and clipping, Flatten Transparency; graphic styles, links and libraries; CSS library export preserves fractional linear and radial gradient stop positions. Left: freeform and mixed spot/process gradients export as stops or process colours to SVG/PDF; confirm the Scale Strokes & Effects default; Recolor model selection is available through the engine and MCP only (#2520); the dialog retains its existing controls | 4–8 | | M2 | Drawing tools + smart guides | ✅ mostly done (Curvature point markers without direction handles (#1236, in part); Direct Selection segment click/Delete preserves neighboring edges, #1278; Flare, Reshape, Live Corners widget dragging and per-corner radii and kinds landed, on any path's corners; Shaper rough-shape recognition and orientation steps, the remaining Pen modifier nuances; Shaper Groups with editable sources and separate fill/stroke edits landed; custom ruler origin with drag preview, active-artboard reset, relative X/Y fields and native round-trip (#1212); construction widget/refinements pending) | 12–18 | @@ -91,6 +91,14 @@ atomically; disable Transparency to preserve object transparency while sampling `tests_eyedropper.rs` covers ranges, Unicode, history and transparency; tool tests cover brush-key sampling coexistence. Scores and estimates unchanged. See [development.md](development.md#highlighted-text-eyedropper-2026-10-21). +## Drag artwork between document tabs (2026-10-22) + +Selection drags activate another visible document tab after 400 ms, then copy editable artwork +on canvas release with the original grab point. Source and clipboard preservation, resource +rendering, swatch conflicts, cancellation, rotated/zoomed views and target Undo/Redo have regression +coverage (`tests_document_drag`, `tests_clipboard`). Overflow-menu and cross-window drag remain +separate. Scores and hours unchanged. [Behavior and command details](development.md#drag-artwork-between-document-tabs-2026-10-22). + ## Revision history | Date | Change | Summary | @@ -212,3 +220,12 @@ sampling coexistence. Scores and estimates unchanged. See [development.md](devel | 2026-10-10 | minor | M9 row from main: initial Revolve | | 2026-10-10 | minor | Added the Alpha gate table (core-workflow gate): all six workflows work end to end, `.ai` exchange partial but not blocking; stage stays alpha | | 2026-10-10 | major | Created from ROADMAP.md's milestone table and "Where we're going"; added Current focus and the beta list | + +> **Tab-drag status (2026-10-22):** updated from upstream, including the Turkish catalog and all three tab-drag translations requested in #3479; Selection-tool reset regression-checked and clipboard evidence scoped to internal state; drag lifecycle, insertion and regression contracts documented; behavior, scores and estimates unchanged. + +| Date | Change | Tab-drag history | +|---|---|---| +| 2026-10-22 | minor | Verify Selection-tool reset after tab activation, drop and cancellation; narrow clipboard evidence to the tested internal clipboard and correct documentation grammar; behavior, scores and estimates unchanged. | +| 2026-10-22 | minor | Merge current main and complete the Turkish tab-drag labels and validation messages requested by the maintainer; all 24 catalogs covered; scores and estimates unchanged. | +| 2026-10-22 | minor | Tab-drag follow-up: reconcile upstream progress updates and document drag lifecycle, insertion behavior and regression contracts; behavior, scores and estimates unchanged. | +| 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 | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 7f9174da1..4c055932d 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -85,9 +85,9 @@ dominate; 3D, graphs and automation are specialist). They sum to 105. Feature de | Symbols, blends, envelopes, Repeat, perspective | 5 | 87% | 30.7's Blend panel with ease in/out, dynamic symbols, symbol libraries, Start Global Edit, envelope Anti-Alias/Preserve Shape; Symbol Options › Registration and Export Type (Graphic / Movie Clip) are done | 5–9 | estimated | | Image Trace, graphs, image tools | 3 | 75% | graph Column designs, Image Trace Create Strokes depth (Create Object Mosaic is done; junction-returning piece survival has source-generated regressions in `image-trace-quality.md`; large images trace with a warning; Auto Color keeps rare distinct colours) | 5–8 | estimated (raised from 72%); quick presets reveal adjustment controls, Properties reopens them | | Layers, artboards, document setup | 5 | 78% | Selected-artboard deletion is consistent across panel actions (all-selected retains artboard 1); history prunes removed indices while preserving valid multiselection; remaining: 30.0's artboard locking and colours and managing artboards from the canvas, Layers panel row context menus and Collect for Export (click-to-fit objects is done, #1227) | 5–8 | estimated (lowered from 82%: target moved); Artboard deletion keys keep artwork and the first of an all-selected set | -| View & navigation | 3 | 70% | New View/Edit Views, multiple windows and Arrange | 9–14 | estimated; cached canvas reprojection preserves scale during resize, rotation and DPI changes (regression and contributor original-file evidence below; scores/hours unchanged); cached canvas dimensions match the renderer limit before cache-key creation, view construction and worker submission (regression evidence below) | +| View & navigation | 3 | 70% | New View/Edit Views, multiple windows and Arrange | 9–14 | estimated; document-tab artwork hover/copy and lossless transfer tested (2026-10-22; score/hours unchanged); cached canvas reprojection preserves scale during resize, rotation and DPI changes (regression and contributor original-file evidence below; scores/hours unchanged); cached canvas dimensions match the renderer limit before cache-key creation, view construction and worker submission (regression evidence below) | | File formats | 6 | 75% | `.ai` files open with a flattened or wrong layer structure in real cases (#951), guides lost (#779), no writing of Illustrator's editing data (#868), FreeHand/CorelDRAW/DWG/HEIC/JPEG 2000 import, 3D export; see [file-format-parity.md](file-format-parity.md); EPS legacy mesh/opacity-mask and raster comparison follow-up verified | 10–20 | estimated (lowered from 85%: open issues); DXF control-point SPLINE import closure regression-tested (#1299 follow-up after #1320); PDF writer metadata, reader streaming and immutable backend checks in `crates/pdf/src/tests.rs`, `crates/pdf/src/tests_reader_streaming.rs` and `xtask/src/pdf_deps.rs`; PDF Place and clipboard paste retain live text (synthetic regressions) | -| View & navigation | 3 | 70% | New View/Edit Views, multiple windows and Arrange | 9–14 | estimated; cached canvas reprojection preserves scale during resize, rotation and DPI changes (regression and contributor original-file evidence below; scores/hours unchanged) | +| View & navigation | 3 | 70% | New View/Edit Views, multiple windows and Arrange | 9–14 | estimated; document-tab artwork hover/copy and lossless transfer tested (2026-10-22; score/hours unchanged); cached canvas reprojection preserves scale during resize, rotation and DPI changes (regression and contributor original-file evidence below; scores/hours unchanged) | | View & navigation | 3 | 70% | New View/Edit Views, multiple windows and Arrange | 9–14 | estimated | | Guides, grids, smart guides, snapping, rulers | 3 | 80% | 30.0's tangent and perpendicular snapping and its snapping panel, "intersect" smart guides for a moved selection's anchors (drawn points and dragged anchors snap to intersections, #1658), video rulers, distance readouts while drawing | 4–7 | estimated (lowered from 85%: target moved); new ruler-guide Alt orientation implemented | | Guides, grids, smart guides, snapping, rulers | 3 | 80% | 30.0's tangent and perpendicular snapping and its snapping panel, "intersect" smart guides for a moved selection's anchors (drawn points and dragged anchors snap to intersections, #1658), global/artboard ruler mode switch and video rulers, distance readouts while drawing | 4–7 | estimated (lowered from 85%: target moved); new ruler-guide Alt orientation implemented | @@ -936,6 +936,14 @@ atomically; disable Transparency to preserve object transparency while sampling `tests_eyedropper.rs` covers ranges, Unicode, history and transparency; tool tests cover brush-key sampling coexistence. Scores and estimates unchanged. See [development.md](development.md#highlighted-text-eyedropper-2026-10-21). +## Drag artwork between document tabs (2026-10-22) + +Selection drags activate another visible document tab after 400 ms, then copy editable artwork +on canvas release with the original grab point. Source and clipboard preservation, resource +rendering, swatch conflicts, cancellation, rotated/zoomed views and target Undo/Redo have regression +coverage (`tests_document_drag`, `tests_clipboard`). Overflow-menu and cross-window drag remain +separate. Scores and hours unchanged. [Behavior and command details](development.md#drag-artwork-between-document-tabs-2026-10-22). + ## Revision history | Date | Change | Summary | @@ -1123,3 +1131,12 @@ sampling coexistence. Scores and estimates unchanged. See [development.md](devel | 2026-10-10 | major | Full re-measure against Illustrator 2026 30.x from code counts, open issues and public docs; added breadth/depth/ready-for-real-work numbers, generative-AI row, calibration from PR timestamps; merged the ROADMAP's parity estimate, honest assessment and "Shipped so far" into this file | | 2026-10-05 | major | (in ROADMAP.md) M3 and M4 rows re-derived after the paint and files milestones | | 2026-10-02 | major | (in ROADMAP.md) First 22-area weighted estimate | + +> **Tab-drag status (2026-10-22):** updated from upstream, including the Turkish catalog and all three tab-drag translations requested in #3479; Selection-tool reset regression-checked and clipboard evidence scoped to internal state; drag lifecycle, insertion and regression contracts documented; behavior, scores and estimates unchanged. + +| Date | Change | Tab-drag history | +|---|---|---| +| 2026-10-22 | minor | Verify Selection-tool reset after tab activation, drop and cancellation; narrow clipboard evidence to the tested internal clipboard and correct documentation grammar; behavior, scores and estimates unchanged. | +| 2026-10-22 | minor | Merge current main and complete the Turkish tab-drag labels and validation messages requested by the maintainer; all 24 catalogs covered; scores and estimates unchanged. | +| 2026-10-22 | minor | Tab-drag follow-up: reconcile upstream progress updates and document drag lifecycle, insertion behavior and regression contracts; behavior, scores and estimates unchanged. | +| 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 | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 93e6fda8c..dee75ec29 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -212,6 +212,10 @@ empty), without opening the dialog. ## Panels, Control bar, Properties, context menus +| Interaction | Status | Evidence | +|---|---|---| +| Selection artwork drag hovers a visible document tab to activate it, then drops editable artwork on its canvas | done | `tests_document_drag`, `edit.dropSelection`, lossless resource and one-step Undo regressions; overflow and cross-window drag remain separate | + | Behaviour | Status | Notes | |---|---|---| | Effect > Warp Options: style previews, orientation choices and percent controls | done | #1760; focused input, mixed-style and history checks passed; original captures reviewed. No geometry or optional Properties changes. | @@ -278,6 +282,14 @@ atomically; disable Transparency to preserve object transparency while sampling `tests_eyedropper.rs` covers ranges, Unicode, history and transparency; tool tests cover brush-key sampling coexistence. Scores and estimates unchanged. See [development.md](development.md#highlighted-text-eyedropper-2026-10-21). +## Drag artwork between document tabs (2026-10-22) + +Selection drags activate another visible document tab after 400 ms, then copy editable artwork +on canvas release with the original grab point. Source and clipboard preservation, resource +rendering, swatch conflicts, cancellation, rotated/zoomed views and target Undo/Redo have regression +coverage (`tests_document_drag`, `tests_clipboard`). Overflow-menu and cross-window drag remain +separate. Scores and hours unchanged. [Behavior and command details](development.md#drag-artwork-between-document-tabs-2026-10-22). + ## Revision history | Date | Change | Summary | @@ -370,3 +382,12 @@ sampling coexistence. Scores and estimates unchanged. See [development.md](devel | 2026-10-11 | minor | Edit Toolbar opens the All Tools drawer beside the toolbar instead of a centred dialog, dims the tools the toolbar holds, and edits the toolbar by drag and drop while it is open (#1018); score unchanged (estimated) | | 2026-10-11 | minor | Direct Selection: dragging a path selected whole by an anchor moves it whole; Alt-drag copies by an anchor too, toggles during the drag and snaps onto the original (#1104); score unchanged (estimated) | | 2026-10-10 | major | First checklist, built from the tool sources and tests against Illustrator 2026's public documentation | + +> **Tab-drag status (2026-10-22):** updated from upstream, including the Turkish catalog and all three tab-drag translations requested in #3479; Selection-tool reset regression-checked and clipboard evidence scoped to internal state; drag lifecycle, insertion and regression contracts documented; behavior, scores and estimates unchanged. + +| Date | Change | Tab-drag history | +|---|---|---| +| 2026-10-22 | minor | Verify Selection-tool reset after tab activation, drop and cancellation; narrow clipboard evidence to the tested internal clipboard and correct documentation grammar; behavior, scores and estimates unchanged. | +| 2026-10-22 | minor | Merge current main and complete the Turkish tab-drag labels and validation messages requested by the maintainer; all 24 catalogs covered; scores and estimates unchanged. | +| 2026-10-22 | minor | Tab-drag follow-up: reconcile upstream progress updates and document drag lifecycle, insertion behavior and regression contracts; behavior, scores and estimates unchanged. | +| 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 |