From f346727dd8d206de24a1dc2fad6dfcd11f427e9b Mon Sep 17 00:00:00 2001 From: bflatastic Date: Mon, 5 Oct 2026 08:38:08 +0700 Subject: [PATCH] M4.23: paste placement, Paste Remembers Layers and the Paste menu's enable rule Paste (also without formatting) goes to the centre of the view (edit.paste {center}); Paste in Front/Back with nothing selected go to the top/bottom of the current layer, in order; Paste on All Artboards keeps the objects' offset to the artboard they were copied from. layer.pasteRemembersLayers {on?} (document flag paste_remembers_layers, saved, document.inspect) pastes objects back into the layers they came from by name, making missing ones; the Layers panel now has its menu with the layer commands and the check item. The Paste menu items are enabled while the system clipboard holds SVG (looked at once per frame at most, a few times a second). Co-Authored-By: Claude Opus 5.5 --- crates/doc/src/lib.rs | 5 ++ crates/engine/src/cmd/edit.rs | 57 +++++++++++--- crates/engine/src/cmd/layer.rs | 22 ++++++ crates/engine/src/cmd/typemenu.rs | 2 +- crates/engine/src/inspect.rs | 1 + crates/engine/src/tests_clipboard.rs | 109 +++++++++++++++++++++++++- crates/ui-egui/src/dock.rs | 7 +- crates/ui-egui/src/lib.rs | 32 ++++++++ crates/ui-egui/src/menus.rs | 3 +- crates/ui-egui/src/panels/layers.rs | 25 ++++++ crates/ui-egui/src/panels/mod.rs | 1 + crates/ui-egui/src/tests_clipboard.rs | 79 ++++++++++++++++++- docs/control-protocol.md | 6 ++ docs/mcp.md | 9 ++- 14 files changed, 339 insertions(+), 19 deletions(-) diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index b085c392..d5c6d5e3 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -472,6 +472,10 @@ pub struct Document { /// File → Document Setup (bleed, transparency grid, paper, type options). #[serde(default, skip_serializing_if = "skip::is_default")] pub setup: DocSetup, + /// Layers panel → Paste Remembers Layers: pasted objects go back into the layers (by name) + /// they were copied from instead of the current layer. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub paste_remembers_layers: bool, } fn ppi72() -> f64 { @@ -528,6 +532,7 @@ impl Document { page_knockout: false, spot_use_lab: true, setup: DocSetup::default(), + paste_remembers_layers: false, }; let id = d.alloc_id(); d.layers.push(Arc::new(Node::layer(id, "Layer 1", LayerColor::Preset(0)))); diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 8db3cf54..6919e182 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -1,5 +1,6 @@ //! File (document-level) and Edit commands, plus document queries. +use std::collections::BTreeMap; use std::sync::Arc; use serde_json::{Value, json}; @@ -44,7 +45,7 @@ pub fn specs() -> Vec { "Paste", ["Edit"], Some("Cmd+V"), - "{dx?, dy?, swatchConflict?} paste offset by dx/dy (default: the Paste Offset preference). Pasting brings the image blobs, symbols, patterns, global and spot swatches (with the tint swatches of the tints used), gradient swatches, graphic styles, character and paragraph styles and brushes the objects use; one of the same name that differs comes in renamed. swatchConflict, for a swatch whose name the document gives another colour (clipboard.conflicts): \"merge\" (default: the objects take the document's swatch) | \"add\" (the pasted swatch comes in renamed) | {name: \"merge\"|\"add\"} → {ids, added: resources added, merged: conflicts merged, renamed: [{kind, from, to}]}", + "{center?: [x, y], dx?, dy?, swatchConflict?} paste centred on `center` (the app passes the view centre), else offset by dx/dy (default: the Paste Offset preference). Pasting brings the image blobs, symbols, patterns, global and spot swatches (with the tint swatches of the tints used), gradient swatches, graphic styles, character and paragraph styles and brushes the objects use; one of the same name that differs comes in renamed. swatchConflict, for a swatch whose name the document gives another colour (clipboard.conflicts): \"merge\" (default: the objects take the document's swatch) | \"add\" (the pasted swatch comes in renamed) | {name: \"merge\"|\"add\"}. With Paste Remembers Layers on (layer.pasteRemembersLayers), objects go back into the layers they came from (by name; made when missing) → {ids, added: resources added, merged: conflicts merged, renamed: [{kind, from, to}]}", has_clipboard, |s, p| paste(s, p, PasteMode::Offset) ), @@ -53,7 +54,7 @@ pub fn specs() -> Vec { "Paste in Front", ["Edit"], Some("Cmd+F"), - "{swatchConflict?} paste in place just above the top selected object (resources as edit.paste) → {ids, added, merged, renamed}", + "{swatchConflict?} paste in place just above the top selected object, or on top of the current layer when nothing is selected (resources and layers as edit.paste) → {ids, added, merged, renamed}", has_clipboard, |s, p| paste(s, p, PasteMode::Front) ), @@ -62,7 +63,7 @@ pub fn specs() -> Vec { "Paste in Back", ["Edit"], Some("Cmd+B"), - "{swatchConflict?} paste in place just below the bottom selected object (resources as edit.paste) → {ids, added, merged, renamed}", + "{swatchConflict?} paste in place just below the bottom selected object, or at the bottom of the current layer when nothing is selected (resources and layers as edit.paste) → {ids, added, merged, renamed}", has_clipboard, |s, p| paste(s, p, PasteMode::Back) ), @@ -71,7 +72,7 @@ pub fn specs() -> Vec { "Paste in Place", ["Edit"], Some("Cmd+Shift+V"), - "{swatchConflict?} paste where the objects were copied (resources as edit.paste) → {ids, added, merged, renamed}", + "{swatchConflict?} paste where the objects were copied (resources and layers as edit.paste) → {ids, added, merged, renamed}", has_clipboard, |s, p| paste(s, p, PasteMode::InPlace) ), @@ -80,7 +81,7 @@ pub fn specs() -> Vec { "Paste on All Artboards", ["Edit"], Some("Cmd+Alt+Shift+V"), - "{swatchConflict?} paste a copy on every artboard (resources as edit.paste) → {ids, added, merged, renamed}", + "{swatchConflict?} paste a copy on every artboard at the offset the objects had to the artboard they were copied from (resources and layers as edit.paste) → {ids, added, merged, renamed}", has_clipboard, |s, p| paste(s, p, PasteMode::AllArtboards) ), @@ -244,7 +245,10 @@ fn paste_clip(s: &mut Session, p: &Value, mode: PasteMode, clip: &Clipboard, cho let st = s.doc()?; let same_doc = clip.source_doc == Some(st.uid); let parent = st.insertion_parent(); - // Front/back: relative to the selection (top-most / bottom-most selected object). + // Paste Remembers Layers (not while isolating a group: pastes stay in it). + let remember = st.doc.paste_remembers_layers && 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 { PasteMode::Front | PasteMode::Back => { let order = st.selection.in_paint_order(&st.doc); @@ -254,15 +258,21 @@ fn paste_clip(s: &mut Session, p: &Value, mode: PasteMode, clip: &Clipboard, cho _ => None, }; let placements: Vec = match mode { - PasteMode::Offset => vec![Affine::translate((f64_or(p, "dx", off), f64_or(p, "dy", off)))], + PasteMode::Offset => 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))), + }], PasteMode::AllArtboards => { - let src = st.doc.artboards.first().map(|a| a.rect.origin()).unwrap_or_default(); + let src = clip.source_artboard.or_else(|| st.doc.artboards.first().map(|a| a.rect)).map(|r| r.origin()).unwrap_or_default(); st.doc.artboards.iter().map(|a| Affine::translate(a.rect.origin() - src)).collect() } _ => vec![Affine::IDENTITY], }; let (ids, imported) = s.edit(mode.label(), |d, sel| { let imported = clip.import_into(d, choices, same_doc); + let mut layers: BTreeMap<&str, NodeId> = BTreeMap::new(); + // Objects pasted into each parent so far (keeps their order in front and back pastes). + let mut placed: BTreeMap, usize> = BTreeMap::new(); let mut new_ids = vec![]; for xf in placements { for (k, n) in clip.nodes.iter().enumerate() { @@ -271,10 +281,22 @@ fn paste_clip(s: &mut Session, p: &Value, mode: PasteMode, clip: &Clipboard, cho if xf != Affine::IDENTITY { c.transform(xf, false); } - let (par, idx) = match anchor { - Some((par, i, _)) => (par, if mode == PasteMode::Front { i + 1 + k } else { i + k }), - None => (parent, usize::MAX), + let layer = match clip.source_layers.get(k) { + Some(Some(name)) if remember => Some(*layers.entry(name).or_insert_with(|| layer_named(d, name))), + _ => None, }; + let (par, idx) = match (anchor, layer) { + // Next to the anchor, unless the object goes back to another layer. + (Some((par, i, _)), l) if l.is_none() || l == par => { + let k = placed.get(&par).copied().unwrap_or(0); + (par, if mode == PasteMode::Front { i + 1 + k } else { i + k }) + } + (_, l) => { + let par = l.or(parent); + (par, if mode == PasteMode::Back { placed.get(&par).copied().unwrap_or(0) } else { usize::MAX }) + } + }; + *placed.entry(par).or_default() += 1; new_ids.push(d.insert(par, idx, c)?); } } @@ -289,6 +311,19 @@ fn paste_clip(s: &mut Session, p: &Value, mode: PasteMode, clip: &Clipboard, cho })) } +/// The first unlocked layer or sublayer called `name` (topmost first), or a new top layer. +fn layer_named(d: &mut Document, name: &str) -> NodeId { + let mut found = None; + for l in d.layers.iter().rev() { + l.walk(&mut |n| { + if found.is_none() && n.is_layer() && !n.locked && n.name.as_deref() == Some(name) { + found = Some(n.id); + } + }); + } + found.unwrap_or_else(|| d.add_layer(Some(name))) +} + fn duplicate(s: &mut Session, p: &Value) -> Result { let roots = selected_roots(s)?; let dx = f64_or(p, "dx", 0.0); diff --git a/crates/engine/src/cmd/layer.rs b/crates/engine/src/cmd/layer.rs index 2ce07033..f7f60ea0 100644 --- a/crates/engine/src/cmd/layer.rs +++ b/crates/engine/src/cmd/layer.rs @@ -65,9 +65,31 @@ pub fn specs() -> Vec { has_doc, target ), + cmd!( + "layer.pasteRemembersLayers", + "Paste Remembers Layers", + ["Window", "Layers"], + None, + "{on?} (default: toggle) the document option: on, the Paste commands put objects back into the layers they were copied from (by name; a missing one is made on top), off into the current layer; one undo step when it changes (document.inspect → pasteRemembersLayers) → {on}", + has_doc, + paste_remembers_layers + ), ] } +/// Layers panel → Paste Remembers Layers. +fn paste_remembers_layers(s: &mut Session, p: &Value) -> Result { + let cur = s.doc()?.doc.paste_remembers_layers; + let on = bool_or(p, "on", !cur); + if on != cur { + s.edit("Paste Remembers Layers", |d, _| { + d.paste_remembers_layers = on; + Ok(()) + })?; + } + Ok(json!({ "on": on })) +} + /// The Layers panel's target circle. fn target(s: &mut Session, p: &Value) -> Result { const C: &str = "layer.target"; diff --git a/crates/engine/src/cmd/typemenu.rs b/crates/engine/src/cmd/typemenu.rs index ff45ead4..a114ea7f 100644 --- a/crates/engine/src/cmd/typemenu.rs +++ b/crates/engine/src/cmd/typemenu.rs @@ -97,7 +97,7 @@ pub fn specs() -> Vec { "Paste without Formatting", ["Edit"], Some("Cmd+Alt+V"), - "{dx?, dy?, swatchConflict?} paste as edit.paste; pasted text takes the default character style (one run) and leaves its character and paragraph styles behind → {ids, added, merged, renamed}", + "{center?, dx?, dy?, swatchConflict?} paste as edit.paste; pasted text takes the default character style (one run) and leaves its character and paragraph styles behind → {ids, added, merged, renamed}", has_clipboard, paste_plain ), diff --git a/crates/engine/src/inspect.rs b/crates/engine/src/inspect.rs index d2440cb7..704af969 100644 --- a/crates/engine/src/inspect.rs +++ b/crates/engine/src/inspect.rs @@ -71,6 +71,7 @@ pub fn document(s: &Session) -> Value { "objects": d.node_count(), "tool": s.tool_id(), "paint": {"fill": s.paint.fill.label(), "stroke": s.paint.stroke.label(), "strokeWidth": s.paint.stroke_width, "fillActive": s.fill_active, "appearanceItem": s.appearance_item()}, + "pasteRemembersLayers": d.paste_remembers_layers, }) } diff --git a/crates/engine/src/tests_clipboard.rs b/crates/engine/src/tests_clipboard.rs index f5b646c1..174948ad 100644 --- a/crates/engine/src/tests_clipboard.rs +++ b/crates/engine/src/tests_clipboard.rs @@ -1,9 +1,10 @@ -//! Copy and paste between documents: resources and swatch conflicts (M4.22). +//! Copy and paste between documents (resources, swatch conflicts) and paste placement (M4.22, +//! M4.23). use serde_json::{Value, json}; use vectorcraft_color::{Color, Paint}; use vectorcraft_doc::{Document, Node, NodeKind}; -use vectorcraft_geom::Rect; +use vectorcraft_geom::{Point, Rect}; use super::*; @@ -491,3 +492,107 @@ fn a_failed_batch_puts_the_clipboard_back() { assert!(s.execute("command.batch", &json!({"commands": steps})).is_err()); assert_eq!(s.clipboard.nodes.len(), 1); } + +// ---------- M4.23: placement ---------- + +#[test] +fn paste_centres_on_the_given_point() { + let mut s = session(); + let r = rect(&mut s, 0.0, 0.0, 40.0, 20.0); + copy(&mut s, &[r]); + let v = s.execute("edit.paste", &json!({"center": [300, 200]})).unwrap(); + let b = doc(&s).bounds_of(&ids_of(&v), false).unwrap(); + assert!((b.center() - Point::new(300.0, 200.0)).hypot() < 1e-9, "{b:?}"); + // Without a centre, the offset applies. + let v = s.execute("edit.paste", &json!({"dx": 5, "dy": 7})).unwrap(); + assert_eq!(doc(&s).bounds_of(&ids_of(&v), false).unwrap().origin(), Point::new(5.0, 7.0)); +} + +#[test] +fn front_and_back_without_a_selection_use_the_current_layers_ends() { + let mut s = session(); + let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0); + let b = rect(&mut s, 20.0, 0.0, 10.0, 10.0); + copy(&mut s, &[a, b]); + let layer = |s: &Session| doc(s).layers[0].children().unwrap().iter().map(|n| n.id).collect::>(); + s.execute("select.none", &json!({})).unwrap(); + let back = ids_of(&s.execute("edit.pasteInBack", &json!({})).unwrap()); + assert_eq!(&layer(&s)[..2], &back[..], "pasted at the bottom (index 0), in order"); + s.execute("select.none", &json!({})).unwrap(); + let front = ids_of(&s.execute("edit.pasteInFront", &json!({})).unwrap()); + let l = layer(&s); + assert_eq!(&l[l.len() - 2..], &front[..], "pasted on top, in order"); + // With a selection: right above / below it. + sel(&mut s, &[a]); + let above = ids_of(&s.execute("edit.pasteInFront", &json!({})).unwrap()); + let l = layer(&s); + let i = l.iter().position(|id| *id == a).unwrap(); + assert_eq!(&l[i + 1..i + 3], &above[..]); + sel(&mut s, &[a]); + let below = ids_of(&s.execute("edit.pasteInBack", &json!({})).unwrap()); + let l = layer(&s); + let i = l.iter().position(|id| *id == a).unwrap(); + assert_eq!(&l[i - 2..i], &below[..]); +} + +#[test] +fn paste_on_all_artboards_keeps_the_offset_to_the_source_artboard() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 200, "height": 100, "artboards": 3})).unwrap(); + let boards: Vec = doc(&s).artboards.iter().map(|a| a.rect).collect(); + // On artboard 2, 10 pt right and 15 pt down from its corner. + let r = rect(&mut s, boards[1].x0 + 10.0, boards[1].y0 + 15.0, 20.0, 20.0); + copy(&mut s, &[r]); + assert_eq!(s.clipboard.source_artboard, Some(boards[1])); + let ids = ids_of(&s.execute("edit.pasteOnAllArtboards", &json!({})).unwrap()); + assert_eq!(ids.len(), 3); + for (id, b) in ids.iter().zip(&boards) { + assert_eq!(doc(&s).bounds_of(&[*id], false).unwrap().origin(), Point::new(b.x0 + 10.0, b.y0 + 15.0)); + } +} + +#[test] +fn paste_remembers_layers_restores_the_source_layers() { + let mut s = session(); + let first = doc(&s).layers[0].id; + let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0); + let art = id_of(&s.execute("layer.new", &json!({"name": "Art"})).unwrap()); + let b = rect(&mut s, 20.0, 0.0, 10.0, 10.0); + copy(&mut s, &[a, b]); + assert_eq!(s.clipboard.source_layers, [Some("Layer 1".to_string()), Some("Art".to_string())]); + let other = id_of(&s.execute("layer.new", &json!({"name": "Other"})).unwrap()); + assert_eq!(s.execute("layer.pasteRemembersLayers", &json!({})).unwrap()["on"], true); + assert!(doc(&s).paste_remembers_layers); + assert_eq!(s.execute("document.inspect", &json!({})).unwrap()["pasteRemembersLayers"], true); + let ids = ids_of(&s.execute("edit.paste", &json!({})).unwrap()); + let parents: Vec<_> = ids.iter().map(|id| doc(&s).parent_of(*id)).collect(); + assert_eq!(parents, [Some(first), Some(art)]); + // In a document without those layers they are made. + new_doc(&mut s); + s.execute("layer.pasteRemembersLayers", &json!({"on": true})).unwrap(); + let ids = ids_of(&s.execute("edit.paste", &json!({})).unwrap()); + let names: Vec<_> = ids.iter().map(|id| doc(&s).node(doc(&s).parent_of(*id).unwrap()).unwrap().name.clone().unwrap()).collect(); + assert_eq!(names, ["Layer 1", "Art"]); + assert_eq!(doc(&s).layers.len(), 2, "Layer 1 exists; Art is new"); + // The option is an undoable document setting. + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(!doc(&s).paste_remembers_layers); + // Off: everything goes to the current layer. + s.execute("document.activate", &json!({"index": 0})).unwrap(); + assert_eq!(s.execute("layer.pasteRemembersLayers", &json!({"on": false})).unwrap()["on"], false); + s.execute("layer.setCurrent", &json!({"id": other.0})).unwrap(); + let ids = ids_of(&s.execute("edit.paste", &json!({})).unwrap()); + assert!(ids.iter().all(|id| doc(&s).parent_of(*id) == Some(other))); +} + +#[test] +fn paste_remembers_layers_survives_save_and_old_files_load_without_it() { + let mut s = session(); + s.execute("layer.pasteRemembersLayers", &json!({"on": true})).unwrap(); + let bytes = vectorcraft_format::save(doc(&s), false); + assert!(vectorcraft_format::load(&bytes).unwrap().paste_remembers_layers); + let v: Value = serde_json::from_slice(&vectorcraft_format::save(&Document::new(10.0, 10.0), false)).unwrap(); + 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); +} diff --git a/crates/ui-egui/src/dock.rs b/crates/ui-egui/src/dock.rs index 985e5c9d..67ef44ce 100644 --- a/crates/ui-egui/src/dock.rs +++ b/crates/ui-egui/src/dock.rs @@ -41,7 +41,12 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { } x = r.right(); } - icons::paint(ui, "menu", egui::Rect::from_center_size(strip.right_center() - vec2(14.0, 0.0), vec2(14.0, 14.0)), t.text_dim); + let menu_id = match app.ui.dock_tab { + DockTab::Properties => "properties", + DockTab::Layers => "layers", + DockTab::Libraries => "libraries", + }; + panels::panel_menu(app, ui, menu_id, egui::Rect::from_center_size(strip.right_center() - vec2(14.0, 0.0), vec2(16.0, 16.0))); egui::Frame::NONE.inner_margin(egui::Margin { left: 12, right: 10, top: 10, bottom: 8 }).show(ui, |ui| match app.ui.dock_tab { DockTab::Properties => { egui::ScrollArea::vertical().id_salt("props").auto_shrink([false, false]).show(ui, |ui| panels::properties::show(app, ui)); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 0ea9bfd2..f81f8f99 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -176,8 +176,16 @@ pub struct VectorcraftApp { pub custom_titlebar: bool, /// File → Place: picked files, the place cursor's thumbnails, the Control bar's image details. pub place: place::PlaceState, + /// The Paste commands can paste from the system clipboard alone: it holds SVG while the + /// internal clipboard is empty. It enables the Paste menu items. + pub(crate) system_svg: bool, + /// When `system_svg` was last checked (app time, s; at most once per frame). + system_svg_at: f64, } +/// Seconds between two looks at the system clipboard for [`VectorcraftApp::system_svg`]. +const SYSTEM_CLIPBOARD_POLL: f64 = 0.25; + impl VectorcraftApp { pub fn new(session: Session, services: Services) -> Self { let views = session.documents().iter().map(|_| View::default()).collect(); @@ -216,6 +224,8 @@ impl VectorcraftApp { hover_doc: None, custom_titlebar: false, place: Default::default(), + system_svg: false, + system_svg_at: f64::NEG_INFINITY, } } @@ -260,8 +270,17 @@ impl VectorcraftApp { if let Some(r) = menus::run_ui_command(self, id, ¶ms) { return r; } + let mut params = params; if id.starts_with("edit.paste") { self.adopt_system_clipboard(); + // Paste (also without formatting) goes to the centre of the view. + if matches!(id, "edit.paste" | "edit.pasteWithoutFormatting") + && ["center", "dx", "dy"].iter().all(|k| params.get(k).is_none()) + && let Some(c) = self.view().filter(|v| v.fitted).map(|v| v.center) + && let Some(p) = params.as_object_mut() + { + p.insert("center".into(), serde_json::json!([c.x, c.y])); + } if let Some(r) = dialogs::swatch_conflict::ask(self, id, ¶ms) { return r; } @@ -476,6 +495,19 @@ impl VectorcraftApp { } self.last_time = now; self.sync_views(); + // Read the system clipboard only when that alone decides whether Paste is enabled, and at + // most a few times a second (opening it locks it against other apps on some systems). + if !(0.0..SYSTEM_CLIPBOARD_POLL).contains(&(now - self.system_svg_at)) { + self.system_svg_at = now; + self.system_svg = self.session.clipboard.is_empty() + && self.session.active().is_some() + && self + .services + .clipboard_read + .as_mut() + .and_then(|read| read()) + .is_some_and(|t| vectorcraft_engine::cmd::clipboard::looks_like_svg(&t)); + } // The window's close button (or the system quitting the app) asks about unsaved documents. if ctx.input(|i| i.viewport().close_requested()) && unsaved::any_dirty(self) { ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose); diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index c996b210..f07429ab 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -899,7 +899,8 @@ pub fn shortcut_of(id: &str) -> Option<&'static str> { /// Is a command currently enabled? pub fn enabled(app: &VectorcraftApp, id: &str) -> bool { if let Some(c) = vectorcraft_engine::find_command(id) { - return (c.enabled)(&app.session).is_ok(); + // SVG on the system clipboard can be pasted with an empty internal clipboard. + return (c.enabled)(&app.session).is_ok() || app.system_svg && id.starts_with("edit.paste"); } match id { "file.save" diff --git a/crates/ui-egui/src/panels/layers.rs b/crates/ui-egui/src/panels/layers.rs index 03ce91bb..802734b7 100644 --- a/crates/ui-egui/src/panels/layers.rs +++ b/crates/ui-egui/src/panels/layers.rs @@ -397,6 +397,31 @@ fn thumb(ui: &Ui, n: &Node, r: egui::Rect) { }); } +/// The Layers panel's (≡) menu. +pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { + const ITEMS: [(&str, &str); 6] = [ + ("New Layer…", "layer.new"), + ("New Sublayer…", "layer.newSublayer"), + ("Duplicate Layer", "layer.duplicate"), + ("Delete Layer", "layer.delete"), + ("Make/Release Clipping Mask", "layer.clippingMask.toggle"), + ("Collect in New Layer", "layer.collectInNew"), + ]; + for (i, (label, cmd)) in ITEMS.into_iter().enumerate() { + if i == 4 { + ui.separator(); + } + if widgets::menu_item(ui, label, crate::menus::enabled(app, cmd), false) { + app.run(cmd, json!({})).ok(); + } + } + ui.separator(); + let remembers = app.session.active().is_some_and(|d| d.doc.paste_remembers_layers); + if widgets::menu_item(ui, "Paste Remembers Layers", app.session.active().is_some(), remembers) { + app.run("layer.pasteRemembersLayers", json!({"on": !remembers})).ok(); + } +} + #[cfg(test)] mod tests { use serde_json::json; diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index 9906efe8..198cf910 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -135,6 +135,7 @@ pub fn panel_menu_items(app: &mut VectorcraftApp, ui: &mut Ui, id: &str) { flattener_preview::ID => flattener_preview::menu(app, ui), "attributes" => attributes::menu(app, ui), "colorThemes" => color_themes::menu(app, ui), + "layers" => layers::menu(app, ui), _ => { ui.add_enabled(false, egui::Button::new("No options").frame(false)); } diff --git a/crates/ui-egui/src/tests_clipboard.rs b/crates/ui-egui/src/tests_clipboard.rs index 1c673d3f..d8deda39 100644 --- a/crates/ui-egui/src/tests_clipboard.rs +++ b/crates/ui-egui/src/tests_clipboard.rs @@ -1,11 +1,13 @@ -//! Paste in the app: the Swatch Conflict dialog. +//! Paste in the app: the Swatch Conflict dialog, pasting at the view centre, the Paste menu items +//! enabled by SVG on the system clipboard, and the Layers panel's Paste Remembers Layers. use serde_json::{Value, json}; use vectorcraft_color::{Color, Paint}; use vectorcraft_doc::NodeId; use vectorcraft_engine::Session; +use vectorcraft_geom::Point; -use crate::{VectorcraftApp, dialogs}; +use crate::{Services, VectorcraftApp, dialogs, menus}; fn run(app: &mut VectorcraftApp, id: &str, p: Value) -> Value { app.run(id, p).unwrap_or_else(|e| panic!("{id}: {e}")) @@ -101,3 +103,76 @@ fn apply_to_all_answers_the_rest_and_cancel_pastes_nothing() { assert_eq!(v["merged"], 2); assert!(app.ui.dialog.is_none()); } + +#[test] +fn paste_goes_to_the_centre_of_the_view() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + run(&mut app, "file.new", json!({"width": 400, "height": 300})); + let r = rect(&mut app); + copy(&mut app, r); + let v = app.view_mut().unwrap(); + (v.center, v.fitted) = (Point::new(250.0, 180.0), true); + let ids: Vec = run(&mut app, "edit.paste", json!({}))["ids"].as_array().unwrap().iter().map(|i| NodeId(i.as_u64().unwrap())).collect(); + let b = app.session.active().unwrap().doc.bounds_of(&ids, true).unwrap(); + assert!((b.center() - Point::new(250.0, 180.0)).hypot() < 1e-6, "{b:?}"); +} + +const SVG: &str = r##""##; + +/// An app whose system clipboard holds `text`, with a document, after one frame. +fn with_system_clipboard(text: &'static str) -> VectorcraftApp { + let services = Services { clipboard_read: Some(Box::new(move || Some(text.to_string()))), ..Default::default() }; + let mut app = VectorcraftApp::new(Session::new(), services); + run(&mut app, "file.new", json!({"width": 200, "height": 200})); + assert!(!menus::enabled(&app, "edit.paste"), "not checked before a frame"); + let ctx = egui::Context::default(); + ctx.run_ui(Default::default(), |ui| app.logic(ui.ctx())).textures_delta.clear(); + app +} + +#[test] +fn paste_menu_items_are_enabled_by_svg_on_the_system_clipboard() { + let mut app = with_system_clipboard(SVG); + assert!(app.session.clipboard.is_empty()); + for id in ["edit.paste", "edit.pasteInFront", "edit.pasteInBack", "edit.pasteInPlace", "edit.pasteOnAllArtboards", "edit.pasteWithoutFormatting"] + { + assert!(menus::enabled(&app, id), "{id}"); + } + let entry = menus::menu_entries(&app).into_iter().find(|e| e.command.as_deref() == Some("edit.paste")).unwrap(); + assert!(entry.enabled); + // Choosing it pastes the SVG's objects. + menus::invoke(&mut app, "edit.paste", json!({})); + assert_eq!(objects(&app).len(), 2); + // Plain text is not something to paste. + let app = with_system_clipboard("plain text"); + assert!(!menus::enabled(&app, "edit.paste")); +} + +#[test] +fn the_layers_panel_menu_toggles_paste_remembers_layers() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + run(&mut app, "file.new", json!({"width": 200, "height": 200})); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let frame = |app: &mut VectorcraftApp, events: Vec| { + let mut out = ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| crate::panels::layers::menu(app, ui)); + out.textures_delta.clear(); + let mut texts = vec![]; + for c in &out.shapes { + if let egui::Shape::Text(t) = &c.shape { + texts.push((t.galley.text().to_string(), t.visual_bounding_rect())); + } + } + texts + }; + let item = |texts: &[(String, egui::Rect)]| texts.iter().find(|(t, _)| t.ends_with("Paste Remembers Layers")).cloned().unwrap(); + let (label, r) = item(&frame(&mut app, vec![])); + assert!(!label.starts_with('✓')); + let button = + |pressed| egui::Event::PointerButton { pos: r.center(), button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + for e in [egui::Event::PointerMoved(r.center()), button(true), button(false)] { + frame(&mut app, vec![e]); + } + assert!(app.session.active().unwrap().doc.paste_remembers_layers); + assert!(item(&frame(&mut app, vec![])).0.starts_with('✓'), "checked"); +} diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 814fdf86..e5b4cace 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -233,3 +233,9 @@ dialog instead and answers `{dialog: "swatchConflict"}`. Fields: `conflicts` (`[ spot}]`), `index` (the conflict asked about), `choice` (`merge` or `add`), `applyToAll` and `choices` (the answers so far). `ui.dialog.confirm` records the answer and asks about the next conflict (with `applyToAll`, answers the rest the same); the last one runs the paste with `swatchConflict`. `ui.dialog.cancel` pastes nothing. + +Paste placement: through the app, `edit.paste` and `edit.pasteWithoutFormatting` without `center`, `dx` or `dy` +paste at the centre of the view. The Paste menu items are enabled while the system clipboard holds SVG, even with +nothing copied in the app (looked at up to four times a second; `ui.menu.list` shows it). The Layers panel menu (≡ on +the dock's tab strip while Layers shows) lists the layer commands and Paste Remembers Layers +(`layer.pasteRemembersLayers`, checked when on). diff --git a/docs/mcp.md b/docs/mcp.md index d22ef971..ffd98e2e 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -881,7 +881,14 @@ them, and `swatchConflict` answers: `"merge"` (the default: the objects take the pasted swatch comes in renamed) or one answer per name. Pasting back into the document the objects came from uses its resources as they are now and raises no conflict. +Placement: `edit.paste {center}` centres the objects on a point (the app passes the view centre), else offsets them +by `dx`/`dy` (the Paste Offset preference). With nothing selected, `edit.pasteInFront` / `edit.pasteInBack` put them +on top / at the bottom of the current layer. `edit.pasteOnAllArtboards` keeps their offset to the artboard they were +copied from. `layer.pasteRemembersLayers {on?}` (a document option, `document.inspect` → `pasteRemembersLayers`) +pastes objects back into the layers they came from, by name, making missing ones. + ```json {"name":"run_command","arguments":{"command":"clipboard.conflicts","params":{}}} -{"name":"run_command","arguments":{"command":"edit.paste","params":{"swatchConflict":{"Brand":"add"}}}} +{"name":"run_command","arguments":{"command":"edit.paste","params":{"center":[300,200],"swatchConflict":{"Brand":"add"}}}} +{"name":"run_command","arguments":{"command":"layer.pasteRemembersLayers","params":{"on":true}}} ```