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