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