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

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