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 <noreply@anthropic.com>
This commit is contained in:
parent
ee2aa96dcd
commit
f346727dd8
|
|
@ -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))));
|
||||
|
|
|
|||
|
|
@ -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<CommandSpec> {
|
|||
"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<CommandSpec> {
|
|||
"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<CommandSpec> {
|
|||
"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<CommandSpec> {
|
|||
"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<CommandSpec> {
|
|||
"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<Affine> = 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<Option<NodeId>, 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<Value> {
|
||||
let roots = selected_roots(s)?;
|
||||
let dx = f64_or(p, "dx", 0.0);
|
||||
|
|
|
|||
|
|
@ -65,9 +65,31 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
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<Value> {
|
||||
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<Value> {
|
||||
const C: &str = "layer.target";
|
||||
|
|
|
|||
|
|
@ -97,7 +97,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
"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
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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::<Vec<_>>();
|
||||
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<Rect> = 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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<NodeId> = 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##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 10"><rect width="5" height="5"/><circle cx="15" cy="5" r="5"/></svg>"##;
|
||||
|
||||
/// 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<egui::Event>| {
|
||||
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");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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}}}
|
||||
```
|
||||
|
|
|
|||
Loading…
Reference in New Issue