Type: text cleanup and Convert to Text act on every frame of a selected story

After Select All in a frame of threaded text, Remove Line Breaks and
Remove Paragraph Breaks changed only the edited frame, and Convert to
Text over MCP converted only the selected frame. Both now act on the
text of every frame of the story as one undo step.

- addon.cleanup.removeBreaks reads the frames of the story as one text,
  so a break at the end of a frame before a word in the next one becomes
  a space. The same applies when every frame of a story is selected as
  objects.
- text.convertListToText without ids, id or a range converts every
  paragraph of the story. Locked and hidden frames of the selected story
  are not converted, even when ids names them. When the call converts
  the whole story, the story stays selected; before, the call reset the
  Type tool's selection to the edited frame.
- A selection inside one frame, and unthreaded type, behave as before.

Docs: docs/mcp.md, docs/target-app-parity.md.
This commit is contained in:
dotCooCoo
2026-11-05 14:34:27 +07:00
committed by bflatastic
parent 4c901a382a
commit 2c1f3d7605
5 changed files with 221 additions and 28 deletions
+71 -22
View File
@@ -13,7 +13,7 @@ use vectorcraft_geom::{Affine, Point, Vec2};
use vectorcraft_text::{FontDb, FontMatch, TextLayout, edit};
use crate::cmd::fonts::{edit_symbol_texts, symbol_texts};
use crate::cmd::textedit::editing_range;
use crate::cmd::textedit::{editing_range, editing_story};
use crate::cmd::typecmd::refresh_bounds;
use crate::cmd::{CommandSpec, bad, checked_ids_or_selection, cmd, has_doc, has_selection, switch_or, text_param};
use crate::{EngineError, Result, Session};
@@ -72,7 +72,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Remove Breaks",
["Object", "Plug-ins", "Artwork Cleanup"],
None,
"{kind: \"line\"|\"paragraph\", ids?: [id…]} remove the forced line breaks (line) or paragraph returns (paragraph) in the characters selected with a Type tool, else in all the text of the selected type, as one undo step; a break between two words becomes a space → {ids, removed}",
"{kind: \"line\"|\"paragraph\", ids?: [id…]} remove the forced line breaks (line) or paragraph returns (paragraph) in the characters selected with a Type tool (after Select All in a frame of threaded text, in the text of every frame of the story), else in all the text of the selected type, as one undo step; a break between two words becomes a space, the frames of a threaded story selected all together being read as one text → {ids, removed}",
has_selection,
remove_breaks
),
@@ -730,12 +730,11 @@ fn join_text(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({ "id": id.0, "joined": joined, "lines": count }))
}
/// Remove the breaks `brk` in bytes `a..b` of `t`: one between two words becomes a space → how many
/// went.
fn strip_breaks(t: &mut TextObject, a: usize, b: usize, brk: char) -> usize {
let text = t.plain_text();
let (a, b) = (edit::floor_char(&text, a), edit::floor_char(&text, b));
let Some(range) = text.get(a..b) else { return 0 };
/// The breaks `brk` in bytes `a..b` of `text`, each with whether it becomes a space (it is between
/// two words).
fn break_cuts(text: &str, a: usize, b: usize, brk: char) -> Vec<(usize, bool)> {
let (a, b) = (edit::floor_char(text, a), edit::floor_char(text, b));
let Some(range) = text.get(a..b) else { return vec![] };
let mut prev = text.get(..a).and_then(|s| s.chars().next_back());
let mut cuts = vec![];
for (i, c) in range.char_indices() {
@@ -753,6 +752,12 @@ fn strip_breaks(t: &mut TextObject, a: usize, b: usize, brk: char) -> usize {
}
cuts.push((at, space));
}
cuts
}
/// Remove the breaks `brk` at the bytes of `cuts` from `t`, each one that `cuts` marks becoming a
/// space → how many went.
fn apply_cuts(t: &mut TextObject, cuts: &[(usize, bool)], brk: char) -> usize {
for &(at, space) in cuts.iter().rev() {
let insert = if space { " " } else { "" };
let end = at + brk.len_utf8();
@@ -763,6 +768,30 @@ fn strip_breaks(t: &mut TextObject, a: usize, b: usize, brk: char) -> usize {
cuts.len()
}
/// The breaks `brk` in the text of the frames of `story`, read as one text: a break at the end or
/// start of a frame between words of the frames on either side becomes a space. Each editable frame
/// comes with its own breaks, at its own bytes.
fn story_cuts(d: &Document, story: &[NodeId], brk: char) -> Vec<(NodeId, Vec<(usize, bool)>)> {
let texts: Vec<(NodeId, String)> = story.iter().filter_map(|id| Some((*id, text_of(d, *id)?.plain_text()))).collect();
let joined: String = texts.iter().map(|(_, t)| t.as_str()).collect();
// The breaks in story order, handed to the frames in one pass.
let mut cuts = break_cuts(&joined, 0, joined.len(), brk).into_iter().peekable();
let mut out = vec![];
let mut start = 0;
for (id, text) in texts {
let end = start + text.len();
let mut own = vec![];
while let Some((at, space)) = cuts.next_if(|(at, _)| *at < end) {
own.push((at - start, space));
}
if d.is_editable(id) {
out.push((id, own));
}
start = end;
}
out
}
fn remove_breaks(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "addon.cleanup.removeBreaks";
let (brk, label, none) = match text_param(p, "kind", C)? {
@@ -771,21 +800,41 @@ fn remove_breaks(s: &mut Session, p: &Value) -> Result<Value> {
Some(other) => return Err(bad(C, format!("`kind` must be \"line\" or \"paragraph\", not {other:?}"))),
None => return Err(bad(C, "missing `kind`")),
};
// The characters a Type tool has selected, else all the text of the selected type.
// The characters a Type tool has selected (after Select All in threaded text, all the text of
// every frame of the story), else all the text of the selected type.
let editing = if p.get("ids").is_some() { None } else { editing_range(s)?.filter(|(_, a, b)| a != b) };
let story = if editing.is_some() { editing_story(s) } else { vec![] };
let d = &s.doc()?.doc;
let targets: Vec<(NodeId, usize, usize)> = match editing {
Some((id, a, b)) => vec![(id, a.min(b), a.max(b))].into_iter().filter(|(id, ..)| d.is_editable(*id)).collect(),
None => texts_under(d, &checked_ids_or_selection(s, p, C)?)?.into_iter().map(|id| (id, 0, usize::MAX)).collect(),
// Each type object with the breaks that go from it.
let targets: Vec<(NodeId, Vec<(usize, bool)>)> = match editing {
Some(_) if !story.is_empty() => story_cuts(d, &story, brk),
Some((id, a, b)) => vec![(id, a.min(b), a.max(b))]
.into_iter()
.filter(|(id, ..)| d.is_editable(*id))
.filter_map(|(id, a, b)| Some((id, break_cuts(&text_of(d, id)?.plain_text(), a, b, brk))))
.collect(),
None => {
let ids = texts_under(d, &checked_ids_or_selection(s, p, C)?)?;
// A threaded story whose frames are all among them is read as one text, once.
let named: std::collections::HashSet<NodeId> = ids.iter().copied().collect();
let mut done = std::collections::HashSet::new();
let mut out: Vec<(NodeId, Vec<(usize, bool)>)> = vec![];
for &id in &ids {
if !done.insert(id) {
continue;
}
let story = crate::cmd::threads::story_of(d, id);
if !story.is_empty() && story.iter().all(|f| named.contains(f)) {
done.extend(story.iter().copied());
out.extend(story_cuts(d, &story, brk));
} else if let Some(t) = text_of(d, id) {
out.push((id, break_cuts(&t.plain_text(), 0, usize::MAX, brk)));
}
}
out
}
};
let count: usize = targets
.iter()
.filter_map(|(id, a, b)| {
let text = text_of(d, *id)?.plain_text();
let (a, b) = (edit::floor_char(&text, *a), edit::floor_char(&text, *b));
Some(text.get(a..b)?.matches(brk).count())
})
.sum();
let count: usize = targets.iter().map(|(_, cuts)| cuts.len()).sum();
if count == 0 {
return Err(EngineError::Other(none.into()));
}
@@ -794,9 +843,9 @@ fn remove_breaks(s: &mut Session, p: &Value) -> Result<Value> {
}
let (removed, ids) = s.edit(label, |d, _| {
let (mut removed, mut ids) = (0, vec![]);
for (id, a, b) in targets {
for (id, cuts) in targets {
let Some(NodeKind::Text(t)) = d.node_mut(id).map(|n| &mut n.kind) else { continue };
let n = strip_breaks(t, a, b, brk);
let n = apply_cuts(t, &cuts, brk);
if n > 0 {
refresh_bounds(t);
removed += n;
+16 -3
View File
@@ -52,7 +52,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Convert to Text",
[],
None,
"{ids?|id?, start?: byte, end?: byte} (Type › Bullets and Numbering › Convert to Text: the paragraphs of the selected type, or of `ids`, or those a start/end range touches) the marker of each list paragraph becomes characters of its text: the bullet, or the number and its period as the paragraph showed it, then a tab, at the start of the paragraph in the marker's font, size and color; the list is turned off. When the marker's tab went to the left indent, the paragraph gets a left-aligned tab stop without a leader there, in place of any stop there, which keeps the text after the tab where it started; when it went to a tab stop and a stop there or past it has a leader or is not left-aligned, or a right, center or decimal stop lies between the marker and that place, the paragraph gets a left-aligned stop without a leader where the marker's tab ended, in place of any stop there and of those right, center and decimal stops before it (past the left indent only in a paragraph with no tab of its own). In threaded type the numbers are those counted through the story's frames, and a paragraph that continues into a later frame is converted where its text starts. An empty list paragraph has no marker and only leaves the list. A paragraph with no direction set gets the direction it ran in when the characters would change it (a letter or roman numeral before right-to-left text). With the Every-line Composer, a paragraph with a tab is broken line by line, so a converted paragraph can break its lines at other places. While the Type tool edits, its typing ends first and its anchor and caret move past the characters inserted at or before them; in threaded type, when the caret's character moved to another frame, the tool edits that frame, which becomes the selection when it was not selected. One undo step → {ids, converted: paragraphs, inserted: [{id, at: byte before the change, length: bytes}]}",
"{ids?|id?, start?: byte, end?: byte} (Type › Bullets and Numbering › Convert to Text: the paragraphs of the selected type, or of `ids`, or those a start/end range touches; after Select All in a frame of threaded text, without `ids`, `id` or a range, every paragraph of the story's frames that are neither locked nor hidden; while the story is selected, its locked and hidden frames are not converted even when named) the marker of each list paragraph becomes characters of its text: the bullet, or the number and its period as the paragraph showed it, then a tab, at the start of the paragraph in the marker's font, size and color; the list is turned off. When the marker's tab went to the left indent, the paragraph gets a left-aligned tab stop without a leader there, in place of any stop there, which keeps the text after the tab where it started; when it went to a tab stop and a stop there or past it has a leader or is not left-aligned, or a right, center or decimal stop lies between the marker and that place, the paragraph gets a left-aligned stop without a leader where the marker's tab ended, in place of any stop there and of those right, center and decimal stops before it (past the left indent only in a paragraph with no tab of its own). In threaded type the numbers are those counted through the story's frames, and a paragraph that continues into a later frame is converted where its text starts. An empty list paragraph has no marker and only leaves the list. A paragraph with no direction set gets the direction it ran in when the characters would change it (a letter or roman numeral before right-to-left text). With the Every-line Composer, a paragraph with a tab is broken line by line, so a converted paragraph can break its lines at other places. While the Type tool edits, its typing ends first and its anchor and caret move past the characters inserted at or before them (a whole story that Select All selected stays selected when the call converts all of it); in threaded type, when the caret's character moved to another frame, the tool edits that frame, which becomes the selection when it was not selected. One undo step → {ids, converted: paragraphs, inserted: [{id, at: byte before the change, length: bytes}]}",
has_doc,
convert_list_to_text
),
@@ -876,8 +876,20 @@ fn move_editing_selection(
fn convert_list_to_text(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "text.convertListToText";
use vectorcraft_doc::text::ParaList;
let ids = super::typecmd::text_targets(s, p, C)?;
let range = super::typecmd::TextRange::parse(p, C)?;
// After Select All in a frame of threaded text, a call that names no type and no range takes
// every paragraph of the story's editable frames; its locked and hidden frames stay as they are
// whichever way they are named.
let full = super::textedit::editing_story(s);
let story: Vec<NodeId> = full.iter().copied().filter(|f| s.doc().is_ok_and(|st| st.doc.is_editable(*f))).collect();
let named = ["ids", "id"].iter().any(|k| p.get(*k).is_some_and(|v| !v.is_null()));
let ids = if !story.is_empty() && !named && range.is_none() {
story.clone()
} else {
super::typecmd::text_targets(s, p, C)?.into_iter().filter(|id| !full.contains(id) || story.contains(id)).collect()
};
// Converting the whole story leaves it selected.
let whole_story = !story.is_empty() && range.is_none() && story.iter().all(|f| ids.contains(f));
let editing = match super::textedit::editing_range(s)? {
Some((id, _, _)) => {
let options = s.tool_options();
@@ -1058,7 +1070,8 @@ fn convert_list_to_text(s: &mut Session, p: &Value) -> Result<Value> {
}
Ok(())
})?;
if let Some(editing) = editing {
// A whole story selected by Select All stays selected, with the characters inserted.
if let Some(editing) = editing.filter(|_| !whole_story) {
move_editing_selection(s, editing, &story_before, &insertions)?;
}
}
+122
View File
@@ -1645,6 +1645,128 @@ fn select_all_in_one_frame_selects_only_its_text_once_the_caret_moves() {
assert!(s.execute("text.editRange", &json!({"id": a.0, "start": 0, "end": 0, "clear": "x"})).is_err());
}
/// A story of bulleted paragraphs threaded from area type `a` into frame `b`, each frame holding
/// paragraph returns, with the Type tool editing `b` at its start.
fn threaded_list_edited_in_its_second_frame() -> (Session, NodeId, NodeId, String) {
let mut s = session();
let story = "Alpha beta.\n".repeat(12);
let a = s.execute("text.create", &json!({"x": 10, "y": 10, "text": story, "size": 12, "area": {"width": 150, "height": 60}})).unwrap()["id"]
.as_u64()
.unwrap();
let b = s.execute("shape.rectangle", &json!({"x": 200, "y": 10, "width": 150, "height": 300})).unwrap()["id"].as_u64().unwrap();
s.execute("select.set", &json!({"ids": [a, b]})).unwrap();
s.execute("text.thread.create", &json!({})).unwrap();
s.execute("text.setFormat", &json!({"ids": [a, b], "list": "bullet"})).unwrap();
let (a, b) = (NodeId(a), NodeId(b));
for id in [a, b] {
let t = obj(&s, id);
assert!(t.plain_text().contains('\n'), "frame {} holds paragraph returns", id.0);
assert!((0..t.paragraph_count()).all(|k| t.para_at(k).list.is_some()), "frame {} is a list", id.0);
}
s.execute("select.set", &json!({"ids": [b.0]})).unwrap();
s.select_tool("type", ViewInfo::default()).unwrap();
s.set_tool_option("textSelection", &json!({"editing": b.0, "anchor": 0, "caret": 0}));
(s, a, b, story)
}
#[test]
fn remove_breaks_takes_the_whole_story_that_select_all_selected() {
let (mut s, a, b, story) = threaded_list_edited_in_its_second_frame();
s.execute("select.all", &json!({})).unwrap();
let steps = undo_labels(&s).len();
s.execute("addon.cleanup.removeBreaks", &json!({"kind": "paragraph"})).unwrap();
assert_eq!(undo_labels(&s).len(), steps + 1, "one undo step");
let joined = format!("{}{}", obj(&s, a).plain_text(), obj(&s, b).plain_text());
assert_eq!(joined, story.trim_end_matches('\n').replace('\n', " "), "every frame's returns go, each between words as a space");
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(format!("{}{}", obj(&s, a).plain_text(), obj(&s, b).plain_text()), story);
assert!(obj(&s, a).plain_text().contains('\n') && obj(&s, b).plain_text().contains('\n'));
}
#[test]
fn remove_breaks_on_a_selected_threaded_story_puts_a_space_between_its_frames() {
let (mut s, a, b, story) = threaded_list_edited_in_its_second_frame();
assert!(obj(&s, a).plain_text().ends_with('\n'), "the first frame ends with a paragraph return");
s.select_tool("selection", ViewInfo::default()).unwrap();
s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap();
s.execute("addon.cleanup.removeBreaks", &json!({"kind": "paragraph"})).unwrap();
let joined = format!("{}{}", obj(&s, a).plain_text(), obj(&s, b).plain_text());
assert_eq!(joined, story.trim_end_matches('\n').replace('\n', " "), "the return at the end of the first frame becomes a space");
}
#[test]
fn remove_breaks_in_one_frames_selection_leaves_the_other_frames() {
let (mut s, a, b, _) = threaded_list_edited_in_its_second_frame();
let (in_a, b_len) = (obj(&s, a).plain_text(), obj(&s, b).plain_text().len());
s.set_tool_option("textSelection", &json!({"editing": b.0, "anchor": 0, "caret": b_len}));
s.execute("addon.cleanup.removeBreaks", &json!({"kind": "paragraph"})).unwrap();
assert_eq!(obj(&s, a).plain_text(), in_a, "the first frame keeps its returns");
assert!(!obj(&s, b).plain_text().contains('\n'), "the edited frame's returns go");
}
#[test]
fn convert_to_text_takes_the_whole_story_that_select_all_selected() {
let (mut s, a, b, _) = threaded_list_edited_in_its_second_frame();
s.execute("select.all", &json!({})).unwrap();
let steps = undo_labels(&s).len();
s.execute("text.convertListToText", &json!({})).unwrap();
assert_eq!(undo_labels(&s).len(), steps + 1, "one undo step");
for id in [a, b] {
let t = obj(&s, id);
assert!((0..t.paragraph_count()).all(|k| t.para_at(k).list.is_none()), "frame {} leaves its list", id.0);
assert!(t.plain_text().contains("\u{2022}\t"), "frame {} holds its bullets as characters", id.0);
}
assert!(s.tool_options().get("story").is_some(), "the whole story stays selected");
s.execute("edit.undo", &json!({})).unwrap();
for id in [a, b] {
let t = obj(&s, id);
assert!((0..t.paragraph_count()).all(|k| t.para_at(k).list.is_some()), "frame {} is a list again", id.0);
}
}
#[test]
fn convert_to_text_of_the_whole_story_leaves_a_locked_frame() {
// Over MCP without ids, and as the Paragraph panel sends it, with every frame of the story.
for named in [false, true] {
let (mut s, a, b, _) = threaded_list_edited_in_its_second_frame();
s.execute("object.lock", &json!({"ids": [b.0]})).unwrap();
s.execute("select.set", &json!({"ids": [a.0]})).unwrap();
s.set_tool_option("textSelection", &json!({"editing": a.0, "anchor": 0, "caret": 0}));
assert_eq!(s.execute("select.all", &json!({})).unwrap()["story"], json!([a.0, b.0]));
let p = if named { json!({"ids": [a.0, b.0]}) } else { json!({}) };
s.execute("text.convertListToText", &p).unwrap();
let (ta, tb) = (obj(&s, a), obj(&s, b));
assert!(ta.plain_text().contains("\u{2022}\t"), "the editable frame is converted");
// The converted text is longer, so the story flows on into the locked frame; its own
// paragraphs stay a list.
let last = tb.paragraph_count() - 2;
assert!(tb.para_at(last).list.is_some(), "the locked frame's paragraphs stay a list (ids named: {named})");
assert!(!tb.plain_text().ends_with("\u{2022}\tAlpha beta.\n"));
assert!(s.tool_options().get("story").is_some(), "the whole story stays selected");
}
}
#[test]
fn convert_to_text_of_named_frames_ends_the_story_selection() {
let (mut s, _, b, _) = threaded_list_edited_in_its_second_frame();
s.execute("select.all", &json!({})).unwrap();
s.execute("text.convertListToText", &json!({"ids": [b.0]})).unwrap();
assert!(obj(&s, b).plain_text().contains("\u{2022}\t"));
assert!(s.tool_options().get("story").is_none(), "the Type tool's selection moves past the characters inserted");
}
#[test]
fn convert_to_text_in_one_frame_leaves_the_other_frames() {
let (mut s, a, b, _) = threaded_list_edited_in_its_second_frame();
let in_a = obj(&s, a).plain_text();
s.execute("text.convertListToText", &json!({})).unwrap();
let (ta, tb) = (obj(&s, a), obj(&s, b));
assert_eq!(ta.plain_text(), in_a, "the first frame's text is as it was");
assert!(ta.para_at(0).list.is_some(), "the first frame stays a list");
assert!((0..tb.paragraph_count()).all(|k| tb.para_at(k).list.is_none()), "the edited frame leaves its list");
assert!(tb.plain_text().contains("\u{2022}\t"), "the edited frame holds its bullets as characters");
}
#[test]
fn a_frame_dragged_with_the_type_tools_is_the_dragged_rectangle() {
for tool in ["type", "verticalType"] {
+11 -3
View File
@@ -4982,7 +4982,8 @@ thread's first frame that holds text (which is selected when it was not) with th
and Cut take. Typing, Delete and Cut replace the whole story as one undo step (`text.editRange` with `clear`, the other
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). A caret move or a click ends the story selection.
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.
```json
{"name":"press_key","arguments":{"key":"A","mods":{"cmd":true}}}
@@ -5429,6 +5430,10 @@ the type it changed, how many paragraphs were `converted`, and the characters `i
`id`, the byte `at` which they went in (counted in the text before the change) and their `length` in bytes. While the
Type tool edits, `text.convertListToText` ends its typing first and moves its anchor and caret past the characters
inserted at or before them, whether the call comes from the menu, MCP or the control channel.
After Select All in a frame of threaded text, a call without `ids`, `id`, `start` or `end` converts every paragraph of
the story's frames that are neither locked nor hidden. While the story is selected, its locked and hidden frames are
not converted, even when `ids` names them. When the call converts all of its other frames, the whole story stays
selected.
In threaded type, when the caret's character moved to another frame, the Type tool edits that frame with the caret
next to the same character, and that frame becomes the selection when it was not selected.
@@ -6501,8 +6506,11 @@ glyph of its first character lands where it was drawn.
line becomes a left-aligned paragraph whose leading is the distance from the baseline above, so the lines stay where
they were. Character styles stay; the object takes the top-left piece's place and paint. Returns `{id, joined, lines}`.
- `addon.cleanup.removeBreaks {kind, ids?}` removes the forced line breaks (`kind: "line"`) or paragraph returns
(`"paragraph"`) in the characters selected with a Type tool, else in all the text of the selected type. A break
between two words becomes a space; merged paragraphs take the first one's paragraph style.
(`"paragraph"`) in the characters selected with a Type tool, else in all the text of the selected type. After
Select All in a frame of threaded text, it removes them from the text of every frame of the story, as one undo step.
A break between two words becomes a space; merged paragraphs take the first one's paragraph style. The frames of a
threaded story, selected all together or through Select All, are read as one text, so a break at the end of one
frame before a word in the next one also becomes a space.
- `addon.cleanup.splitText {}` works while a Type tool edits horizontal point or area type: the text before the
selected characters, those characters and the text after them become point type objects (at a bare cursor, the text
before and after it). Spaces at the ends of each piece go; a piece that starts inside a left-aligned line takes a
+1
View File
@@ -706,6 +706,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
- The second Tab (`window.dock`) shows again only the bars the first Tab hid, after a restart too: a Control bar that was off stays off, a toolbar hidden with Window › Tools stays hidden, and panels shown with Shift+Tab in between stay shown.
- 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`).
- 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).