From f534b0061a527e308e1623d5041b88fc016ea9bf Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 6 Nov 2026 10:18:33 -0800 Subject: [PATCH] Type: switching to the Eyedropper keeps a whole-story selection With a threaded story selected by Select All, switching to the Eyedropper kept only the edited frame's selection, so a pick styled one frame. The selection now carries the story's frames, a pick or Shift-click applies to the text of every frame as one undo step, and switching back to a Type tool restores the story selection. A selection inside one frame and unthreaded type behave as before. A pick and a fill or stroke change on a selected story leave locked and hidden frames and frames on a locked artboard as they are. appearance.copyFrom leaves out a frame that is its source. Typing over a selected story no longer removes the text of a frame whose thread was removed after Select All. --- crates/engine/src/cmd/appearance.rs | 18 ++-- crates/engine/src/cmd/paint.rs | 11 ++- crates/engine/src/tests/textedit.rs | 133 ++++++++++++++++++++++++++++ crates/engine/src/tooling.rs | 5 +- crates/tools/src/text.rs | 7 +- crates/tools/src/text/tool.rs | 6 ++ docs/mcp.md | 6 +- docs/target-app-parity.md | 1 + 8 files changed, 171 insertions(+), 16 deletions(-) diff --git a/crates/engine/src/cmd/appearance.rs b/crates/engine/src/cmd/appearance.rs index c8c479bc0..adf5ecca8 100644 --- a/crates/engine/src/cmd/appearance.rs +++ b/crates/engine/src/cmd/appearance.rs @@ -126,7 +126,7 @@ pub fn specs() -> Vec { "Eyedropper", [], None, - "{source: id, ids?, pickUp?, apply? (trees as eyedropper.setOptions, merged over the Eyedropper Options for this call), reverse?: bool, append?: bool, defaultsOnly?: bool} copy the attributes both picked up and applied (by default the whole appearance stack, opacity and blend mode, and from type to type the character and paragraph attributes) from `source` to ids (default: highlighted characters while editing type, otherwise the object selection; highlighted characters reject enabled non-neutral object transparency), the fill, stroke and weight also to the paint defaults; `defaultsOnly` updates only paint defaults and cannot be combined with ids, reverse or append; `reverse` (Alt-click) copies from the first selected object (or ids) onto `source` instead, or with nothing selected what the Eyedropper picked up last (a click without Alt); `append` (Shift+Alt-click) adds the source's fills, strokes and effects on top of each target's stack; placed gradients land at the same place relative to each target's bounds (defaults: fitted to new art) → {ids}", + "{source: id, ids?, pickUp?, apply? (trees as eyedropper.setOptions, merged over the Eyedropper Options for this call), reverse?: bool, append?: bool, defaultsOnly?: bool} copy the attributes both picked up and applied (by default the whole appearance stack, opacity and blend mode, and from type to type the character and paragraph attributes) from `source` to ids (default: highlighted characters while editing type, after Select All in a frame of threaded text the text of the story's frames that are not locked, hidden or on a locked artboard, leaving out `source`, otherwise the object selection; highlighted characters reject enabled non-neutral object transparency), the fill, stroke and weight also to the paint defaults; `defaultsOnly` updates only paint defaults and cannot be combined with ids, reverse or append; `reverse` (Alt-click) copies from the first selected object (or ids) onto `source` instead, or with nothing selected what the Eyedropper picked up last (a click without Alt); `append` (Shift+Alt-click) adds the source's fills, strokes and effects on top of each target's stack; placed gradients land at the same place relative to each target's bounds (defaults: fitted to new art) → {ids}", has_doc, copy_from ), @@ -1103,14 +1103,18 @@ fn copy_from(s: &mut Session, p: &Value) -> Result { if !reverse && !defaults_only { s.eyedropper_sample = Some(node); } - let range = range.filter(|(id, _, _)| targets.contains(id)); // The highlighted characters' type objects and ranges: after Select All in threaded text, all - // the text of every frame of the story. + // the text of every frame of the story that is not locked, hidden or on a locked artboard, + // except a frame the attributes come from, in place of the object selection. let story = super::textedit::editing_story(s); let ranges: Vec<(NodeId, Option<(usize, usize)>)> = match range { - Some((id, _, _)) if story.contains(&id) => story.iter().map(|f| (*f, None)).collect(), - Some((id, start, end)) => vec![(id, Some((start, end)))], - None => vec![], + Some((id, _, _)) if story.contains(&id) => { + targets.clear(); + let editable = s.doc()?.doc.editable(); + story.iter().copied().filter(|f| Some(*f) != source && editable(*f)).map(|f| (f, None)).collect() + } + Some((id, start, end)) if targets.contains(&id) => vec![(id, Some((start, end)))], + _ => vec![], }; // Character styles cannot represent object transparency. Refuse before even changing // paint defaults; applying it to the object would also alter the unselected characters. @@ -1128,7 +1132,7 @@ fn copy_from(s: &mut Session, p: &Value) -> Result { if !reverse { picked.to_defaults(s, &attrs.appearance); } - if targets.is_empty() || attrs.is_empty() { + if (targets.is_empty() && ranges.is_empty()) || attrs.is_empty() { return Ok(json!({ "ids": [] })); } if !ranges.is_empty() { diff --git a/crates/engine/src/cmd/paint.rs b/crates/engine/src/cmd/paint.rs index 390f6ef49..5031ea902 100644 --- a/crates/engine/src/cmd/paint.rs +++ b/crates/engine/src/cmd/paint.rs @@ -433,10 +433,15 @@ pub(crate) fn apply_paint(s: &mut Session, p: &Value, paint: Paint, fill: bool) && let Some((id, a, b)) = super::textedit::editing_range(s)? && a != b { - // After Select All in threaded text, every frame's text takes it. + // After Select All in threaded text, the text of every frame that is not locked, hidden or + // on a locked artboard takes it. let story = super::textedit::editing_story(s); - let ranges: Vec<(NodeId, usize, usize)> = - if story.is_empty() { vec![(id, a, b)] } else { story.iter().map(|f| (*f, 0, usize::MAX)).collect() }; + let ranges: Vec<(NodeId, usize, usize)> = if story.is_empty() { + vec![(id, a, b)] + } else { + let editable = s.doc()?.doc.editable(); + story.iter().copied().filter(|f| editable(*f)).map(|f| (f, 0, usize::MAX)).collect() + }; let ids: Vec = ranges.iter().map(|(f, _, _)| *f).collect(); // When the type comes out as it was, the document and its undo history stay as they are. let before = s.doc()?.doc.clone(); diff --git a/crates/engine/src/tests/textedit.rs b/crates/engine/src/tests/textedit.rs index dde0c4bf5..4e1faf611 100644 --- a/crates/engine/src/tests/textedit.rs +++ b/crates/engine/src/tests/textedit.rs @@ -1613,6 +1613,139 @@ fn the_eyedropper_styles_the_whole_story_that_select_all_selected() { } } +/// A red rectangle outside the story of [`threaded_story_edited_in_its_second_frame`], to pick up +/// from at (525, 325), with the Type tool editing frame `b` at its start again. +fn red_source(s: &mut Session, b: NodeId) { + let source = s.execute("shape.rectangle", &json!({"x": 500, "y": 300, "width": 50, "height": 50})).unwrap()["id"].as_u64().unwrap(); + s.execute("paint.setFill", &json!({"ids": [source], "color": "#ff0000"})).unwrap(); + s.execute("select.set", &json!({"ids": [b.0]})).unwrap(); + s.set_tool_option("textSelection", &json!({"editing": b.0, "anchor": 0, "caret": 0})); +} + +fn all_red(s: &Session, id: NodeId) -> bool { + obj(s, id).runs.iter().all(|r| r.style.fill.color().is_some_and(|c| c.to_hex() == "#ff0000")) +} + +fn any_red(s: &Session, id: NodeId) -> bool { + obj(s, id).runs.iter().any(|r| r.style.fill.color().is_some_and(|c| c.to_hex() == "#ff0000")) +} + +/// A click and a Shift-click of the Eyedropper on the rectangle of [`red_source`]. +fn eyedropper_picks() -> [Mods; 2] { + [Mods::default(), Mods { shift: true, ..Default::default() }] +} + +fn pick_red_source(s: &mut Session, mods: Mods) { + s.pointer(&PointerEvent::new(PointerKind::Down, 525.0, 325.0).with_mods(mods), ViewInfo::default()).unwrap(); +} + +fn highlights(s: &mut Session) -> usize { + s.overlays(ViewInfo::default()).iter().filter(|o| matches!(o, vectorcraft_tools::Overlay::Highlight { .. })).count() +} + +#[test] +fn switching_to_the_eyedropper_keeps_the_whole_story_selected() { + for mods in eyedropper_picks() { + let (mut s, a, b, _) = threaded_story_edited_in_its_second_frame(); + red_source(&mut s, b); + s.execute("select.all", &json!({})).unwrap(); + let highlighted = highlights(&mut s); + s.select_tool("eyedropper", ViewInfo::default()).unwrap(); + assert_eq!(s.tool_options()["story"], json!([a.0, b.0])); + assert!(highlights(&mut s) >= highlighted, "the Eyedropper highlights the text of both frames"); + let undo = s.doc().unwrap().history.undo.len(); + pick_red_source(&mut s, mods); + assert!(all_red(&s, a) && all_red(&s, b), "every frame takes the pick"); + assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "one undo step"); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(!any_red(&s, a) && !any_red(&s, b), "one undo takes the pick back from every frame"); + s.select_tool("type", ViewInfo::default()).unwrap(); + let o = s.tool_options(); + assert_eq!((&o["editing"], &o["story"]), (&json!(a.0), &json!([a.0, b.0])), "back in the Type tool the story stays selected"); + s.tool_text("x", ViewInfo::default()).unwrap(); + assert_eq!(format!("{}{}", obj(&s, a).plain_text(), obj(&s, b).plain_text()), "x", "typing replaces the whole story"); + } +} + +#[test] +fn a_selection_inside_one_threaded_frame_stays_in_that_frame_under_the_eyedropper() { + for mods in eyedropper_picks() { + let (mut s, a, b, _) = threaded_story_edited_in_its_second_frame(); + red_source(&mut s, b); + s.set_tool_option("select", &json!({"start": 0, "end": 4})); + s.select_tool("eyedropper", ViewInfo::default()).unwrap(); + assert!(s.tool_options().get("story").is_none()); + let undo = s.doc().unwrap().history.undo.len(); + pick_red_source(&mut s, mods); + assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1); + assert_eq!(obj(&s, b).runs[0].text.len(), 4, "only the selected characters take the pick"); + assert!(any_red(&s, b) && !all_red(&s, b) && !any_red(&s, a)); + s.select_tool("type", ViewInfo::default()).unwrap(); + let o = s.tool_options(); + assert_eq!((o["editing"].as_u64(), o["start"].as_u64(), o["end"].as_u64()), (Some(b.0), Some(0), Some(4))); + } +} + +#[test] +fn an_eyedropper_pick_on_the_whole_story_leaves_locked_and_hidden_frames() { + for command in ["object.lock", "object.hide"] { + for held_first in [false, true] { + for mods in eyedropper_picks() { + let (mut s, a, b, _) = threaded_story_edited_in_its_second_frame(); + let (held, other) = if held_first { (a, b) } else { (b, a) }; + s.execute(command, &json!({"ids": [held.0]})).unwrap(); + red_source(&mut s, other); + assert_eq!(s.execute("select.all", &json!({})).unwrap()["story"], json!([a.0, b.0])); + s.select_tool("eyedropper", ViewInfo::default()).unwrap(); + let undo = s.doc().unwrap().history.undo.len(); + pick_red_source(&mut s, mods); + assert!(all_red(&s, other), "{command}: the editable frame takes the pick"); + assert!(!any_red(&s, held), "{command}: the held frame stays as it is"); + assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "{command}: one undo step"); + } + } + } +} + +#[test] +fn an_eyedropper_pick_from_a_frame_of_the_selected_story_styles_the_other_frames() { + for source_first in [false, true] { + let (mut s, a, b, _) = threaded_story_edited_in_its_second_frame(); + let (source, other) = if source_first { (a, b) } else { (b, a) }; + s.execute("text.setStyle", &json!({"ids": [source.0], "fill": "#ff0000"})).unwrap(); + assert!(all_red(&s, source) && !any_red(&s, other)); + s.execute("select.set", &json!({"ids": [b.0]})).unwrap(); + s.set_tool_option("textSelection", &json!({"editing": b.0, "anchor": 0, "caret": 0})); + s.execute("select.all", &json!({})).unwrap(); + s.select_tool("eyedropper", ViewInfo::default()).unwrap(); + let undo = s.doc().unwrap().history.undo.len(); + let r = s.execute("appearance.copyFrom", &json!({"source": source.0})).unwrap(); + assert_eq!(r, json!({"ids": [other.0]})); + assert!(all_red(&s, other) && all_red(&s, source)); + assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1); + } +} + +#[test] +fn typing_after_a_thread_is_removed_leaves_the_frame_that_left_the_story() { + for through_eyedropper in [false, true] { + let (mut s, _, b, _) = threaded_story_edited_in_its_second_frame(); + s.execute("select.all", &json!({})).unwrap(); + if through_eyedropper { + s.select_tool("eyedropper", ViewInfo::default()).unwrap(); + } + s.execute("text.thread.remove", &json!({})).unwrap(); + assert_eq!(s.execute("text.thread.info", &json!({})).unwrap(), json!([])); + if through_eyedropper { + s.select_tool("type", ViewInfo::default()).unwrap(); + } + let left = obj(&s, b).plain_text(); + assert!(!left.is_empty()); + s.tool_text("x", ViewInfo::default()).unwrap(); + assert_eq!(obj(&s, b).plain_text(), left, "through the Eyedropper: {through_eyedropper}"); + } +} + #[test] fn typing_over_the_whole_story_replaces_every_frames_text_in_one_step() { let (mut s, a, b, story) = threaded_story_edited_in_its_second_frame(); diff --git a/crates/engine/src/tooling.rs b/crates/engine/src/tooling.rs index 66d84a925..2d85d620f 100644 --- a/crates/engine/src/tooling.rs +++ b/crates/engine/src/tooling.rs @@ -131,8 +131,9 @@ impl Session { if self.tool.id() == id { return Ok(()); } - // Sampling type keeps its highlighted characters as the target. Capture before the - // Type tool deactivates (which commits typing and clears its editing state). + // Sampling type keeps its highlighted characters as the target, and a whole story that + // Select All selected. Capture before the Type tool deactivates (which commits typing and + // clears its editing state). let is_type = |id: &str| matches!(id, "type" | "areaType" | "typeOnPath" | "verticalType" | "verticalAreaType" | "verticalTypeOnPath"); let text_selection = ((id == "eyedropper" && is_type(self.tool.id())) || (self.tool.id() == "eyedropper" && is_type(id))) .then(|| self.tool.options()) diff --git a/crates/tools/src/text.rs b/crates/tools/src/text.rs index 9dd96a1bf..6066baab8 100644 --- a/crates/tools/src/text.rs +++ b/crates/tools/src/text.rs @@ -441,8 +441,8 @@ impl TypeTool { } /// The other frames whose whole text is selected along with the edited frame's after Select - /// All in threaded text ([`Self::story`]): none once the selection is not all of the edited - /// frame's text. + /// All in threaded text ([`Self::story`]) that are still threaded with it: none once the + /// selection is not all of the edited frame's text. fn story_others(&self, cx: &ToolContext) -> Vec { let Some(id) = self.editing.filter(|id| self.story.contains(id)) else { return vec![] }; let Some(t) = Self::text(cx, id) else { return vec![] }; @@ -450,7 +450,8 @@ impl TypeTool { if a != 0 || b < edit::runs_len(&t.runs) { return vec![]; } - self.story.iter().copied().filter(|f| *f != id && Self::text(cx, *f).is_some()).collect() + let Some(thread) = cx.doc.text_threads.iter().find(|t| t.contains(&id)) else { return vec![] }; + self.story.iter().copied().filter(|f| *f != id && thread.contains(f) && Self::text(cx, *f).is_some()).collect() } /// Move the caret (Shift keeps the anchor). Moving ends the typing session. diff --git a/crates/tools/src/text/tool.rs b/crates/tools/src/text/tool.rs index 5634cf5a1..180a5e811 100644 --- a/crates/tools/src/text/tool.rs +++ b/crates/tools/src/text/tool.rs @@ -546,6 +546,12 @@ impl Tool for TypeTool { { self.start_editing(NodeId(id), b); self.anchor = a; + // The frames of a threaded story whose whole text Select All selected. + let story: Vec = + v.get("story").and_then(Value::as_array).into_iter().flatten().filter_map(Value::as_u64).map(NodeId).collect(); + if story.len() >= 2 && story.contains(&NodeId(id)) { + self.story = story; + } } } "prevTool" | "previousTool" => { diff --git a/docs/mcp.md b/docs/mcp.md index 3283eea2a..96430c5ae 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -5015,7 +5015,11 @@ and Cut take. Typing, Delete and Cut replace the whole story as one undo step (` frames whose text goes), and a fill, stroke, `type.step` or Character or Paragraph panel change applies to the text of every frame (the Character panel sends `text.setRangeStyle {id, story: true}`, which styles every frame's text in one undo step). Remove Line Breaks, Remove Paragraph Breaks and Convert to Text also act on the text of every frame, as -one undo step. A caret move or a click ends the story selection. +one undo step. A caret move or a click ends the story selection. Switching to the Eyedropper keeps the story selected, +and switching back to a Type tool restores it. `appearance.copyFrom` and `paint.sampleColor` then apply to the text of +every frame that is not locked, hidden or on a locked artboard as one undo step, and `appearance.copyFrom` leaves out a +frame that is its `source`. A fill or stroke change also leaves locked and hidden frames and frames on a locked artboard +as they are. Once a thread is removed, typing replaces the text of the edited frame only. ```json {"name":"press_key","arguments":{"key":"A","mods":{"cmd":true}}} diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 757fb0bb3..81f446dca 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -735,6 +735,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - The function key shortcuts for Brushes, Color, Layers, Info, Gradient, Stroke, Attributes, Graphic Styles, Appearance, Align, Transform, Pathfinder, Transparency, and Symbols activate a background tab on the first press, hide its expanded dock or floating group when that tab is in front, and restore the group with all its tabs together on the next press; in an icon column they open and close the flyout while keeping the icons. A reassigned chord keeps that behavior, and Window > a hidden sibling restores the group. - Cmd/Ctrl+A follows the keyboard: in a panel, dialog or search field it selects the field's text, while the Type tool edits it selects all of that text (also `select.all` over MCP), and only otherwise every object. On macOS the menu bar's Select All, Cut, Copy and Paste (keys or clicks) act on a focused field too, and elsewhere the in-window menus' do: the field that had the keyboard when the menu opened gets them, with its selection, and the keyboard back (#516). In a frame of threaded text, Select All selects the whole story, highlighted in every frame: Copy and Cut take all of it, typing or Delete replaces it as one undo step, and a fill, a size step or a Character or Paragraph panel change applies to every frame's text (VectorCraft's own behavior; `textedit::select_all_in_a_threaded_frame_selects_the_whole_story`, `textedit::typing_over_the_whole_story_replaces_every_frames_text_in_one_step`). - With the whole story of threaded text selected by Select All, Remove Line Breaks, Remove Paragraph Breaks and Type › Bullets and Numbering › Convert to Text act on the text of every frame as one undo step, and the story stays selected after Convert to Text; a selection inside one frame changes only that frame (VectorCraft's own behavior; `textedit::remove_breaks_takes_the_whole_story_that_select_all_selected`, `textedit::convert_to_text_takes_the_whole_story_that_select_all_selected`). + - Switching from a Type tool to the Eyedropper keeps a whole story selected by Select All: a pick applies to the text of every frame that is not locked, hidden or on a locked artboard as one undo step, and switching back restores the story selection (VectorCraft's own behavior; `textedit::switching_to_the_eyedropper_keeps_the_whole_story_selected`). - While the Type tool edits text, a character typed with AltGr is typed and runs no command. Firefox on Windows reports AltGr as Ctrl and Alt held, so in the web build AltGr+L (ł on a Polish layout) does not also open Layer Options for a new layer, and AltGr+Q (@ on a German layout) does not also run Reset Tracking and Kerning. A Ctrl+Alt chord that types no character runs its command. - On a layout without Latin letters, a one-key shortcut goes by the position of the key that typed the character, in the web build too, where the character arrives before its key press: on a Russian layout the P key (`з`) chooses the Pen, and two keys pressed in one frame each run their own shortcut. - With the Artboard tool, Cmd/Ctrl+A after a click on a row of the Artboards panel selects every artboard, as the macOS menu bar's Select All does then; the active artboard stays active, and the art's selection does not change. After a press outside the panel's list, Cmd/Ctrl+A selects every object again. It used to select every object after a click in the panel too. Public reference: [Select artboards](https://helpx.adobe.com/illustrator/desktop/create-manage-artboards/add-edit-artboards/select-artboards.html).