From d8b6f4cd02327f40255473b16966d5edc7d620ab Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 21:02:58 -0800 Subject: [PATCH] Layers: Paste Remembers Layers puts art back into the sublayer it was copied from Copy now remembers the whole chain of layers around each object, not only the name of the nearest one. With Paste Remembers Layers on: - In the same document, art goes back into the sublayer it came from while that sublayer exists, even after it was moved, so two sublayers of the same name in different layers stay apart. A deleted sublayer is made again inside the layer that held it. - In another document, the sublayer and the layers around it are matched by name from the top level down; of repeated names, the topmost chain that matches the most names wins, a shown and unlocked one first. A missing sublayer is made inside its layer, and a missing layer on top with the sublayer inside it, in the paste's undo step. - When the matched layer, or a layer around it, is hidden or locked, the paste fails with "the target layer is locked or hidden" and nothing is pasted, instead of putting art in a hidden layer or making a second layer of the same name. --- crates/doc/src/lib.rs | 4 +- crates/engine/src/cmd/clipboard.rs | 34 +++++- crates/engine/src/cmd/edit.rs | 129 +++++++++++++++++++--- crates/engine/src/cmd/layer.rs | 2 +- crates/engine/src/lib.rs | 2 +- crates/engine/src/tests/clipboard.rs | 154 ++++++++++++++++++++++++++- docs/mcp.md | 6 +- docs/target-app-parity.md | 1 + 8 files changed, 306 insertions(+), 26 deletions(-) diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 15de79660f..0bcea57790 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -265,8 +265,8 @@ 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. + /// Layers panel → Paste Remembers Layers: pasted objects go back into the layers and + /// sublayers they were copied from instead of the current layer. #[serde(default, skip_serializing_if = "std::ops::Not::not")] pub paste_remembers_layers: bool, /// File → File Info (its title is [`Document::info_title`]). diff --git a/crates/engine/src/cmd/clipboard.rs b/crates/engine/src/cmd/clipboard.rs index 9e52454de1..d28bd0e6c9 100644 --- a/crates/engine/src/cmd/clipboard.rs +++ b/crates/engine/src/cmd/clipboard.rs @@ -88,8 +88,9 @@ pub struct Clipboard { /// The artboard the objects were copied from (none when they lie on no artboard; the first of /// [`Self::artboards`] when there are any): Paste on All Artboards keeps their offset to it. pub source_artboard: Option, - /// Per object: the name of the layer it was copied from (Paste Remembers Layers). - pub source_layers: Vec>, + /// Per object: the layer or sublayer it was copied from and the layers around it (Paste + /// Remembers Layers); none when that layer has no name. + pub source_layers: Vec>, /// The open document the objects came from ([`DocState::uid`]): pasting back into it uses its /// own resources as they are now. pub source_doc: Option, @@ -101,6 +102,29 @@ pub struct Clipboard { pub guides: Vec, } +/// The layer an object was copied from, as Paste Remembers Layers finds it again: the top-level +/// layer first, down to the layer or sublayer that holds the object. +#[derive(Clone, Debug, Default, PartialEq, Eq, Hash)] +pub struct SourceLayer { + /// The layers' ids in the source document ([`Clipboard::source_doc`]). + pub ids: Vec, + /// The layers' names (an unnamed layer above the one that holds the object is ""). + pub names: Vec, +} + +impl SourceLayer { + /// The layers around the object `anc` ends with ([`Document::ancestry`]); none when the layer + /// that holds it has no name. + fn of(d: &Document, anc: &[NodeId]) -> Option { + let layers: Vec<&Node> = anc.iter().rev().skip(1).filter_map(|a| d.node(*a).filter(|l| l.is_layer())).collect(); + layers.first()?.name.as_ref()?; + Some(Self { + ids: layers.iter().rev().map(|l| l.id).collect(), + names: layers.iter().rev().map(|l| l.name.clone().unwrap_or_default()).collect(), + }) + } +} + /// Indices of segments selected on `sp` (either explicitly or via incident anchors). pub(crate) fn subpath_selected_segments( sp: &vectorcraft_geom::SubPath, @@ -256,7 +280,7 @@ impl Clipboard { let mut compound_bounds: std::collections::HashMap> = std::collections::HashMap::new(); for id in roots { let (Some(n), Some(anc)) = (d.node(*id), d.ancestry(*id)) else { continue }; - let layer_name = anc.iter().rev().skip(1).find_map(|a| d.node(*a).filter(|l| l.is_layer())).and_then(|l| l.name.clone()); + let source_layer = SourceLayer::of(d, &anc); let compound_parent = anc.iter().rev().skip(1).find_map(|a| d.node(*a).filter(|p| matches!(p.kind, NodeKind::Compound { .. }))); // Gradients fitted to the compound's bounds are pinned there, as a split pins them. let gradient_box = compound_parent @@ -299,11 +323,11 @@ impl Clipboard { if direct && let Some(pieces) = extract_direct_selection(n, &st.selection) { for piece in pieces { nodes.push(inherit_appearance(piece)); - source_layers.push(layer_name.clone()); + source_layers.push(source_layer.clone()); } } else { nodes.push(inherit_appearance(n.clone())); - source_layers.push(layer_name); + source_layers.push(source_layer); } } // Symbol instances are measured by their art. diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 256578f9b2..f3befb3462 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -1,6 +1,6 @@ //! File (document-level) and Edit commands, plus document queries. -use std::collections::{BTreeMap, BTreeSet}; +use std::collections::{BTreeMap, BTreeSet, HashMap}; use std::sync::Arc; use serde_json::{Value, json}; @@ -13,7 +13,7 @@ use super::{ f64_req, finite_numbers, has_clipboard, has_doc, has_selection, has_selection_guides_or_artboard_tool, has_selection_or_guides, index, number_or, object_param, ok, point_param, str_param, switch_or, }; -use crate::{Clipboard, EngineError, inspect}; +use crate::{Clipboard, EngineError, SourceLayer, inspect}; /// Register editing commands, including document-to-document artwork drops and their parameter contracts. pub fn specs() -> Vec { @@ -179,7 +179,7 @@ pub fn specs() -> Vec { "Paste", ["Edit"], Some("Cmd+V"), - "{center?: [x, y], artboardsAt?: [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). Artboards copied with artboard.copy come with their art, together right of the last artboard as they lay, or centered on `artboardsAt` (the Artboard tool's canvas menu passes the right-click point; a value that is not [x, y] is an error) (the other paste commands but Paste on All Artboards: where they were), their art back in the layers it came from, and the Artboard tool, when chosen, selects them. In Draw Inside (view.drawMode) the objects go inside the object drawn into, as new art does, and Paste Remembers Layers doesn't apply; edit.pasteInPlace and edit.pasteOnAllArtboards do the same, and in Draw Behind they go behind the selection as edit.pasteInBack puts them; edit.pasteInFront and edit.pasteInBack ignore the drawing mode → {ids, artboard: the first new artboard's index or null, artboards: the new artboards' indices, added: resources added, merged: conflicts merged, renamed: [{kind, from, to}]}", + "{center?: [x, y], artboardsAt?: [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 and sublayers they came from (by the names of the layer and the layers around it in another document; a missing sublayer is made inside its layer; a hidden or locked one is an error and nothing is pasted). Artboards copied with artboard.copy come with their art, together right of the last artboard as they lay, or centered on `artboardsAt` (the Artboard tool's canvas menu passes the right-click point; a value that is not [x, y] is an error) (the other paste commands but Paste on All Artboards: where they were), their art back in the layers it came from, and the Artboard tool, when chosen, selects them. In Draw Inside (view.drawMode) the objects go inside the object drawn into, as new art does, and Paste Remembers Layers doesn't apply; edit.pasteInPlace and edit.pasteOnAllArtboards do the same, and in Draw Behind they go behind the selection as edit.pasteInBack puts them; edit.pasteInFront and edit.pasteInBack ignore the drawing mode → {ids, artboard: the first new artboard's index or null, artboards: the new artboards' indices, added: resources added, merged: conflicts merged, renamed: [{kind, from, to}]}", has_clipboard, |s, p| paste(s, p, PasteMode::Offset) ), @@ -1380,6 +1380,9 @@ fn paste_clip(s: &mut Session, p: &Value, mode: PasteMode, clip: &Clipboard, cho && inside.is_none() && (st.doc.paste_remembers_layers || boards.is_some()) && parent.is_some_and(|p| st.doc.node(p).is_some_and(Node::is_layer)); + // Each object's remembered layer, found before anything is pasted: a hidden or locked one + // refuses the whole paste. + let remembered = if remember { remembered_layers(&st.doc, &clip.source_layers, same_doc)? } else { vec![] }; // Front/back: relative to the selection (top-most / bottom-most selected object); with // nothing selected, the top / bottom of the current layer. Art other than paths goes beside // the compound path that holds the selected object, not into it. @@ -1423,7 +1426,8 @@ fn paste_clip(s: &mut Session, p: &Value, mode: PasteMode, clip: &Clipboard, cho .flat_map(|(all, dv)| all.iter().map(move |a| (a, dv))) .map(|(a, dv)| super::panelcmds::push_artboard_copy(d, a, a.rect + dv)) .collect(); - let mut layers: BTreeMap<&str, NodeId> = BTreeMap::new(); + // The layers made for remembered layers the document lacks, by parent and name. + let mut made = HashMap::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![]; @@ -1437,8 +1441,9 @@ fn paste_clip(s: &mut Session, p: &Value, mode: PasteMode, clip: &Clipboard, cho // including the pattern phase, independently of transform-tool settings. c.transform(xf, sc); } - let layer = match clip.source_layers.get(k) { - Some(Some(name)) if remember => Some(*layers.entry(name).or_insert_with(|| layer_named(d, name))), + let layer = match remembered.get(k) { + Some(Some(Remembered::Layer(l))) => Some(*l), + Some(Some(Remembered::Make { parent, names })) => make_layers(d, &mut made, *parent, names)?, _ => None, }; // In a clip group or a clipping layer the bottom child is the clipping path: art pasted @@ -1557,17 +1562,111 @@ fn paste_anchor(doc: &Document, id: NodeId, can_enter_compound: bool) -> NodeId } } -/// 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); +/// Where Paste Remembers Layers puts an object: a layer or sublayer of the document, or new +/// layers `names`, each inside the one before, the first under `parent` (none: on top). +#[derive(Clone, Debug, PartialEq)] +enum Remembered { + Layer(NodeId), + Make { parent: Option, names: Vec }, +} + +/// [`remembered_layer`] for each object's source layer, worked out once for each layer. +fn remembered_layers(d: &Document, sources: &[Option], same_doc: bool) -> Result>> { + let mut found: HashMap<&SourceLayer, Remembered> = HashMap::new(); + sources + .iter() + .map(|src| { + let Some(src) = src else { return Ok(None) }; + if let Some(r) = found.get(src) { + return Ok(Some(r.clone())); } - }); + let r = remembered_layer(d, src, same_doc)?; + found.insert(src, r.clone()); + Ok(Some(r)) + }) + .collect() +} + +/// The layer `src` names in `d`. In the document it was copied from, the layer that held the +/// object is kept while it is there with its name, wherever it was moved; else the layers around +/// it still where they were (by id) are kept. Below them, and in another document from the top, +/// each name is matched among the layers inside the one above (the topmost of the same name whose +/// layers match the most names, a shown and unlocked one first). The names left unmatched are +/// made. A matched layer that is hidden or locked, or inside one that is, is an error. +fn remembered_layer(d: &Document, src: &SourceLayer, same_doc: bool) -> Result { + let held = |id: &NodeId| d.node(*id).is_some_and(|n| n.is_layer() && n.name.as_ref() == src.names.last()); + let kept = if same_doc && src.ids.last().is_some_and(held) { + src.ids.len() + } else if same_doc { + (1..=src.ids.len()) + .rev() + .find(|&j| d.node(src.ids[j - 1]).is_some_and(Node::is_layer) && d.ancestry(src.ids[j - 1]).is_some_and(|a| a == src.ids[..j])) + .unwrap_or(0) + } else { + 0 + }; + let rest = &src.names[kept..]; + let (end, matched, _) = named_chain(d, kept.checked_sub(1).map(|j| src.ids[j]), rest); + if let Some(l) = end + && !d.ancestry(l).is_some_and(|a| a.iter().all(|i| d.node(*i).is_some_and(|n| n.visible && !n.locked))) + { + return Err(EngineError::Other("the target layer is locked or hidden".into())); } - found.unwrap_or_else(|| d.add_layer(Some(name))) + Ok(match end { + Some(l) if matched == rest.len() => Remembered::Layer(l), + parent => Remembered::Make { parent, names: rest[matched..].to_vec() }, + }) +} + +/// The deepest chain of layers inside `parent` (none: the top level) named `names` one inside the +/// other: its last layer (`parent` when none matches), how many names it matched and whether all +/// its layers are shown and unlocked. Of chains as deep, the topmost shown and unlocked one wins. +fn named_chain(d: &Document, parent: Option, names: &[String]) -> (Option, usize, bool) { + let mut best = (parent, 0, true); + let Some((name, rest)) = names.split_first() else { return best }; + let siblings = match parent { + None => Some(&d.layers), + Some(p) => d.node(p).and_then(Node::children), + }; + for l in siblings.into_iter().flatten().rev().filter(|l| l.is_layer() && l.name.as_deref() == Some(name.as_str())) { + let (end, n, open) = named_chain(d, Some(l.id), rest); + let chain = (end, n + 1, open && l.visible && !l.locked); + if (chain.1, chain.2) > (best.1, best.2) { + best = chain; + } + } + best +} + +/// Layers `names`, each inside the one before, the first under `parent` (none: on top); `made` +/// keeps the ones a paste has made, by parent and name, so they are made once. The last one, or +/// none when `names` is empty. +fn make_layers( + d: &mut Document, + made: &mut HashMap<(Option, String), NodeId>, + parent: Option, + names: &[String], +) -> Result> { + let mut at = parent; + for name in names { + let key = (at, name.clone()); + let id = match made.get(&key) { + Some(id) => *id, + None => { + let id = match at { + None => d.add_layer(Some(name)), + Some(p) => { + let layer = super::layer::make_layer(d, Some(name)); + d.insert(Some(p), usize::MAX, layer)? + } + }; + made.insert(key, id); + id + } + }; + at = Some(id); + } + Ok(at.filter(|_| !names.is_empty())) } fn duplicate(s: &mut Session, p: &Value) -> Result { diff --git a/crates/engine/src/cmd/layer.rs b/crates/engine/src/cmd/layer.rs index f93cdca7b9..f816ec7688 100644 --- a/crates/engine/src/cmd/layer.rs +++ b/crates/engine/src/cmd/layer.rs @@ -200,7 +200,7 @@ pub fn specs() -> Vec { "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}", + "{on?} (default: toggle) the document option: on, the Paste commands put objects back into the layers or sublayers they were copied from (in that document while they are still there, else by the names of the layer and the layers around it, the topmost match first; a missing sublayer is made inside its layer, a missing top-level layer on top; a hidden or locked one is an error and nothing is pasted), off into the current layer; one undo step when it changes (document.inspect → pasteRemembersLayers) → {on}", has_doc, paste_remembers_layers ), diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index a891fb9599..9067e14f54 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -26,7 +26,7 @@ use vectorcraft_doc::{Document, NodeId, NodeKind, Selection}; use vectorcraft_geom::Affine; use vectorcraft_tools::{PaintDefaults, Tool}; -pub use cmd::clipboard::Clipboard; +pub use cmd::clipboard::{Clipboard, SourceLayer}; pub use cmd::create::constrain_click; pub use cmd::distortcmds::perspective_click; pub use cmd::params::{check_keys, check_params}; diff --git a/crates/engine/src/tests/clipboard.rs b/crates/engine/src/tests/clipboard.rs index 16ce8d8c77..d0e003ec49 100644 --- a/crates/engine/src/tests/clipboard.rs +++ b/crates/engine/src/tests/clipboard.rs @@ -1094,7 +1094,8 @@ fn paste_remembers_layers_restores_the_source_layers() { 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 names: Vec<_> = s.clipboard.source_layers.iter().map(|l| l.as_ref().map(|l| l.names.clone())).collect(); + assert_eq!(names, [Some(vec!["Layer 1".to_string()]), Some(vec!["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); @@ -1121,6 +1122,157 @@ fn paste_remembers_layers_restores_the_source_layers() { assert!(ids.iter().all(|id| doc(&s).parent_of(*id) == Some(other))); } +/// A sublayer `name` made inside `parent`; it becomes the current layer. +fn sublayer(s: &mut Session, parent: NodeId, name: &str) -> NodeId { + id_of(&s.execute("layer.newSublayer", &json!({"parent": parent.0, "name": name})).unwrap()) +} + +/// The names of the layers around `id`, the top-level layer first. +fn layer_path(s: &Session, id: NodeId) -> Vec { + let d = doc(s); + d.ancestry(id).unwrap().iter().filter_map(|a| d.node(*a).filter(|n| n.is_layer())).map(|l| l.name.clone().unwrap()).collect() +} + +/// Art from two sublayers of the same name in different layers pastes back into each one; in +/// another document a missing sublayer is made inside its layer, and a missing layer on top with +/// the sublayer inside, in the paste's one undo step. +#[test] +fn paste_remembers_layers_keeps_same_named_sublayers_apart() { + let mut s = session(); + let first = doc(&s).layers[0].id; + let sub1 = sublayer(&mut s, first, "Sub"); + let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0); + let second = id_of(&s.execute("layer.new", &json!({"name": "Layer 2", "top": true})).unwrap()); + let sub2 = sublayer(&mut s, second, "Sub"); + let b = rect(&mut s, 20.0, 0.0, 10.0, 10.0); + copy(&mut s, &[a, b]); + s.execute("layer.pasteRemembersLayers", &json!({"on": true})).unwrap(); + s.execute("layer.setCurrent", &json!({"id": first.0})).unwrap(); + 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(sub1), Some(sub2)]); + new_doc(&mut s); + s.execute("layer.pasteRemembersLayers", &json!({"on": true})).unwrap(); + let before = doc(&s).clone(); + let ids = ids_of(&s.execute("edit.paste", &json!({})).unwrap()); + let paths: Vec<_> = ids.iter().map(|id| layer_path(&s, *id)).collect(); + assert_eq!(paths, [["Layer 1", "Sub"], ["Layer 2", "Sub"]]); + assert_eq!(doc(&s).layers.len(), 2, "Layer 1 is kept; Layer 2 is new"); + assert_eq!(doc(&s).layers[0].children().unwrap().len(), 1, "Sub is made inside Layer 1"); + let after = doc(&s).clone(); + // Pasting again finds the layers made. + let again = ids_of(&s.execute("edit.paste", &json!({})).unwrap()); + let parents: Vec<_> = again.iter().map(|id| doc(&s).parent_of(*id)).collect(); + assert_eq!(parents, ids.iter().map(|id| doc(&s).parent_of(*id)).collect::>()); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(*doc(&s), after); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(*doc(&s), before, "one undo step takes the art and the layers made for it"); +} + +/// Art whose sublayer was deleted after the copy pastes into a new sublayer of that name inside +/// the same layer, not into a sublayer of that name in another layer. +#[test] +fn paste_remembers_layers_makes_a_deleted_sublayer_again_inside_its_layer() { + let mut s = session(); + let first = doc(&s).layers[0].id; + let sub = sublayer(&mut s, first, "Sub"); + let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0); + let second = id_of(&s.execute("layer.new", &json!({"name": "Layer 2", "top": true})).unwrap()); + let elsewhere = sublayer(&mut s, second, "Sub"); + copy(&mut s, &[a]); + s.execute("layer.delete", &json!({"id": sub.0})).unwrap(); + s.execute("layer.pasteRemembersLayers", &json!({"on": true})).unwrap(); + let ids = ids_of(&s.execute("edit.paste", &json!({})).unwrap()); + let made = doc(&s).parent_of(ids[0]).unwrap(); + assert_ne!(made, elsewhere); + assert_eq!(doc(&s).parent_of(made), Some(first)); + assert_eq!(layer_path(&s, ids[0]), ["Layer 1", "Sub"]); +} + +/// Art whose sublayer was moved into another layer after the copy pastes into that sublayer where +/// it is now; no sublayer is made where it was. +#[test] +fn paste_remembers_layers_follows_a_moved_sublayer() { + let mut s = session(); + let first = doc(&s).layers[0].id; + let sub = sublayer(&mut s, first, "Sub"); + let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0); + let second = id_of(&s.execute("layer.new", &json!({"name": "Layer 2", "top": true})).unwrap()); + copy(&mut s, &[a]); + s.execute("layer.move", &json!({"id": sub.0, "target": second.0})).unwrap(); + s.execute("layer.pasteRemembersLayers", &json!({"on": true})).unwrap(); + let ids = ids_of(&s.execute("edit.paste", &json!({})).unwrap()); + assert_eq!(doc(&s).parent_of(ids[0]), Some(sub)); + assert_eq!(layer_path(&s, ids[0]), ["Layer 2", "Sub"]); + assert!(node(&s, first).children().unwrap().is_empty(), "no Sub is made in Layer 1"); +} + +/// In another document with layers of the same names, art goes into the topmost chain of layers +/// that matches the most names, a shown and unlocked one before a hidden or locked one. +#[test] +fn paste_remembers_layers_takes_the_topmost_open_match_of_repeated_names() { + let mut s = session(); + let first = doc(&s).layers[0].id; + sublayer(&mut s, first, "Sub"); + let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0); + copy(&mut s, &[a]); + new_doc(&mut s); + s.execute("layer.pasteRemembersLayers", &json!({"on": true})).unwrap(); + let lower = doc(&s).layers[0].id; + let lower_sub = sublayer(&mut s, lower, "Sub"); + s.execute("layer.new", &json!({"name": "Sub", "top": true})).unwrap(); + let upper = id_of(&s.execute("layer.new", &json!({"name": "Layer 1", "top": true})).unwrap()); + // The upper Layer 1 has no Sub, and a top-level Sub is not inside a Layer 1: the lower chain + // matches both names. + let ids = ids_of(&s.execute("edit.paste", &json!({})).unwrap()); + assert_eq!(doc(&s).parent_of(ids[0]), Some(lower_sub)); + let upper_sub = sublayer(&mut s, upper, "Sub"); + let ids = ids_of(&s.execute("edit.paste", &json!({})).unwrap()); + assert_eq!(doc(&s).parent_of(ids[0]), Some(upper_sub), "the topmost of two full matches"); + s.execute("layer.setProps", &json!({"id": upper_sub.0, "locked": true})).unwrap(); + let ids = ids_of(&s.execute("edit.paste", &json!({})).unwrap()); + assert_eq!(doc(&s).parent_of(ids[0]), Some(lower_sub), "an unlocked match before a locked one"); +} + +/// A remembered layer or sublayer that is hidden or locked, or inside a layer that is, refuses the +/// whole paste: the document, the selection and the undo history stay as they were. +#[test] +fn paste_remembers_layers_refuses_a_hidden_or_locked_layer() { + let (locked, hidden) = (json!({"locked": true}), json!({"visible": false})); + for (on_sub, props) in [(true, &locked), (true, &hidden), (false, &locked), (false, &hidden)] { + let mut s = session(); + let first = doc(&s).layers[0].id; + let sub = sublayer(&mut s, first, "Sub"); + let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0); + let other = id_of(&s.execute("layer.new", &json!({"name": "Other", "top": true})).unwrap()); + let b = rect(&mut s, 20.0, 0.0, 10.0, 10.0); + copy(&mut s, &[b, a]); + s.execute("layer.pasteRemembersLayers", &json!({"on": true})).unwrap(); + let mut p = props.clone(); + p["id"] = json!(if on_sub { sub.0 } else { first.0 }); + s.execute("layer.setProps", &p).unwrap(); + s.execute("layer.setCurrent", &json!({"id": other.0})).unwrap(); + let before = s.doc().unwrap().clone(); + assert!(s.execute("edit.paste", &json!({})).is_err(), "{p}"); + assert_eq!(s.doc().unwrap().doc, before.doc, "{p}"); + assert_eq!(s.doc().unwrap().selection, before.selection, "{p}"); + assert_eq!(s.doc().unwrap().history.undo.len(), before.history.undo.len(), "{p}"); + } + // In another document, a locked layer of the copied layer's name is refused too. + let mut s = session(); + let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0); + copy(&mut s, &[a]); + new_doc(&mut s); + s.execute("layer.pasteRemembersLayers", &json!({"on": true})).unwrap(); + let locked = doc(&s).layers[0].id; + id_of(&s.execute("layer.new", &json!({"name": "Flyer"})).unwrap()); + s.execute("layer.setProps", &json!({"id": locked.0, "locked": true})).unwrap(); + let before = doc(&s).clone(); + assert!(s.execute("edit.paste", &json!({})).is_err()); + assert_eq!(*doc(&s), before, "no second Layer 1 is made"); +} + #[test] fn paste_remembers_layers_survives_save_and_old_files_load_without_it() { let mut s = session(); diff --git a/docs/mcp.md b/docs/mcp.md index 20e37222c6..2b4bca2dfa 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -6031,7 +6031,11 @@ recorded action leaves both out. Art copied from no artboard keeps its place on offset to that one on the others. An index the document does not have, a repeated index, a value that is not an index or an empty list fails, and nothing is pasted. Copied artboards come back once, where they were, whichever artboards are named. `layer.pasteRemembersLayers {on?}` (a document option, `document.inspect` → `pasteRemembersLayers`) -pastes objects back into the layers they came from, by name, making missing ones. Drawing modes (`view.drawMode`) apply +pastes objects back into the layers and sublayers they came from: in the same document while those are still there, +else by the names of the layer and the layers around it (the topmost chain that matches the most names, a shown and +unlocked one first). A missing sublayer is made inside its layer, and a missing top-level layer on top, in the paste's +undo step. When a matched layer, or a layer around it, is hidden or locked, the paste fails and nothing is pasted. +Drawing modes (`view.drawMode`) apply too: in Draw Inside, `edit.paste`, `edit.pasteInPlace` and `edit.pasteOnAllArtboards` put the objects inside the object drawn into (its clip group, made on the first paste), whatever Paste Remembers Layers says; in Draw Behind, `edit.pasteInPlace` and `edit.pasteOnAllArtboards` put them just below the selection. `edit.pasteInFront` and diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 30b1e883d7..1ff4c3d6c8 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1092,6 +1092,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Ruler guides as layer objects:** a guide goes on the current layer, shows, hides, locks and is deleted with it, is listed in the Layers panel (its row selects it) and moves to another layer (`guide.setLayer`, or its row dragged onto that layer's rows); guides on a hidden or locked layer can't be selected, moved or deleted, Duplicate copies a layer's guides and Merge Selected and Flatten Artwork keep them on the merged layer; guides from files saved before guides had layers go on the top visible, unlocked layer when the document opens. Not done: guides stacked among the art of their layer (they are listed above it), a guide row's own eye and lock. Lock Guides also locks the objects made into guides: they can't be selected, moved or deleted (#1543); they are drawn in the guide colour and style of Preferences, and a dragged selection snaps to ruler guides with Smart Guides on (#1583, fixes #1534). Preferences › Guides & Grid › Lock Guides by Default locks guides when the app starts; it is off by default, and new guides can still be dragged out of a ruler while guides are locked. The Transform panel shows a selected ruler guide's X or Y, and typing a value moves it (#1659, part of #1597). This is not established by the documentation we could find: the public help pages on guides, rulers and the Transform, Control and Properties panels (read again on 2026-10-14) describe selecting and dragging unlocked guides and the panels' X and Y for a selection, not a guide's position in a panel, and the one public statement of it is a user's [community answer](https://community.adobe.com/questions-652/adding-a-new-custom-guide-785586), which is not documentation. It is kept as a convenience pending the owner's decision, with no parity claim and nothing counted towards a score. - **Releasing ruler guides:** View › Guides › Release Guides turns the selected ruler guides into paths with no fill or stroke. An artboard guide becomes a line across its artboard, and a canvas guide becomes a line across the artboards (`view.guides.release {index?, ids?}`). A Cmd/Ctrl+Shift double-click with the Selection, Direct Selection or Group Selection tool releases the ruler guide or guide path under the pointer. - **Layers panel:** every layer, sublayer, group and object is a clickable row at any depth: a click highlights it and makes its layer current (new art goes there), Shift-click a range, Ctrl/Cmd-click toggles. Sublayers are no longer treated as objects: clicks, marquees, Select All and the Select menu take the art inside them, and each sublayer selects in its own colour. Selection squares on rows with selected art (click selects a row's art, Shift adds or removes; dragging the square moves the selection to another row, Alt copies), the eye and lock on every row with drag-down-the-column (one undo step), Ctrl/Cmd-click an eye for per-layer Outline, Alt-click an eye or lock to hide or lock the other layers (again: show or unlock them all), triangles with Alt to open or close everything inside, drag and drop rows above, below or into layers and groups with a drop line (Alt copies) and a dimmed copy of the row under the pointer (layers dropped in the empty space under the last row go below the bottom layer, #2260; dropped on the trash rows are deleted, on New Layer duplicated, as in Illustrator), inline rename, Delete key on highlighted rows. Buttons and the full panel menu: New Layer/Sublayer (Alt: options, Ctrl: on top), Duplicate, Delete, Options for Selection, clipping mask, isolation, Locate Object, Merge Selected, Flatten Artwork, Collect in New Layer, Release to Layers (Sequence and Build), Reverse Order, Template, Hide/Outline/Lock Others and Show/Preview/Unlock All, Paste Remembers Layers, Panel Options (row size, thumbnails per kind, Show Layers Only). Layer Options: name, colour (presets, each with a chip of its colour, or custom), Template, Lock, Show, Print, Preview (outline drawing and clicking) and Dim Images to %. Every operation is a command (`layer.move`, `layer.highlight`, `layer.merge`…) with validation against cycles and invalid nesting. A document opens with only its top-level layers open; the rows left open are saved in the native file (nothing is written for the default) and reopen that way. Only the rows scrolled into view are laid out, from a row list made once per change: a layer of 5,000 groups of 10 paths, all open, draws the panel in 0.4 ms a frame instead of about 29 s (#638). + With Paste Remembers Layers on, art copied from a sublayer pastes back into that sublayer, as [the help page](https://help.adobe.com/archive/en_US/illustrator/cs5/illustrator_cs5_help.pdf#page=223) describes art going back to the layer it was copied from: in the same document while the sublayer is still there, two sublayers of the same name in different layers kept apart; in another document by the names of the sublayer and the layers around it. These rules are VectorCraft's own: a missing sublayer is made inside its layer (a missing layer on top, with the sublayer inside it), in the paste's one undo step; of layers with the same names, the topmost chain that matches the most names wins, a shown and unlocked one first; a matched layer that is hidden or locked, or inside one that is, refuses the paste, and nothing is pasted. The Filter button at the right of Search All keeps the rows that are of an object kind ticked under By Object (paths, shapes, text, images, effects, groups, linked files, guides) and in a state ticked under By Layer (locked, hidden), with the rows that hold them; a section with nothing ticked takes every row. The filter combines with the search, the button is lit while a filter is on, and Clear All in its popup turns the filter off (`ui.layersFilter`). The filter lasts the session. The panel menu's Save Selection submenu has Save Selection…, Update Selection and Edit Selection…, enabled and run as in the Select menu, and lists the document's saved selections: choosing one selects its objects again (`select.recall`). `object.setProps` with `visible: false` or `locked: true` deselects every selected object that is then hidden or locked, the key object included, as the eye and lock columns do.