From 0af80963649c4e164c8a09a123f4456387037887 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 31 Oct 2026 22:12:19 -0700 Subject: [PATCH] Symbols: a refused switch to another symbol keeps the current edit, and isolation stays on the editing layer symbol.edit checks the new placement before it ends the symbol being edited, so a call with an out-of-range x or y fails and leaves the current edit open. While a symbol is edited, object.isolate refuses art outside the editing layer, and object.exitIsolation goes back to the editing layer only from a group inside it; from any other isolation it leaves symbol editing mode. --- crates/engine/src/cmd/object/arrange.rs | 8 +++++- crates/engine/src/cmd/symboledit.rs | 11 +++++++- crates/engine/src/tests/symboledit.rs | 35 +++++++++++++++++++++++++ docs/mcp.md | 7 ++--- 4 files changed, 56 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/cmd/object/arrange.rs b/crates/engine/src/cmd/object/arrange.rs index 69d2baa9c..2e7d2a8e4 100644 --- a/crates/engine/src/cmd/object/arrange.rs +++ b/crates/engine/src/cmd/object/arrange.rs @@ -315,6 +315,12 @@ pub(super) fn isolate(s: &mut Session, p: &Value) -> Result { if guides_locked && crate::is_guide_path(&st.doc, isolation) { return Err(EngineError::Other("guides are locked".into())); } + // While a symbol is edited, only its art on the editing layer is isolated. + if let Some(layer) = st.doc.symbol_edit.as_ref().map(|e| e.layer) + && !st.doc.ancestry(isolation).is_some_and(|a| a.contains(&layer)) + { + return Err(bad("object.isolate", "the object is outside the isolated group")); + } (isolation, st.isolation) }; if let Some(prev) = prev @@ -339,7 +345,7 @@ pub(super) fn exit_isolation(s: &mut Session, p: &Value) -> Result { // Done do. A group isolated inside the symbol's art goes back to the symbol's layer. let st = s.doc()?; if let Some(layer) = st.doc.symbol_edit.as_ref().map(|e| e.layer) - && let Some(inner) = st.isolation.filter(|i| *i != layer) + && let Some(inner) = st.isolation.filter(|i| *i != layer && st.doc.ancestry(*i).is_some_and(|a| a.contains(&layer))) { crate::cmd::settle_interaction(s)?; let st = s.doc_mut()?; diff --git a/crates/engine/src/cmd/symboledit.rs b/crates/engine/src/cmd/symboledit.rs index a89fbe8d6..0afdd7520 100644 --- a/crates/engine/src/cmd/symboledit.rs +++ b/crates/engine/src/cmd/symboledit.rs @@ -112,11 +112,20 @@ pub(crate) fn enter(s: &mut Session, p: &Value) -> Result { } }; // Editing another symbol (one from the Symbols panel, or through the API): the current edit - // ends first. + // ends first, once the new placement is known to be valid, so a refused switch keeps it open. if st.doc.symbol_edit.is_some() { if instance.is_some_and(|i| st.doc.symbol_edit.as_ref().is_some_and(|e| st.doc.ancestry(i).unwrap_or_default().contains(&e.layer))) { return Err(bad(C, "exit isolation mode first")); } + let mut trial = Document::clone(&st.doc); + let mut sel = Selection::default(); + begin(&mut trial, &mut sel, &name, xf, instance)?; + if trial.too_deep(Some(&st.doc)) { + return Err(EngineError::Other(crate::TOO_DEEP.into())); + } + if !crate::sanity::doc_sane(&trial, &sel) { + return Err(EngineError::Other("result would exceed the canvas (coordinates out of range)".into())); + } leave(s, &json!({}))?; } if let Some(i) = s.doc()?.isolation { diff --git a/crates/engine/src/tests/symboledit.rs b/crates/engine/src/tests/symboledit.rs index 491d6cd83..4087fb48b 100644 --- a/crates/engine/src/tests/symboledit.rs +++ b/crates/engine/src/tests/symboledit.rs @@ -372,6 +372,41 @@ fn other_editing_modes_and_symbol_editing_do_not_overlap() { assert_eq!(s.doc().unwrap().doc.layers.iter().filter(|l| l.name.as_deref() == Some("Box")).count(), 1); } +/// Editing another symbol at a place off the canvas fails and keeps the current edit open. +#[test] +fn a_switch_to_another_symbol_that_fails_keeps_the_current_edit() { + let (mut s, first, _) = two_instances(); + run(&mut s, "shape.rectangle", json!({"x": 10, "y": 200, "width": 20, "height": 20})); + run(&mut s, "symbol.new", json!({"name": "Dot"})); + let layer = NodeId(run(&mut s, "symbol.edit", json!({"id": first.0}))["layer"].as_u64().unwrap()); + let e = s.execute("symbol.edit", &json!({"name": "Dot", "x": 1e300})).unwrap_err().to_string(); + assert!(e.contains("exceed the canvas"), "{e}"); + let st = s.doc().unwrap(); + assert_eq!(st.doc.symbol_edit.as_ref().map(|e| (e.symbol.as_str(), e.layer)), Some(("Box", layer))); + assert_eq!((st.isolation, st.active_layer), (Some(layer), Some(layer))); + assert!(st.doc.node(layer).is_some()); +} + +/// While a symbol is edited, art outside the editing layer is not isolated, so leaving isolation +/// mode leaves symbol editing mode. +#[test] +fn art_outside_the_editing_layer_is_not_isolated_while_a_symbol_is_edited() { + let (mut s, first, _) = two_instances(); + let other = id(&run(&mut s, "shape.rectangle", json!({"x": 10, "y": 200, "width": 20, "height": 20}))); + run(&mut s, "select.set", json!({"ids": [first.0]})); + let layer = NodeId(run(&mut s, "symbol.edit", json!({"id": first.0}))["layer"].as_u64().unwrap()); + let e = s.execute("object.isolate", &json!({"id": other.0})).unwrap_err().to_string(); + assert!(e.contains("outside the isolated group"), "{e}"); + assert_eq!(s.doc().unwrap().isolation, Some(layer)); + // Isolation that names art outside the layer (a stale state) still leaves the mode. + s.doc_mut().unwrap().isolation = Some(other); + run(&mut s, "object.exitIsolation", json!({})); + let st = s.doc().unwrap(); + assert!(st.doc.symbol_edit.is_none() && st.doc.node(layer).is_none()); + assert_eq!(st.isolation, None); + assert_eq!(st.selection.objects, vec![first]); +} + #[test] fn renaming_deleting_and_saving_while_a_symbol_is_edited() { let (mut s, first, _) = two_instances(); diff --git a/docs/mcp.md b/docs/mcp.md index 1846d5bc2..bc762c434 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1575,7 +1575,8 @@ already walks Image Trace. Short agent entry points: `symbol.edit {id?, ids?, name?, x?, y?, artboard?, viewCenter?}` (symbol editing mode: the symbol's art on a temporary isolated layer, where the instance `id` shows it or centered on (x, y), by default on the center of artboard `artboard`, the active one the app passes, or in a document without artboards on `viewCenter`, the view's center the - app passes; every instance follows each edit and stays linked), `symbol.exitEditing` + app passes; every instance follows each edit and stays linked; while another symbol is edited, that edit ends first, + and a call that fails leaves it open), `symbol.exitEditing` (leaves the mode; without an edit it adds no undo step), `symbol.duplicate`, `symbol.selectInstances {name}`, `symbol.delete {name | names, expandInstances?}` (`names` deletes several in one undo step). `symbol.new` and `symbol.duplicate {name, newName?}` trim the white space around the @@ -2425,8 +2426,8 @@ and `layerRows`, the rows highlighted in the panel. Preview All Layers when the others are in Outline already), `layer.lockOthers` / `layer.unlockAll` (Alt-clicking an eye or a lock runs Hide or Lock Others for the row's layer, or Show or Unlock All when the others already are), `layer.pasteRemembersLayers`, `object.isolate {id?}` (a group, a layer or a single object, by default the first selected object; not type, not a - hidden or locked object or one in a hidden or locked group or layer, and not a guide path while Lock Guides is on) / - `object.exitIsolation`. + hidden or locked object or one in a hidden or locked group or layer, not a guide path while Lock Guides is on, and + while a symbol is edited only the editing layer or art on it) / `object.exitIsolation`. - **Dialogs and panel state** (control channel and the app's MCP): `ui.layerOptions {ids?}` and `ui.newLayer {sublayer?}` open Layer Options (dialog `layerOptions`: name, color, template, locked, visible, printable, preview, dimImages, dimPercent; OK is one undo step; Cmd/Ctrl+Alt+L runs `ui.newLayer` for a new layer), `ui.layersPanelOptions` opens Panel Options (`layersPanelOptions`: