mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-11 02:31:22 +00:00
Merge pull request #5085 from mockydevs/feat/copy-paste-guides-3226
Fix #3226: copy and paste ruler guides
This commit is contained in:
@@ -168,9 +168,9 @@ pub fn has_selection(s: &Session) -> std::result::Result<(), String> {
|
||||
let st = s.active().ok_or("no document open")?;
|
||||
if st.selection.is_empty() { Err("nothing selected".into()) } else { Ok(()) }
|
||||
}
|
||||
/// Objects are selected, or the Artboard tool is chosen (Copy and Cut take its artboard).
|
||||
pub fn has_selection_or_artboard_tool(s: &Session) -> std::result::Result<(), String> {
|
||||
if s.tool_id() == "artboard" { has_doc(s) } else { has_selection(s) }
|
||||
/// Objects or ruler guides are selected, or the Artboard tool is chosen (what Copy and Cut take).
|
||||
pub fn has_selection_guides_or_artboard_tool(s: &Session) -> std::result::Result<(), String> {
|
||||
if s.tool_id() == "artboard" { has_doc(s) } else { has_selection_or_guides(s) }
|
||||
}
|
||||
/// Objects or ruler guides are selected (what Delete and the arrow keys act on).
|
||||
pub fn has_selection_or_guides(s: &Session) -> std::result::Result<(), String> {
|
||||
|
||||
@@ -93,11 +93,20 @@ pub struct Clipboard {
|
||||
/// The artboards copied with the Artboard tool (`artboard.copy`); `nodes` are then their art.
|
||||
/// Pasting adds a copy of each with the art.
|
||||
pub artboards: Vec<vectorcraft_doc::Artboard>,
|
||||
/// The selected ruler guides copied with (or without) the objects (#3226), as canvas guides on
|
||||
/// no layer: pasting puts them on the layer new art goes to, moved as the objects are.
|
||||
pub guides: Vec<vectorcraft_doc::Guide>,
|
||||
}
|
||||
|
||||
impl Clipboard {
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.nodes.is_empty() && self.artboards.is_empty()
|
||||
self.nodes.is_empty() && self.artboards.is_empty() && self.guides.is_empty()
|
||||
}
|
||||
|
||||
/// Does it hold art (objects or artboards) for other apps? Ruler guides stay in VectorCraft:
|
||||
/// a Copy of guides alone empties the system clipboard (#3226).
|
||||
pub fn has_art(&self) -> bool {
|
||||
!self.nodes.is_empty() || !self.artboards.is_empty()
|
||||
}
|
||||
|
||||
/// Objects `roots` of `st`'s document with their resources, source artboard and layers.
|
||||
@@ -195,7 +204,7 @@ impl Clipboard {
|
||||
|
||||
/// The objects as standalone SVG (`None` when there are none).
|
||||
pub fn svg(&self) -> Option<String> {
|
||||
if self.is_empty() {
|
||||
if !self.has_art() {
|
||||
return None;
|
||||
}
|
||||
let d = self.to_document();
|
||||
|
||||
@@ -286,7 +286,7 @@ impl Clipboard {
|
||||
|
||||
/// The MIME types of [`Self::flavours`], without making them.
|
||||
pub fn flavour_types(&self, svg: bool, pdf: bool) -> Vec<&'static str> {
|
||||
if self.is_empty() {
|
||||
if !self.has_art() {
|
||||
return vec![];
|
||||
}
|
||||
let text = (svg && self.image_bytes() <= COPY_TEXT_IMAGE_BYTES) || self.nodes.iter().all(type_only);
|
||||
|
||||
@@ -10,8 +10,8 @@ use vectorcraft_geom::{Affine, Point, Vec2};
|
||||
use super::clipboard::SwatchChoices;
|
||||
use super::{
|
||||
CommandSpec, Result, Session, always, bad, bool_or, can_clear_history, can_redo, can_undo, checked_ids_param, cmd, f64_or, f64_req,
|
||||
finite_numbers, has_clipboard, has_doc, has_selection, has_selection_or_artboard_tool, has_selection_or_guides, id_param, index, ok, point_param,
|
||||
str_param,
|
||||
finite_numbers, has_clipboard, has_doc, has_selection, has_selection_guides_or_artboard_tool, has_selection_or_guides, id_param, index, ok,
|
||||
point_param, str_param,
|
||||
};
|
||||
use crate::{Clipboard, EngineError, inspect};
|
||||
|
||||
@@ -125,8 +125,24 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
always,
|
||||
undo_group_end
|
||||
),
|
||||
cmd!("edit.cut", "Cut", ["Edit"], Some("Cmd+X"), "{} (the Artboard tool chosen: artboard.cut)", has_selection_or_artboard_tool, cut),
|
||||
cmd!("edit.copy", "Copy", ["Edit"], Some("Cmd+C"), "{} (the Artboard tool chosen: artboard.copy)", has_selection_or_artboard_tool, copy),
|
||||
cmd!(
|
||||
"edit.cut",
|
||||
"Cut",
|
||||
["Edit"],
|
||||
Some("Cmd+X"),
|
||||
"{} (selected ruler guides go too; the Artboard tool chosen: artboard.cut)",
|
||||
has_selection_guides_or_artboard_tool,
|
||||
cut
|
||||
),
|
||||
cmd!(
|
||||
"edit.copy",
|
||||
"Copy",
|
||||
["Edit"],
|
||||
Some("Cmd+C"),
|
||||
"{} the selection with what it uses, and the selected ruler guides (pasted on the layer new art goes to, moved as the objects are; alone, where they were); the Artboard tool chosen: artboard.copy → {copied, guides}",
|
||||
has_selection_guides_or_artboard_tool,
|
||||
copy
|
||||
),
|
||||
cmd!(
|
||||
"edit.paste",
|
||||
"Paste",
|
||||
@@ -177,7 +193,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Clear",
|
||||
["Edit"],
|
||||
Some("Delete"),
|
||||
"{ids?} delete the objects `ids`, else the selection (fully selected objects and direct-selected anchors together, else ruler guides)",
|
||||
"{ids?, guides?: bool (with ids: also delete the selected ruler guides)} delete the objects `ids`, else the selection (fully selected objects and direct-selected anchors together, else ruler guides)",
|
||||
has_selection_or_guides,
|
||||
clear
|
||||
),
|
||||
@@ -652,9 +668,35 @@ fn copy(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
if s.tool_id() == "artboard" {
|
||||
return super::panelcmds::artboard_copy(s, &json!({}), false);
|
||||
}
|
||||
let roots = selected_roots(s)?;
|
||||
s.clipboard = Clipboard::copy(s.doc()?, &roots);
|
||||
Ok(json!({ "copied": s.clipboard.nodes.len() }))
|
||||
let st = s.doc()?;
|
||||
// Selected ruler guides come along (#3226), as canvas guides: their layer and artboard stay
|
||||
// behind.
|
||||
let guides: Vec<vectorcraft_doc::Guide> = st
|
||||
.selection
|
||||
.guides
|
||||
.iter()
|
||||
.filter_map(|i| st.doc.guides.get(*i))
|
||||
.map(|g| vectorcraft_doc::Guide { layer: None, artboard: None, ..g.clone() })
|
||||
.collect();
|
||||
let roots = if st.selection.is_empty() && !guides.is_empty() { vec![] } else { selected_roots(s)? };
|
||||
let st = s.doc()?;
|
||||
let mut clip = Clipboard::copy(st, &roots);
|
||||
// Guides alone: the artboard the first one belongs to, or crosses, is where in-place pastes
|
||||
// measure from, as the objects' artboard is.
|
||||
if clip.nodes.is_empty()
|
||||
&& let Some(g) = st.selection.guides.first().and_then(|i| st.doc.guides.get(*i))
|
||||
{
|
||||
let board = g.artboard.and_then(|id| st.doc.artboards.iter().find(|a| a.id == id)).or_else(|| {
|
||||
st.doc
|
||||
.artboards
|
||||
.iter()
|
||||
.find(|a| if g.vertical { (a.rect.x0..=a.rect.x1).contains(&g.pos) } else { (a.rect.y0..=a.rect.y1).contains(&g.pos) })
|
||||
});
|
||||
clip.source_artboard = board.map(|a| a.rect);
|
||||
}
|
||||
clip.guides = guides;
|
||||
s.clipboard = clip;
|
||||
Ok(json!({ "copied": s.clipboard.nodes.len(), "guides": s.clipboard.guides.len() }))
|
||||
}
|
||||
|
||||
fn cut(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -663,9 +705,15 @@ fn cut(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
// Copy promotes a compound member to its compound. Cut must remove the same objects it
|
||||
// copied, while Clear on its own deletes exactly the selected members.
|
||||
// Ruler guides selected on their own: Copy takes them, Clear removes them.
|
||||
if s.doc()?.selection.is_empty() {
|
||||
copy(s, p)?;
|
||||
return clear(s, &json!({}));
|
||||
}
|
||||
let ids = selected_roots(s)?;
|
||||
copy(s, p)?;
|
||||
if s.doc()?.selection.anchors.is_empty() { clear(s, &json!({ "ids": ids })) } else { clear(s, &json!({})) }
|
||||
// The selected ruler guides it copied go with the objects, in the same undo step (#3226).
|
||||
if s.doc()?.selection.anchors.is_empty() { clear(s, &json!({ "ids": ids, "guides": true })) } else { clear(s, &json!({ "guides": true })) }
|
||||
}
|
||||
|
||||
fn clear(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -679,8 +727,9 @@ fn clear(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return super::docmenu::guide_remove(s, &json!({}));
|
||||
}
|
||||
let st = s.doc()?;
|
||||
// Guides selected with the art go with it (not when ids are given).
|
||||
let with_guides = explicit.is_none();
|
||||
// Guides selected with the art go with it (not when ids are given, unless `guides` says so:
|
||||
// Cut, which names the objects it copied).
|
||||
let with_guides = explicit.is_none() || bool_or(p, "guides", false);
|
||||
let targets = explicit.unwrap_or_else(|| st.selection.in_paint_order(&st.doc));
|
||||
// Keep the selected ids themselves, without promoting compound members. Delete a selected
|
||||
// ancestor just once; its selected descendants leave with it. Layers themselves are kept.
|
||||
@@ -850,6 +899,13 @@ fn paste_clip(s: &mut Session, p: &Value, mode: PasteMode, clip: &Clipboard, cho
|
||||
// In place, in front, in back: onto the active artboard when the UI names it.
|
||||
_ => vec![onto_artboard(&st.doc, p, clip)],
|
||||
};
|
||||
// Guides alone don't follow a plain paste's offset or the view centre: they stay where they
|
||||
// were. With objects they move as the objects do.
|
||||
let guide_placements: Vec<Affine> = if !clip.nodes.is_empty() || !matches!(mode, PasteMode::Offset | PasteMode::Library | PasteMode::Drag) {
|
||||
placements.clone()
|
||||
} else {
|
||||
vec![Affine::IDENTITY]
|
||||
};
|
||||
let (ids, imported, artboards, into) = s.edit(mode.label(), |d, sel| {
|
||||
let imported = clip.import_into(d, choices, same_doc);
|
||||
// Draw Inside: the clip group of the object drawn into, made on the first paste.
|
||||
@@ -897,6 +953,20 @@ fn paste_clip(s: &mut Session, p: &Value, mode: PasteMode, clip: &Clipboard, cho
|
||||
}
|
||||
}
|
||||
sel.set(new_ids.iter().copied());
|
||||
// Copied ruler guides (#3226): on the layer new art goes to, moved as the objects are (a
|
||||
// paste of guides alone keeps them where they were, but onto another artboard or every
|
||||
// artboard as the in-place pastes put art); they are selected with the objects.
|
||||
let guide_layer = parent.and_then(|p| if d.node(p).is_some_and(Node::is_layer) { Some(p) } else { d.layer_containing(p) });
|
||||
for xf in &guide_placements {
|
||||
let dv = xf.translation();
|
||||
for g in &clip.guides {
|
||||
let pos = g.pos + if g.vertical { dv.x } else { dv.y };
|
||||
if pos.is_finite() {
|
||||
sel.guides.push(d.guides.len());
|
||||
d.guides.push(vectorcraft_doc::Guide { pos, layer: guide_layer, ..g.clone() });
|
||||
}
|
||||
}
|
||||
}
|
||||
// Pasted type arrives without the layout bounds cache: fill it so boxes and alignment are
|
||||
// right away (opening the file later would, but paste must not depend on a reopen).
|
||||
super::typecmd::refresh_bounds_of(d, &new_ids);
|
||||
@@ -911,6 +981,7 @@ fn paste_clip(s: &mut Session, p: &Value, mode: PasteMode, clip: &Clipboard, cho
|
||||
}
|
||||
Ok(json!({
|
||||
"ids": ids.iter().map(|i| i.0).collect::<Vec<_>>(),
|
||||
"guides": clip.guides.len() * guide_placements.len(),
|
||||
"artboard": artboards.first(),
|
||||
"artboards": artboards,
|
||||
"added": imported.added,
|
||||
|
||||
@@ -1189,7 +1189,9 @@ fn convert_anchors(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
ok()
|
||||
}
|
||||
|
||||
pub(crate) fn delete_anchors(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
pub(crate) fn delete_anchors(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// `guides: true` (Cut, #3226): the selected ruler guides go in the same undo step.
|
||||
let with_guides = p.get("guides").and_then(Value::as_bool).unwrap_or(false);
|
||||
let st = s.doc()?;
|
||||
let sel = st.selection.anchors.clone();
|
||||
let segments = st.selection.segments.clone();
|
||||
@@ -1197,6 +1199,10 @@ pub(crate) fn delete_anchors(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
// also catches part of another path must still delete those whole objects.
|
||||
let whole = st.selection.whole();
|
||||
s.edit("Clear", |d, selection| {
|
||||
if with_guides {
|
||||
let gone = selection.guides.clone();
|
||||
d.retain_guides(selection, |i, _| !gone.contains(&i));
|
||||
}
|
||||
let targets: BTreeSet<NodeId> = whole.into_iter().filter(|id| d.node(*id).is_some_and(|n| !n.is_layer())).collect();
|
||||
let whole_ids: Vec<NodeId> = targets
|
||||
.iter()
|
||||
|
||||
@@ -67,6 +67,7 @@ mod gradient;
|
||||
mod gradpanel;
|
||||
mod gradpresets;
|
||||
mod gradspan;
|
||||
mod guide_clipboard;
|
||||
mod halftone;
|
||||
mod history_mem;
|
||||
mod ids_without_selection;
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
//! Copying and pasting ruler guides (#3226): selected guides go on the clipboard with the objects
|
||||
//! or alone, and paste into another document or onto another artboard.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::NodeId;
|
||||
|
||||
use crate::*;
|
||||
|
||||
fn run(s: &mut Session, id: &str, p: Value) -> Value {
|
||||
s.execute(id, &p).unwrap_or_else(|e| panic!("{id} {p}: {e}"))
|
||||
}
|
||||
|
||||
/// The active document's guides: (vertical, pos, layer).
|
||||
fn guides(s: &Session) -> Vec<(bool, f64, Option<NodeId>)> {
|
||||
s.doc().unwrap().doc.guides.iter().map(|g| (g.vertical, g.pos, g.layer)).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ruler_guides_copy_and_paste_into_another_document() {
|
||||
let mut s = Session::new();
|
||||
run(&mut s, "file.new", json!({"width": 400, "height": 300}));
|
||||
run(&mut s, "guide.add", json!({"vertical": true, "pos": 120}));
|
||||
run(&mut s, "guide.add", json!({"vertical": false, "pos": 80}));
|
||||
run(&mut s, "guide.select", json!({"indexes": [0, 1]}));
|
||||
let r = run(&mut s, "edit.copy", json!({}));
|
||||
assert_eq!(r["guides"], 2);
|
||||
// Into another document: the same places, on its current layer, selected, one undo step.
|
||||
run(&mut s, "file.new", json!({"width": 400, "height": 300}));
|
||||
let layer = s.doc().unwrap().doc.layers[0].id;
|
||||
let undo = s.doc().unwrap().history.undo.len();
|
||||
let r = run(&mut s, "edit.paste", json!({}));
|
||||
assert_eq!(r["guides"], 2);
|
||||
assert_eq!(guides(&s), [(true, 120.0, Some(layer)), (false, 80.0, Some(layer))], "where they were, not offset");
|
||||
assert_eq!(s.doc().unwrap().selection.guides, [0, 1]);
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1);
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert!(guides(&s).is_empty());
|
||||
// With an object, they move as it does (Paste offsets it by the Paste Offset preference).
|
||||
run(&mut s, "document.activate", json!({"index": 0}));
|
||||
let rect = run(&mut s, "shape.rectangle", json!({"x": 10, "y": 10, "width": 20, "height": 20}))["id"].as_u64().unwrap();
|
||||
run(&mut s, "select.set", json!({"ids": [rect]}));
|
||||
run(&mut s, "guide.select", json!({"indexes": [0], "toggle": true}));
|
||||
run(&mut s, "edit.copy", json!({}));
|
||||
run(&mut s, "document.activate", json!({"index": 1}));
|
||||
run(&mut s, "edit.paste", json!({"dx": 5, "dy": 7}));
|
||||
assert_eq!(guides(&s).iter().map(|g| (g.0, g.1)).collect::<Vec<_>>(), [(true, 125.0)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn guides_paste_in_place_onto_another_artboard_and_cut_removes_them() {
|
||||
let mut s = Session::new();
|
||||
run(&mut s, "file.new", json!({"width": 100, "height": 100, "artboards": 2}));
|
||||
let [a, b] = [0, 1].map(|i| s.doc().unwrap().doc.artboards[i].rect);
|
||||
run(&mut s, "guide.add", json!({"vertical": true, "pos": a.x0 + 30.0}));
|
||||
run(&mut s, "guide.select", json!({"indexes": [0]}));
|
||||
// Cut takes the guide away and keeps it on the clipboard.
|
||||
run(&mut s, "edit.cut", json!({}));
|
||||
assert!(guides(&s).is_empty());
|
||||
// Paste in Place onto the second artboard: 30 pt into it.
|
||||
run(&mut s, "edit.pasteInPlace", json!({"artboard": 1}));
|
||||
assert_eq!(guides(&s).iter().map(|g| g.1).collect::<Vec<_>>(), [b.x0 + 30.0]);
|
||||
// Paste on All Artboards: one on each.
|
||||
run(&mut s, "edit.pasteOnAllArtboards", json!({}));
|
||||
let mut at: Vec<f64> = guides(&s).iter().map(|g| g.1).collect();
|
||||
at.sort_by(f64::total_cmp);
|
||||
assert_eq!(at, [a.x0 + 30.0, b.x0 + 30.0, b.x0 + 30.0]);
|
||||
// Cut with an object and a guide selected takes both away, in one undo step.
|
||||
let rect = run(&mut s, "shape.rectangle", json!({"x": 10, "y": 10, "width": 20, "height": 20}))["id"].as_u64().unwrap();
|
||||
let before = guides(&s).len();
|
||||
run(&mut s, "select.set", json!({"ids": [rect]}));
|
||||
run(&mut s, "guide.select", json!({"indexes": [0], "toggle": true}));
|
||||
let undo = s.doc().unwrap().history.undo.len();
|
||||
run(&mut s, "edit.cut", json!({}));
|
||||
assert!(s.doc().unwrap().doc.node(NodeId(rect)).is_none());
|
||||
assert_eq!(guides(&s).len(), before - 1, "the selected guide goes too");
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1);
|
||||
// Cut with direct-selected anchors and a guide: the anchors and the guide go, one undo step.
|
||||
let path = run(&mut s, "shape.rectangle", json!({"x": 40, "y": 40, "width": 20, "height": 20}))["id"].as_u64().unwrap();
|
||||
run(&mut s, "guide.add", json!({"vertical": false, "pos": 90}));
|
||||
let n = guides(&s).len();
|
||||
run(&mut s, "select.anchors", json!({"id": path, "anchors": [[0, 0]], "mode": "set"}));
|
||||
run(&mut s, "guide.select", json!({"indexes": [n - 1], "toggle": true}));
|
||||
let undo = s.doc().unwrap().history.undo.len();
|
||||
run(&mut s, "edit.cut", json!({}));
|
||||
assert_eq!(guides(&s).len(), n - 1, "the guide goes with the anchors");
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1);
|
||||
// Nothing selected: Copy is off.
|
||||
run(&mut s, "select.none", json!({}));
|
||||
assert!(s.execute("edit.copy", &json!({})).is_err());
|
||||
}
|
||||
|
||||
/// #3226 review: each paste adds guides of its own (a guide is its place and layer; there is no
|
||||
/// id to share), on the layer new art goes to, so they take that layer's colour, visibility and
|
||||
/// lock; and the SVG put on the system clipboard carries no guides.
|
||||
#[test]
|
||||
fn two_pastes_add_their_own_guides_on_the_current_layer_and_svg_has_none() {
|
||||
let mut s = Session::new();
|
||||
run(&mut s, "file.new", json!({"width": 400, "height": 300}));
|
||||
run(&mut s, "guide.add", json!({"vertical": true, "pos": 120}));
|
||||
run(&mut s, "guide.select", json!({"indexes": [0]}));
|
||||
run(&mut s, "edit.copy", json!({}));
|
||||
// Guides alone: no art for the system clipboard's SVG.
|
||||
assert!(s.clipboard_svg().is_none(), "guides alone put no SVG on the system clipboard");
|
||||
assert!(s.clipboard.flavour_types(true, true).is_empty(), "nor any other format (the app empties the system clipboard)");
|
||||
let layer = NodeId(run(&mut s, "layer.new", json!({}))["id"].as_u64().unwrap());
|
||||
run(&mut s, "edit.paste", json!({}));
|
||||
assert_eq!(s.doc().unwrap().selection.guides, [1], "the first paste selects its own guide");
|
||||
run(&mut s, "edit.paste", json!({}));
|
||||
assert_eq!(s.doc().unwrap().selection.guides, [2], "the second paste selects its own guide");
|
||||
let g = guides(&s);
|
||||
assert_eq!(g.len(), 3, "two pastes, two more guides: {g:?}");
|
||||
assert_eq!((g[1].2, g[2].2), (Some(layer), Some(layer)), "on the layer new art goes to");
|
||||
// Moving the second pasted guide leaves the first where it is.
|
||||
run(&mut s, "guide.move", json!({"index": 2, "pos": 200}));
|
||||
assert_eq!(guides(&s).iter().map(|g| g.1).collect::<Vec<_>>(), [120.0, 120.0, 200.0]);
|
||||
// Hiding that layer hides the guides pasted on it: they follow its visibility (and lock).
|
||||
run(&mut s, "layer.setProps", json!({"id": layer.0, "visible": false}));
|
||||
let st = s.doc().unwrap();
|
||||
let shown: Vec<bool> = st.doc.guides.iter().map(|g| st.doc.guide_shown(g)).collect();
|
||||
assert_eq!(shown, [true, false, false]);
|
||||
// With an object, the SVG holds the object and no guide.
|
||||
run(&mut s, "layer.setProps", json!({"id": layer.0, "visible": true}));
|
||||
let rect = run(&mut s, "shape.rectangle", json!({"x": 10, "y": 10, "width": 20, "height": 20}))["id"].as_u64().unwrap();
|
||||
run(&mut s, "select.set", json!({"ids": [rect]}));
|
||||
run(&mut s, "guide.select", json!({"indexes": [0], "toggle": true}));
|
||||
let r = run(&mut s, "edit.copy", json!({}));
|
||||
assert_eq!(r["guides"], 1);
|
||||
let svg = s.clipboard_svg().unwrap();
|
||||
assert!(svg.contains("<rect") || svg.contains("<path"), "the object is there");
|
||||
assert!(!svg.to_lowercase().contains("guide"), "no guide in the SVG: {svg}");
|
||||
}
|
||||
@@ -124,6 +124,13 @@ impl VectorcraftApp {
|
||||
/// ([`Self::write_formats_before_text_input`]). Once they are written, the status bar shows
|
||||
/// the first of the PNG's and PDF's warnings.
|
||||
pub(crate) fn publish_clipboard(&mut self) {
|
||||
// Ruler guides copied alone stay in VectorCraft, but the Copy still replaces what the
|
||||
// system clipboard held: it is emptied, so the next Paste takes the guides rather than
|
||||
// what another app copied before, and what another app copies after still wins (#3226).
|
||||
if !self.session.clipboard.has_art() {
|
||||
self.clear_system_clipboard();
|
||||
return;
|
||||
}
|
||||
if self.services.system_clipboard.is_none() {
|
||||
if self.session.prefs.copy_as_svg {
|
||||
self.clipboard_out = self.session.clipboard_svg();
|
||||
@@ -176,6 +183,28 @@ impl VectorcraftApp {
|
||||
}
|
||||
}
|
||||
|
||||
/// A Copy with nothing for other apps (ruler guides alone): the formats of an earlier Copy or
|
||||
/// Cut not written yet are dropped and the system clipboard is emptied. On the web, empty text
|
||||
/// goes through egui when Copy As SVG is on, in place of the SVG objects would put there.
|
||||
fn clear_system_clipboard(&mut self) {
|
||||
let publishing = &mut self.clipboard_publishing;
|
||||
publishing.latest.fetch_add(1, Ordering::SeqCst);
|
||||
publishing.pending = false;
|
||||
match self.services.system_clipboard.as_mut() {
|
||||
Some(cb) => {
|
||||
if let Err(e) = cb.write(&[]) {
|
||||
self.ui.status = format!("Couldn't copy to the system clipboard: {e}");
|
||||
}
|
||||
}
|
||||
None => {
|
||||
if self.session.prefs.copy_as_svg {
|
||||
self.clipboard_out = Some(String::new());
|
||||
self.clipboard_published = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// End of a frame: when something copied text or a picture through egui this frame (a text
|
||||
/// field, the Type tool), the formats of an earlier Copy or Cut that are not written yet are
|
||||
/// dropped, and the system clipboard keeps that copy.
|
||||
|
||||
@@ -48,6 +48,7 @@ mod svgsave;
|
||||
pub(super) mod synthetic;
|
||||
pub(super) mod sysclip;
|
||||
mod sysclip_emf;
|
||||
mod sysclip_guides;
|
||||
mod sysclip_probe;
|
||||
mod tab_overflow;
|
||||
mod taskbar;
|
||||
|
||||
@@ -80,7 +80,7 @@ pub(super) fn copy_rect(app: &mut VectorcraftApp) {
|
||||
run(app, "edit.copy", json!({}));
|
||||
}
|
||||
|
||||
fn written(board: &Rc<RefCell<Board>>) -> Vec<&'static str> {
|
||||
pub(super) fn written(board: &Rc<RefCell<Board>>) -> Vec<&'static str> {
|
||||
board.borrow().flavours.iter().map(|f| f.mime).collect()
|
||||
}
|
||||
|
||||
@@ -90,7 +90,7 @@ pub(super) fn pasted(app: &VectorcraftApp) -> Vec<Node> {
|
||||
st.selection.objects.iter().map(|id| st.doc.node(*id).unwrap().clone()).collect()
|
||||
}
|
||||
|
||||
fn count(app: &VectorcraftApp) -> usize {
|
||||
pub(super) fn count(app: &VectorcraftApp) -> usize {
|
||||
app.session.active().unwrap().doc.layers[0].children().unwrap().len()
|
||||
}
|
||||
|
||||
@@ -152,7 +152,7 @@ pub(crate) fn blue_png() -> Vec<u8> {
|
||||
|
||||
/// One headless frame of app logic (shortcuts too, from the second frame on) delivering `events`,
|
||||
/// in the test's one context (in a new one the app starts its fonts over).
|
||||
fn frame(app: &mut VectorcraftApp, events: Vec<egui::Event>) -> egui::FullOutput {
|
||||
pub(super) fn frame(app: &mut VectorcraftApp, events: Vec<egui::Event>) -> egui::FullOutput {
|
||||
thread_local! {
|
||||
static CTX: egui::Context = egui::Context::default();
|
||||
}
|
||||
@@ -175,7 +175,7 @@ fn app_with_worker() -> (VectorcraftApp, Rc<RefCell<Board>>) {
|
||||
|
||||
/// [`app_with_worker`], its worker busy with a job labeled `label` (none: another Copy's formats)
|
||||
/// until the sender is dropped, or for 10 s.
|
||||
fn app_with_busy_worker(label: &str) -> (VectorcraftApp, Rc<RefCell<Board>>, Sender<()>) {
|
||||
pub(super) fn app_with_busy_worker(label: &str) -> (VectorcraftApp, Rc<RefCell<Board>>, Sender<()>) {
|
||||
let (mut app, board) = app_with_worker();
|
||||
let hold = hold_worker(&mut app, label);
|
||||
(app, board, hold)
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
//! Ruler guides copied alone and the system clipboard (#3226 review): a guides-only Copy empties
|
||||
//! the system clipboard, as any Copy replaces what it held, so the next Paste takes the guides and
|
||||
//! not what another app copied before. What another app copies after it still wins.
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
|
||||
use serde_json::json;
|
||||
use vectorcraft_doc::NodeKind;
|
||||
use vectorcraft_engine::Session;
|
||||
use vectorcraft_engine::cmd::clipboard::{PNG, TEXT};
|
||||
|
||||
use crate::tests_sysclip::{app, app_with_busy_worker, copy_elsewhere, copy_rect, count, frame, pasted, run, written};
|
||||
use crate::{Services, VectorcraftApp, background};
|
||||
|
||||
fn guides(app: &VectorcraftApp) -> usize {
|
||||
app.session.active().unwrap().doc.guides.len()
|
||||
}
|
||||
|
||||
/// Add a vertical guide at `pos`, select only it and copy it.
|
||||
fn copy_guide(app: &mut VectorcraftApp, pos: f64) {
|
||||
run(app, "guide.add", json!({"vertical": true, "pos": pos}));
|
||||
let last = guides(app) - 1;
|
||||
run(app, "select.none", json!({}));
|
||||
run(app, "guide.select", json!({"indexes": [last]}));
|
||||
run(app, "edit.copy", json!({}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_guides_only_copy_pastes_the_guides_not_what_another_app_copied_before() {
|
||||
let (mut app, board) = app();
|
||||
copy_elsewhere(&board, vec![(TEXT, b"Words another app copied".to_vec()), (PNG, vec![1, 2, 3])]);
|
||||
copy_guide(&mut app, 120.0);
|
||||
assert!(written(&board).is_empty(), "the Copy emptied the system clipboard: {:?}", written(&board));
|
||||
run(&mut app, "edit.paste", json!({}));
|
||||
assert_eq!(guides(&app), 2, "the copied guide was pasted");
|
||||
assert_eq!(count(&app), 0, "and not the other app's text");
|
||||
assert_eq!(app.session.active().unwrap().selection.guides, [1]);
|
||||
// A second Paste takes the guides again.
|
||||
run(&mut app, "edit.paste", json!({}));
|
||||
assert_eq!((guides(&app), count(&app)), (3, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn what_another_app_copies_after_a_guides_only_copy_is_pasted() {
|
||||
let (mut app, board) = app();
|
||||
copy_guide(&mut app, 120.0);
|
||||
copy_elsewhere(&board, vec![(TEXT, b"Copied later".to_vec())]);
|
||||
run(&mut app, "edit.paste", json!({}));
|
||||
let NodeKind::Text(t) = &pasted(&app)[0].kind else { panic!("not type") };
|
||||
assert_eq!(t.plain_text(), "Copied later");
|
||||
assert_eq!(guides(&app), 1, "no guide pasted");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn objects_copied_after_guides_paste_as_objects() {
|
||||
let (mut app, board) = app();
|
||||
copy_guide(&mut app, 120.0);
|
||||
run(&mut app, "select.none", json!({}));
|
||||
copy_rect(&mut app);
|
||||
assert!(!written(&board).is_empty(), "objects go to the system clipboard");
|
||||
run(&mut app, "edit.paste", json!({}));
|
||||
assert!(matches!(pasted(&app)[0].kind, NodeKind::Path { .. }));
|
||||
assert_eq!(guides(&app), 1, "the earlier guides-only copy is replaced");
|
||||
}
|
||||
|
||||
/// A guides-only Copy right after an object Copy whose formats are still being made: those formats
|
||||
/// are dropped, so they don't land on the system clipboard after it and replace the guides.
|
||||
#[test]
|
||||
fn a_guides_only_copy_drops_an_earlier_copys_formats_not_written_yet() {
|
||||
let (mut app, board, hold) = app_with_busy_worker("");
|
||||
copy_elsewhere(&board, vec![(TEXT, b"Words another app copied".to_vec())]);
|
||||
copy_rect(&mut app);
|
||||
copy_guide(&mut app, 120.0);
|
||||
drop(hold);
|
||||
background::wait_all(&mut app);
|
||||
assert!(written(&board).is_empty(), "{:?}", written(&board));
|
||||
let objects = count(&app);
|
||||
run(&mut app, "edit.paste", json!({}));
|
||||
assert_eq!((guides(&app), count(&app)), (2, objects), "the guide, not the rectangle or the text");
|
||||
}
|
||||
|
||||
/// The web (no clipboard service): a guides-only Copy puts empty text on the clipboard through
|
||||
/// egui, in place of the SVG an object Copy puts there, so another app's SVG copied before is not
|
||||
/// pasted instead of the guides.
|
||||
#[test]
|
||||
fn on_the_web_a_guides_only_copy_empties_the_clipboard_text() {
|
||||
let browser = Rc::new(RefCell::new(r#"<svg xmlns="http://www.w3.org/2000/svg"><rect width="9" height="9"/></svg>"#.to_string()));
|
||||
let read = browser.clone();
|
||||
let services = Services { clipboard_read: Some(Box::new(move || Some(read.borrow().clone()))), ..Default::default() };
|
||||
let mut app = VectorcraftApp::new(Session::new(), services);
|
||||
run(&mut app, "file.new", json!({"width": 400, "height": 300}));
|
||||
copy_guide(&mut app, 120.0);
|
||||
let out = frame(&mut app, vec![]);
|
||||
let copied: Vec<&String> =
|
||||
out.platform_output.commands.iter().filter_map(|c| if let egui::OutputCommand::CopyText(t) = c { Some(t) } else { None }).collect();
|
||||
assert_eq!(copied, [""], "empty text replaces the browser's clipboard");
|
||||
// The browser now holds that empty text.
|
||||
browser.borrow_mut().clear();
|
||||
run(&mut app, "edit.paste", json!({}));
|
||||
assert_eq!((guides(&app), count(&app)), (2, 0), "the guide, not the other app's SVG");
|
||||
}
|
||||
@@ -1594,10 +1594,15 @@ A double-click on a Repeat (Radial, Grid or Mirror) with the Selection tool ente
|
||||
|
||||
With area type selected, the Properties panel's Paragraph section and the Control panel show Align Top, Align Center, Align Bottom and Justify Vertically buttons, as the public documentation places them ([Align text](https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/align-text.html)). They set the type's vertical alignment in its frame (`text.areaOptions {verticalAlign}`, as Area Type Options does) on every selected frame in one undo step, each frame keeping its other options; the shared alignment shows selected, none when the frames differ (#3759). As that page limits vertical alignment to area type without rows, columns or threads, the buttons are turned off while a selected frame has rows or columns or is threaded; Area Type Options and `text.areaOptions` still align each row/column cell (a VectorCraft extension, unchanged here). Tests: `panels::properties::tests::vertical_alignment_buttons_align_the_selected_area_type`.
|
||||
|
||||
## Copy and paste ruler guides (2026-11-01)
|
||||
|
||||
Edit › Copy and Cut put the selected ruler guides on the clipboard, with the selected objects or on their own, so they paste into another artboard or document (#3226). Pasted guides go on the layer new art goes to and are selected; with objects they move as the objects do, and alone they keep their places, while Paste in Place, in Front and in Back put them on the active artboard as they lay on theirs and Paste on All Artboards on every artboard. Guides have no format other apps read, so a Copy of guides alone empties the system clipboard, and the next Paste takes the guides rather than what another app copied before. Tests: `tests::guide_clipboard`, `tests::sysclip_guides` (ui-egui).
|
||||
|
||||
## Revision history
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-11-01 | minor | Copy and paste ruler guides (#3226); evidence added; scores unchanged. |
|
||||
| 2026-11-04 | minor | Area type vertical alignment buttons in the Properties panel's Paragraph section and the Control panel, off for columns, rows and threaded frames (#3759); evidence added; scores unchanged. |
|
||||
| 2026-11-03 | minor | Enclosing Lasso is an add-on with no default shortcut or Basic toolbar slot. Scores and estimates remain unchanged. |
|
||||
| 2026-11-02 | minor | Wheel input over closed font-family fields changes selected text (#5253); public documentation and application choices distinguished; scores and estimates unchanged. |
|
||||
|
||||
@@ -420,6 +420,7 @@ separate. Scores and hours unchanged. [Behavior and command details](development
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-11-01 | minor | Edit › Copy and Cut take the selected ruler guides, with the objects or alone; pasting puts them on the layer new art goes to, moved as the objects are (alone: where they were), Paste in Place onto the active artboard and Paste on All Artboards onto each (#3226); scores and estimates unchanged |
|
||||
| 2026-11-04 | minor | Area type: Align Top / Center / Bottom / Justify Vertically buttons in the Properties panel (Paragraph section) and the Control panel set its vertical alignment in the frame, as Area Type Options does; turned off for frames with rows or columns and threaded frames (#3759); scores and estimates unchanged |
|
||||
| 2026-11-03 | minor | Enclosing Lasso selects fully enclosed objects with a freehand loop under Object › Plug-ins; scores and estimates unchanged. |
|
||||
| 2026-11-03 | minor | Type on a Path Options has a Preview checkbox (#5360, as public tutorials of the dialog show); scores and estimates unchanged |
|
||||
@@ -429,6 +430,7 @@ separate. Scores and hours unchanged. [Behavior and command details](development
|
||||
| 2026-11-01 | minor | Live Corners: while a corner widget is pressed or dragged with the Selection or Direct Selection tool, the Up and Down arrows step the corners' kind (round, inverted round, chamfer) with the radius, as one undo step (#2898); scores and estimates unchanged |
|
||||
| 2026-11-01 | minor | Keyboard Shortcuts: every Effect menu item (Distort & Transform › Transform…, Stylize › Drop Shadow…, …) is listed under its menu path and takes a shortcut of its own, which the menu shows and which opens that effect's dialog (or applies an effect without options) (#3296); scores and estimates unchanged |
|
||||
| 2026-10-31 | minor | Units › East Asian Type acts (#394): Q and Ha (a quarter of a millimetre each) are units that preference offers; 4 Preferences options remain stored but unused; scores and estimates unchanged |
|
||||
| 2026-11-01 | minor | Edit › Copy and Cut take the selected ruler guides, with the objects or alone; pasting puts them on the layer new art goes to, moved as the objects are (alone: where they were), Paste in Place onto the active artboard and Paste on All Artboards onto each; a Copy of guides alone empties the system clipboard, so Paste takes the guides, not what another app copied before (#3226); scores and estimates unchanged |
|
||||
| 2026-11-01 | minor | Color Effects add-on (a VectorCraft addition): Find Nearest Swatch (#4923); scores and estimates unchanged |
|
||||
| 2026-11-01 | minor | Artboards panel: a row dragged above or below another reorders the artboards (#4687); scores and estimates unchanged |
|
||||
| 2026-11-01 | minor | Object › Plug-ins › Color Effects › Create Swatch Legend…: a dialog that draws labelled chips of the colour swatches (all, or the ones selected in the Swatches panel) as one group (#4919); scores and estimates unchanged |
|
||||
|
||||
Reference in New Issue
Block a user