From 3073f0ab030dc41c8307d16e0a6f930ee8a2e095 Mon Sep 17 00:00:00 2001 From: "Ayberk A." <105879357+AyberkA0@users.noreply.github.com> Date: Mon, 12 Oct 2026 14:52:32 +0300 Subject: [PATCH] Isolation mode: double-clicking a single object isolates it With Double Click To Isolate on, the Selection tool isolated groups only. A double-click on a single path, compound path, image, mesh or placed file now isolates it too, as the public documentation describes. Type still switches to the Type tool; blends, envelopes, repeats and symbol instances keep their own editing modes. While a single object is isolated only it is hit, marqueed and taken by Select All, new art goes beside it in its group or layer, and a double-click on the empty canvas leaves with it selected. object.isolate accepts any object except type. --- crates/doc/src/hit.rs | 20 ++++++++++-- crates/engine/src/cmd/object.rs | 6 ++-- crates/engine/src/cmd/select.rs | 3 +- crates/engine/src/lib.rs | 6 ++++ crates/engine/src/tests_prefs.rs | 54 ++++++++++++++++++++++++++++++++ crates/tools/src/corners.rs | 5 ++- crates/tools/src/select.rs | 17 +++++++--- docs/mcp.md | 5 +-- docs/ui-parity.md | 3 +- 9 files changed, 104 insertions(+), 15 deletions(-) diff --git a/crates/doc/src/hit.rs b/crates/doc/src/hit.rs index 226241c8..b74d7c2c 100644 --- a/crates/doc/src/hit.rs +++ b/crates/doc/src/hit.rs @@ -31,6 +31,10 @@ impl Hit { /// The object the Selection tool selects: the child of the layer (the outermost group). /// Inside isolation mode (`scope`), the child of the isolated container instead. pub fn top_object(&self, scope: Option) -> NodeId { + // A single object isolated: it is the only object. + if scope == Some(self.leaf) { + return self.leaf; + } // The innermost of the isolated container and an envelope whose contents are edited. let at = |s: Option| s.and_then(|s| self.ancestry.iter().position(|x| *x == s)); if let Some(i) = at(scope).max(at(self.contents_of)) { @@ -101,10 +105,16 @@ fn hit_test_skipping(doc: &Document, p: Point, opt: HitOptions, skip: &dyn Fn(No // locked, and clicks pass through it (#833). if editable_only && let Some(scope) = opt.scope - && let Some(container) = doc.node(scope).filter(|n| n.is_container()) + && let Some(isolated) = doc.node(scope) && let Some(mut chain) = doc.ancestry(scope) { - return hit_children(container, p, opt, skip, &mut chain, editable_only).map(finish); + if isolated.is_container() { + return hit_children(isolated, p, opt, skip, &mut chain, editable_only).map(finish); + } + // A single object isolated (a path, an image…): only it hits, among its siblings. + chain.pop(); + let parent = chain.last().and_then(|id| doc.node(*id))?; + return hit_children(parent, p, opt, &|id| id != scope || skip(id), &mut chain, editable_only).map(finish); } let mut chain = Vec::new(); for layer in doc.layers.iter().rev() { @@ -283,7 +293,11 @@ fn hit_any(n: &Node, p: Point, opt: HitOptions) -> Option { pub fn marquee(doc: &Document, r: Rect, scope: Option, leaves: bool) -> Vec { let mut out = Vec::new(); let tops: Vec<&std::sync::Arc> = match scope.and_then(|s| doc.node(s)) { - Some(s) => s.children().map(|c| c.iter().collect()).unwrap_or_default(), + Some(s) => match s.children() { + Some(c) => c.iter().collect(), + // A single object isolated: only it. + None => doc.parent_of(s.id).and_then(|p| doc.node(p)).and_then(Node::children).into_iter().flatten().filter(|c| c.id == s.id).collect(), + }, // The objects of the layers, looking through sublayers (hidden, locked and template ones // are left out). None => doc diff --git a/crates/engine/src/cmd/object.rs b/crates/engine/src/cmd/object.rs index 6928fa15..7de994ed 100644 --- a/crates/engine/src/cmd/object.rs +++ b/crates/engine/src/cmd/object.rs @@ -857,8 +857,10 @@ fn isolate(s: &mut Session, p: &Value) -> Result { .or_else(|| s.active().and_then(|d| d.selection.objects.first().copied())) .ok_or_else(|| bad("object.isolate", "missing id"))?; let st = s.doc_mut()?; - if st.doc.node(id).is_none_or(|n| !n.is_container()) { - return Err(bad("object.isolate", "only groups and layers can be isolated")); + // Groups, layers and single objects (a path, a compound path, an image…); type isn't + // isolated: a double-click on it edits its text. + if st.doc.node(id).is_none_or(|n| matches!(n.kind, NodeKind::Text(_))) { + return Err(bad("object.isolate", "type objects can't be isolated")); } let isolation = st.doc.node(id).filter(|n| n.extras().shaper.is_some()).and_then(|n| n.children()).and_then(|c| c.first()).map_or(id, |n| n.id); st.isolation = Some(isolation); diff --git a/crates/engine/src/cmd/select.rs b/crates/engine/src/cmd/select.rs index 36d6fdb5..9db257c3 100644 --- a/crates/engine/src/cmd/select.rs +++ b/crates/engine/src/cmd/select.rs @@ -193,7 +193,8 @@ pub fn specs() -> Vec { fn selectable(d: &Document, iso: Option) -> Vec { match iso.and_then(|i| d.node(i)) { Some(c) if d.ancestry(c.id).is_some_and(|a| a.iter().all(|i| d.node(*i).is_some_and(|n| n.visible && !n.locked && !n.is_template()))) => { - c.layer_art(true) + // A single object isolated is all there is to select. + if c.is_container() { c.layer_art(true) } else { vec![c.id] } } Some(_) => vec![], None => d.selectable_art(), diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 0bbfdeeb..2b0a390a 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -483,6 +483,12 @@ impl DocState { { return Some(i); } + // A single object isolated: new art goes beside it, in its group or layer. + if let Some(p) = self.isolation.filter(|i| self.doc.node(*i).is_some_and(|n| !n.is_container())).and_then(|i| self.doc.parent_of(i)) + && self.doc.node(p).is_some_and(|n| matches!(n.kind, NodeKind::Group { .. } | NodeKind::Layer { .. })) + { + return Some(p); + } // A sublayer takes new art only while it and the layers around it are shown and unlocked. self.active_layer.filter(|l| self.doc.node(*l).is_some_and(|n| n.is_layer()) && self.doc.is_editable(*l)).or_else(|| self.doc.default_layer()) } diff --git a/crates/engine/src/tests_prefs.rs b/crates/engine/src/tests_prefs.rs index cd9374a2..9b831a02 100644 --- a/crates/engine/src/tests_prefs.rs +++ b/crates/engine/src/tests_prefs.rs @@ -298,6 +298,60 @@ fn selection_preferences_drive_the_selection_tool() { assert!(double(&mut s).is_some(), "on: the group is isolated"); } +/// Double Click To Isolate: a double-click with the Selection tool isolates a single object too, +/// not only a group. Inside, only it is hit, marqueed and selected by Select All; new art goes +/// beside it; a double-click on the empty canvas leaves, with it selected. Type isn't isolated: +/// a double-click on it switches to the Type tool. +#[test] +fn double_clicking_a_single_object_isolates_it() { + use vectorcraft_tools::{PointerEvent, PointerKind}; + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); + let id = |v: Value| NodeId(v["id"].as_u64().unwrap()); + let rect = |s: &mut Session, x: f64| id(s.execute("shape.rectangle", &json!({"x": x, "y": 100, "width": 50, "height": 50})).unwrap()); + let (a, b) = (rect(&mut s, 100.0), rect(&mut s, 200.0)); + s.select_tool("selection", ViewInfo::default()).unwrap(); + let ev = |s: &mut Session, kinds: &[PointerKind], x: f64, y: f64| { + for k in kinds { + s.pointer(&PointerEvent::new(*k, x, y), ViewInfo::default()).unwrap(); + } + }; + let click = [PointerKind::Down, PointerKind::Up]; + let double = [PointerKind::DoubleClick]; + ev(&mut s, &double, 125.0, 125.0); + assert_eq!(s.doc().unwrap().isolation, Some(a)); + // Only the isolated object is hit: a click on the other selects nothing. + ev(&mut s, &click, 225.0, 125.0); + assert!(s.doc().unwrap().selection.objects.is_empty()); + ev(&mut s, &click, 125.0, 125.0); + assert_eq!(s.doc().unwrap().selection.objects, vec![a]); + // A marquee over both and Select All take it alone. + s.execute("select.none", &json!({})).unwrap(); + ev(&mut s, &[PointerKind::Down], 90.0, 90.0); + ev(&mut s, &[PointerKind::Move, PointerKind::Up], 300.0, 300.0); + assert_eq!(s.doc().unwrap().selection.objects, vec![a]); + s.execute("select.none", &json!({})).unwrap(); + s.execute("select.all", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().selection.objects, vec![a]); + // A second double-click on it stays in isolation; new art lands on its layer. + ev(&mut s, &double, 125.0, 125.0); + assert_eq!(s.doc().unwrap().isolation, Some(a)); + let c = rect(&mut s, 300.0); + let st = s.doc().unwrap(); + assert_eq!(st.doc.parent_of(c), st.doc.parent_of(a)); + // A double-click on the empty canvas leaves isolation, the object selected. + ev(&mut s, &double, 50.0, 350.0); + let st = s.doc().unwrap(); + assert_eq!((st.isolation, st.selection.objects.clone()), (None, vec![a])); + assert!(st.doc.node(b).is_some()); + // Type is not isolated. + let t = id(s.execute("text.create", &json!({"x": 100, "y": 300, "text": "Type"})).unwrap()); + assert!(s.execute("object.isolate", &json!({"id": t.0})).is_err()); + s.execute("prefs.set", &json!({"key": "doubleClickToIsolate", "value": false})).unwrap(); + ev(&mut s, &double, 225.0, 125.0); + assert_eq!(s.doc().unwrap().isolation, None, "off: no isolation"); +} + /// General › Use Precise Cursors (#394): the Pen's pointer becomes a crosshair; the Selection /// tool's arrow stays. #[test] diff --git a/crates/tools/src/corners.rs b/crates/tools/src/corners.rs index f6311cef..949337b3 100644 --- a/crates/tools/src/corners.rs +++ b/crates/tools/src/corners.rs @@ -632,7 +632,10 @@ mod tests { let cx2 = cx(&d, &whole, &p); let a = crate::create("selection").pointer(&cx2, &PointerEvent::new(PointerKind::DoubleClick, 190.0, 110.0)); assert_eq!(a, vec![Action::Dialog(DIALOG.into(), json!({"id": id.0, "corners": [0, 1, 2, 3]}))]); - assert!(crate::create("selection").pointer(&cx2, &PointerEvent::new(PointerKind::DoubleClick, 150.0, 150.0)).is_empty()); + // Off the widgets, the Selection tool's double-click isolates the shape (Double Click To + // Isolate) instead. + let a = crate::create("selection").pointer(&cx2, &PointerEvent::new(PointerKind::DoubleClick, 150.0, 150.0)); + assert_eq!(a, vec![Action::Exec("object.isolate".into(), json!({"id": id.0}))]); } /// Hide Corner Widget for angles greater than (#394): a rectangle's right angles hide below diff --git a/crates/tools/src/select.rs b/crates/tools/src/select.rs index 3cae0937..b175d72a 100644 --- a/crates/tools/src/select.rs +++ b/crates/tools/src/select.rs @@ -3,8 +3,8 @@ //! Guides, or with them off View › Snap to Point, snap it), //! bounding-box scale (Shift proportional, Alt from centre) and rotate (outside corners, Shift 45°), //! drag a live rectangle's or polygon's corner widget to round its corners (Alt-click cycles their kind, -//! double-click opens the Corners dialog), double-click to enter isolation mode (Double Click To -//! Isolate), Cmd/Ctrl-click to select the object behind (Command Click to Select Objects Behind), +//! double-click opens the Corners dialog), double-click a group or an object to enter isolation +//! mode (Double Click To Isolate), Cmd/Ctrl-click to select the object behind (Command Click to Select Objects Behind), //! click or drag a ruler guide ([`crate::rulerguide`]), drag the brackets of type on a path //! ([`crate::pathtype`]), double-click the type widget to convert point type to area type and back //! ([`crate::typewidget`]). @@ -260,12 +260,19 @@ impl Tool for SelectionTool { } if let Some(h) = hit_test(cx.doc, p, cx.hit_options()) { let top = h.top_object(cx.isolation); - if cx.double_click_isolate && cx.doc.node(top).is_some_and(|n| matches!(n.kind, vectorcraft_doc::NodeKind::Group { .. })) { - return vec![Action::Exec("object.isolate".into(), json!({ "id": top.0 }))]; - } if cx.doc.node(top).is_some_and(|n| matches!(n.kind, vectorcraft_doc::NodeKind::Text(_))) { return vec![Action::SwitchTool("type".into())]; } + // A group, a compound path or a single path, image, mesh or placed file, not + // the one already isolated. Blends, envelopes, repeats and symbol instances + // aren't: they have editing modes of their own. + use vectorcraft_doc::NodeKind as K; + let isolates = cx.doc.node(top).is_some_and(|n| { + matches!(n.kind, K::Group { .. } | K::Compound { .. } | K::Path { .. } | K::Image(_) | K::Mesh(_) | K::PlacedDocument(_)) + }); + if cx.double_click_isolate && isolates && cx.isolation != Some(top) { + return vec![Action::Exec("object.isolate".into(), json!({ "id": top.0 }))]; + } } else if cx.isolation.is_some() { return vec![Action::Exec("object.exitIsolation".into(), json!({}))]; } diff --git a/docs/mcp.md b/docs/mcp.md index 36adee92..e39c31f8 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1159,7 +1159,7 @@ and `layerRows`, the rows highlighted in the panel. `layer.reverse {ids?}`, `layer.template {ids?, on?}`, `layer.hideOthers` / `layer.showAll`, `layer.outlineOthers` / `layer.previewAll`, `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}` / `object.exitIsolation`. + `object.isolate {id}` (a group, a layer or a single object; not type) / `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), `ui.layersPanelOptions` opens Panel Options (`layersPanelOptions`: @@ -1572,7 +1572,8 @@ The Selection & Anchor Display and General preferences apply to `pointer_gesture - `ctrlClickSelectsBehind` (on by default): a Selection tool click with `mods: {cmd: true}` (Command on macOS, Ctrl elsewhere) selects the object under the selected one there, the next such click the one under that, then the topmost again. Cmd held to borrow the selection tool from another tool clicks as usual. -- `doubleClickToIsolate` (on by default): off, a `doubleclick` on a group with the Selection tool no longer isolates it. +- `doubleClickToIsolate` (on by default): a `doubleclick` with the Selection tool isolates a group, a compound path or a + single path, image, mesh or placed file (not type, blends, envelopes, repeats or symbol instances); off, it no longer does. - `usePreciseCursors`: the Pen, Eyedropper, Slice and Blend tools' pointers are a crosshair. - `snapToPointTolerance` (1–8 px, 2 by default): with View › Snap to Point on and Smart Guides off (desktop app), the point a selection is dragged by, a drawn point and a transform tool's reference point land on an anchor or a ruler diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 5572264f..1f3f3faa 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -74,7 +74,7 @@ tools, never checked side by side) isn't captured row by row. | Cut path at selected anchors, Remove Anchor Points, Delete removes segments | done | #1278: segment Delete preserves neighboring edges and endpoint anchors; a second Delete removes the remainder; explicit anchor Delete retains its behavior | | Live Corners widgets (radius readout, Alt-click cycles kind, double-click the Corners dialog) | done | a Direct Selection click on a widget selects its corner (#1239); a press-and-drag on a widget of a path selected whole still rounds every corner (#908), and adding widgets to the selection by Shift-click (#1232) is missing | | Corners between curved sides (#1144), Relative rounding | partial | Relative rounding missing | -| Isolation mode by double-click, breadcrumbs, Esc to exit | done | | +| Isolation mode by double-click (groups and single objects), breadcrumbs, Esc to exit | done | Single paths, compound paths, images, meshes and placed files isolate too ([helpx: Isolate objects](https://helpx.adobe.com/africa/illustrator/desktop/manage-objects/select-objects/isolate-objects.html)) | | "Magnetic" moves of selected anchors with a modifier (#955 request) | missing | not an Illustrator feature as documented; under discussion | ## Smart guides and snapping @@ -205,6 +205,7 @@ re-measure of the panels added since. | 2026-10-12 | minor | Attributes: directly selected compound members read and change the compound fill rule; path direction stays member-local (#1197); score unchanged | | 2026-10-12 | minor | Pathfinder Divide: open cutting lines split filled paths with one Undo (#1307); scores and hours unchanged | | 2026-10-12 | minor | Numeric entry: click-to-focus selection waits for release so text drags keep their start (#1326, in part); keyboard focus, double-click, arrows and wheel preserved; scores and hours unchanged; short movements below the click threshold remain open | +| 2026-10-12 | minor | Double Click To Isolate: the Selection tool also isolates a single path, compound path, image, mesh or placed file; only it is hit, marqueed and selected inside; scores and hours unchanged | | 2026-10-11 | minor | A marquee inside a filled shape no longer selects it: only touching its path does (#1103); score unchanged (estimated) | | 2026-10-11 | minor | Live Corners: corners between curved sides round too, the curve trimmed to what the cut leaves (#1144); score unchanged (estimated) | | 2026-10-11 | minor | Artboards: Shift-click selects several, which move, copy and delete together (#1139); score unchanged (estimated) |