mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-08 16:30:41 +00:00
Merge pull request #5542 from dotCooCoo/feat/select-all-threaded-story
Type: Select All in a threaded frame selects the whole story
This commit is contained in:
@@ -1087,15 +1087,25 @@ fn copy_from(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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.
|
||||
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![],
|
||||
};
|
||||
// Character styles cannot represent object transparency. Refuse before even changing
|
||||
// paint defaults; applying it to the object would also alter the unselected characters.
|
||||
if let Some((id, _, _)) = range.filter(|_| attrs.appearance.transparency) {
|
||||
let target = s.doc()?.doc.node(id).ok_or(EngineError::NoNode(id))?;
|
||||
if picked.opacity != 1.0 || picked.blend != BlendMode::Normal || target.opacity != 1.0 || target.blend != BlendMode::Normal {
|
||||
return Err(bad(
|
||||
C,
|
||||
"object transparency cannot be sampled onto highlighted characters; disable Transparency in Eyedropper Options or select the whole object",
|
||||
));
|
||||
if attrs.appearance.transparency {
|
||||
for (id, _) in &ranges {
|
||||
let target = s.doc()?.doc.node(*id).ok_or(EngineError::NoNode(*id))?;
|
||||
if picked.opacity != 1.0 || picked.blend != BlendMode::Normal || target.opacity != 1.0 || target.blend != BlendMode::Normal {
|
||||
return Err(bad(
|
||||
C,
|
||||
"object transparency cannot be sampled onto highlighted characters; disable Transparency in Eyedropper Options or select the whole object",
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
if !reverse {
|
||||
@@ -1104,19 +1114,23 @@ fn copy_from(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if targets.is_empty() || attrs.is_empty() {
|
||||
return Ok(json!({ "ids": [] }));
|
||||
}
|
||||
if let Some((id, start, end)) = range {
|
||||
if !ranges.is_empty() {
|
||||
let ids: Vec<NodeId> = ranges.iter().map(|(f, _)| *f).collect();
|
||||
let before = s.doc()?.doc.clone();
|
||||
super::overprint::edit_counted(s, "Eyedropper", |d| {
|
||||
let Some(NodeKind::Text(t)) = d.node_mut(id).map(|n| &mut n.kind) else { return Err(EngineError::NoNode(id)) };
|
||||
let placed = picked.placed(Some(t.local_bounds()));
|
||||
vectorcraft_text::edit::style_range(&mut t.runs, start, end, |st| picked.run(st, &placed, &attrs));
|
||||
if let Some((_, para)) = picked.text.as_ref().filter(|_| attrs.paragraph) {
|
||||
t.edit_paras(Some(t.paragraphs_in(start, end)), |p| *p = para.clone());
|
||||
for &(id, range) in &ranges {
|
||||
let Some(NodeKind::Text(t)) = d.node_mut(id).map(|n| &mut n.kind) else { return Err(EngineError::NoNode(id)) };
|
||||
let (start, end) = range.unwrap_or((0, vectorcraft_text::edit::runs_len(&t.runs)));
|
||||
let placed = picked.placed(Some(t.local_bounds()));
|
||||
vectorcraft_text::edit::style_range(&mut t.runs, start, end, |st| picked.run(st, &placed, &attrs));
|
||||
if let Some((_, para)) = picked.text.as_ref().filter(|_| attrs.paragraph) {
|
||||
t.edit_paras(Some(t.paragraphs_in(start, end)), |p| *p = para.clone());
|
||||
}
|
||||
super::typecmd::refresh_bounds(t);
|
||||
}
|
||||
super::typecmd::refresh_bounds(t);
|
||||
Ok(count_changed(d, &before, &[id]))
|
||||
Ok(count_changed(d, &before, &ids))
|
||||
})?;
|
||||
return Ok(json!({ "ids": [id.0] }));
|
||||
return Ok(json!({ "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>() }));
|
||||
}
|
||||
// When every target comes out as it was, the document and its undo history stay as they are.
|
||||
let before = s.doc()?.doc.clone();
|
||||
|
||||
@@ -433,29 +433,36 @@ 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.
|
||||
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 ids: Vec<NodeId> = 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();
|
||||
super::overprint::edit_counted(s, if fill { "Fill Color" } else { "Stroke Color" }, |d| {
|
||||
let Some(NodeKind::Text(t)) = d.node_mut(id).map(|n| &mut n.kind) else { return Err(EngineError::NoNode(id)) };
|
||||
let (xf, lb) = (t.xf, t.local_bounds());
|
||||
vectorcraft_text::edit::style_range(&mut t.runs, a, b, |st| {
|
||||
if fill {
|
||||
st.fill = place_run_paint(&paint, p, xf, lb);
|
||||
} else {
|
||||
if !paint.is_none() {
|
||||
run_stroke_weight(st);
|
||||
for &(id, a, b) in &ranges {
|
||||
let Some(NodeKind::Text(t)) = d.node_mut(id).map(|n| &mut n.kind) else { return Err(EngineError::NoNode(id)) };
|
||||
let (xf, lb) = (t.xf, t.local_bounds());
|
||||
vectorcraft_text::edit::style_range(&mut t.runs, a, b, |st| {
|
||||
if fill {
|
||||
st.fill = place_run_paint(&paint, p, xf, lb);
|
||||
} else {
|
||||
if !paint.is_none() {
|
||||
run_stroke_weight(st);
|
||||
}
|
||||
st.stroke = place_run_paint(&paint, p, xf, vectorcraft_doc::appearance::stroke_paint_bounds(lb, st.stroke_width));
|
||||
}
|
||||
st.stroke = place_run_paint(&paint, p, xf, vectorcraft_doc::appearance::stroke_paint_bounds(lb, st.stroke_width));
|
||||
}
|
||||
});
|
||||
super::typecmd::refresh_bounds(t);
|
||||
Ok(super::appearance::count_changed(d, &before, &[id]))
|
||||
});
|
||||
super::typecmd::refresh_bounds(t);
|
||||
}
|
||||
Ok(super::appearance::count_changed(d, &before, &ids))
|
||||
})?;
|
||||
if focus {
|
||||
s.fill_active = fill;
|
||||
}
|
||||
s.remember_paint(&paint);
|
||||
return Ok(json!({"objects": 1}));
|
||||
return Ok(json!({"objects": ids.len()}));
|
||||
}
|
||||
// Resolve the requested targets before mutating defaults or the active proxy.
|
||||
let item = item_target(s, p, cmd)?.of_kind(s, fill);
|
||||
|
||||
@@ -20,7 +20,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"All",
|
||||
["Select"],
|
||||
Some("Cmd+A"),
|
||||
"{} → {count}; while the Type tool edits text, all of that text instead → {editing, start, end} (byte offsets)",
|
||||
"{} → {count}; while the Type tool edits text, all of that text instead → {editing, start, end} (byte offsets); in a frame of threaded text, the whole story: the tool edits the thread's first frame that holds text (selected when it was not) with the text of every frame selected → {editing, start, end, story: [id…] (the frames in thread order)}",
|
||||
has_doc,
|
||||
all
|
||||
),
|
||||
@@ -271,6 +271,31 @@ fn all(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
// While the Type tool edits text, Select All takes all of that text and leaves the art
|
||||
// selection as it is (Illustrator: text cursor in a text object).
|
||||
if s.tool_wants_text() {
|
||||
// In a frame of threaded text, the whole story: the tool edits its first frame that holds
|
||||
// text with every frame's text selected, and that frame is selected when it was not.
|
||||
let editing = s.tool_options()["editing"].as_u64().map(NodeId);
|
||||
let story = match editing {
|
||||
Some(id) => super::threads::story_of(&s.doc()?.doc, id),
|
||||
None => vec![],
|
||||
};
|
||||
if let Some(&first) = story.first() {
|
||||
let d = &s.doc()?.doc;
|
||||
let holds_text = |f: &NodeId| matches!(d.node(*f).map(|n| &n.kind), Some(NodeKind::Text(t)) if t.runs.iter().any(|r| !r.text.is_empty()));
|
||||
let first = story.iter().copied().find(holds_text).unwrap_or(first);
|
||||
super::textedit::editing_range(s)?;
|
||||
let ids: Vec<u64> = story.iter().map(|id| id.0).collect();
|
||||
s.set_tool_option("selectAll", &json!({ "story": ids, "editing": first.0 }));
|
||||
let st = s.doc()?;
|
||||
let selected = std::iter::successors(Some(first), |c| st.doc.parent_of(*c)).any(|c| st.selection.contains(c));
|
||||
if !selected {
|
||||
s.select(|_, sel| sel.set([first]))?;
|
||||
}
|
||||
let end = match s.doc()?.doc.node(first).map(|n| &n.kind) {
|
||||
Some(NodeKind::Text(t)) => t.plain_text().len(),
|
||||
_ => 0,
|
||||
};
|
||||
return Ok(json!({ "editing": first.0, "start": 0, "end": end, "story": ids }));
|
||||
}
|
||||
s.set_tool_option("selectAll", &json!(true));
|
||||
let editing = s.tool_options()["editing"].clone();
|
||||
let id = editing.as_u64().map(NodeId);
|
||||
|
||||
@@ -21,7 +21,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Edit Text",
|
||||
[],
|
||||
None,
|
||||
"{id, start: byte, end: byte, insert?: string, runs?: [{text, style}]} replace bytes start..end of the plain text (inserted text takes the replaced text's style unless styled `runs` are given) → {id, caret}",
|
||||
"{id, start: byte, end: byte, insert?: string, runs?: [{text, style}], clear?: [id…] (other type objects whose whole text goes in the same edit, as when typing replaces a threaded story that Select All selected)} replace bytes start..end of the plain text (inserted text takes the replaced text's style unless styled `runs` are given) → {id, caret}",
|
||||
has_doc,
|
||||
edit_range
|
||||
),
|
||||
@@ -39,7 +39,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Character",
|
||||
[],
|
||||
None,
|
||||
"{id? (default: the one selected type object), start?: byte, end?: byte (default: all text), font?, style?, size?: pt, leading?: pt|\"auto\", tracking?, kerning?: 1/1000 em|\"auto\"|\"optical\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg, fill?: colour|\"none\", stroke?: colour|\"none\", keepModel?: bool (fill and stroke, as paint.setFill), strokeWidth?: pt, strokeOptions?: {weight?, cap?, join?, miterLimit?, align?, dash?, dashOffset?, alignDashes?} (as stroke.set: the character stroke), underline?, strikethrough?, allCaps?: bool, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), features?: [\"dlig\", \"-liga\", …], featureChanges?: [\"tnum\", \"-onum\", …] (OpenType features turned on, or with - off, on top of each character's own; its other features are kept), charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\", proportionalMetrics?: bool (full-width glyphs on the font's proportional widths: `palt` in horizontal type, `vpal` in vertical type), tateChuYoko?: bool (Character panel menu › Tate-chu-yoko, with Show East Asian Options: in vertical type those characters stand side by side across one em of the column; kept on the characters and ignored in horizontal type), tcyUpDown?, tcyLeftRight?: pt -1296..1296 (Character panel menu › Tate-chu-yoko Settings: in vertical type a block of tate-chu-yoko characters moves up by a positive Up/Down and down by a negative one, right by a positive Left/Right and left by a negative one, as one piece with the values of its first character; 0 by default; automatic digit blocks and horizontal type do not move; neither turns tateChuYoko on), standardVerticalRoman?: bool (Character panel menu › Standard Vertical Roman Alignment, with Show East Asian Options: in vertical type those half-width characters, such as Latin letters and digits, stand upright one below the other instead of being rotated; spaces and tate-chu-yoko blocks are not changed; kept on the characters and ignored in horizontal type; off by default), noBreak?: bool (Character panel menu › No Break: these characters stay on one line, including across a space inside the run), kashidas?: bool (Character panel menu › Kashidas: selected characters allow insertion only when both joined neighbors have it on; on by default), charDirection?: \"default\"|\"leftToRight\"|\"rightToLeft\" (Character panel menu › Character Direction: the characters are set left to right or right to left inside their paragraph, as a Unicode bidi embedding; default: as the bidi algorithm sets them), digits?: \"default\"|\"arabic\"|\"hindi\"|\"farsi\"|\"devanagari\"|\"bengali\"|\"gurmukhi\"|\"gujarati\"|\"odia\"|\"tamil\"|\"telugu\"|\"kannada\"|\"malayalam\" (Character panel menu › Digits: the numeral system every digit is drawn in, whatever was typed: Western, Arabic-Indic (hindi), Eastern Arabic-Indic (farsi) or an Indic script's own digits), fontHeightReference?: \"emBox\"|\"capHeight\"|\"xHeight\"|\"icfBox\" (Character panel › Set Font Height Reference: the height of the font that size measures, the em box by default; with a font that has no such height, or one outside 0.1 to 2 em, the em box), language?: name|tag (Character panel › Language: one of Document Setup's languages by name or BCP 47 tag, case-insensitive, such as \"Turkish\" or \"tr\"; the characters are shaped in it, so the font uses that language's forms (OpenType `locl`); saved as the tag, English: USA, the default, as none; new type takes the document's language), fontAxes?: {\"wght\": 650, …} (Character panel › Variable Font: the setting of each variation axis named, in the axis's units and clamped to its range; null: back to the style's own setting; other axes keep theirs; tags the font has no axis for are left out; a font or style change clears them; text.fontList {family, style} lists the axes)} style a character range (size/leading/baselineShift are document points and hScale includes the object transform; runs are split at the range ends) → {id, runs, warning?: text (the font or style set isn't available: its type is drawn in the fallback font or the family's closest style)}",
|
||||
"{id? (default: the one selected type object), start?: byte, end?: byte (default: all text), story?: bool = false (all the text of every frame of the threaded story `id` is a frame of, in one undo step; no `start` or `end`), font?, style?, size?: pt, leading?: pt|\"auto\", tracking?, kerning?: 1/1000 em|\"auto\"|\"optical\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg, fill?: colour|\"none\", stroke?: colour|\"none\", keepModel?: bool (fill and stroke, as paint.setFill), strokeWidth?: pt, strokeOptions?: {weight?, cap?, join?, miterLimit?, align?, dash?, dashOffset?, alignDashes?} (as stroke.set: the character stroke), underline?, strikethrough?, allCaps?: bool, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), features?: [\"dlig\", \"-liga\", …], featureChanges?: [\"tnum\", \"-onum\", …] (OpenType features turned on, or with - off, on top of each character's own; its other features are kept), charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\", proportionalMetrics?: bool (full-width glyphs on the font's proportional widths: `palt` in horizontal type, `vpal` in vertical type), tateChuYoko?: bool (Character panel menu › Tate-chu-yoko, with Show East Asian Options: in vertical type those characters stand side by side across one em of the column; kept on the characters and ignored in horizontal type), tcyUpDown?, tcyLeftRight?: pt -1296..1296 (Character panel menu › Tate-chu-yoko Settings: in vertical type a block of tate-chu-yoko characters moves up by a positive Up/Down and down by a negative one, right by a positive Left/Right and left by a negative one, as one piece with the values of its first character; 0 by default; automatic digit blocks and horizontal type do not move; neither turns tateChuYoko on), standardVerticalRoman?: bool (Character panel menu › Standard Vertical Roman Alignment, with Show East Asian Options: in vertical type those half-width characters, such as Latin letters and digits, stand upright one below the other instead of being rotated; spaces and tate-chu-yoko blocks are not changed; kept on the characters and ignored in horizontal type; off by default), noBreak?: bool (Character panel menu › No Break: these characters stay on one line, including across a space inside the run), kashidas?: bool (Character panel menu › Kashidas: selected characters allow insertion only when both joined neighbors have it on; on by default), charDirection?: \"default\"|\"leftToRight\"|\"rightToLeft\" (Character panel menu › Character Direction: the characters are set left to right or right to left inside their paragraph, as a Unicode bidi embedding; default: as the bidi algorithm sets them), digits?: \"default\"|\"arabic\"|\"hindi\"|\"farsi\"|\"devanagari\"|\"bengali\"|\"gurmukhi\"|\"gujarati\"|\"odia\"|\"tamil\"|\"telugu\"|\"kannada\"|\"malayalam\" (Character panel menu › Digits: the numeral system every digit is drawn in, whatever was typed: Western, Arabic-Indic (hindi), Eastern Arabic-Indic (farsi) or an Indic script's own digits), fontHeightReference?: \"emBox\"|\"capHeight\"|\"xHeight\"|\"icfBox\" (Character panel › Set Font Height Reference: the height of the font that size measures, the em box by default; with a font that has no such height, or one outside 0.1 to 2 em, the em box), language?: name|tag (Character panel › Language: one of Document Setup's languages by name or BCP 47 tag, case-insensitive, such as \"Turkish\" or \"tr\"; the characters are shaped in it, so the font uses that language's forms (OpenType `locl`); saved as the tag, English: USA, the default, as none; new type takes the document's language), fontAxes?: {\"wght\": 650, …} (Character panel › Variable Font: the setting of each variation axis named, in the axis's units and clamped to its range; null: back to the style's own setting; other axes keep theirs; tags the font has no axis for are left out; a font or style change clears them; text.fontList {family, style} lists the axes)} style a character range (size/leading/baselineShift are document points and hScale includes the object transform; runs are split at the range ends) → {id, runs, warning?: text (the font or style set isn't available: its type is drawn in the fallback font or the family's closest style)}",
|
||||
has_doc,
|
||||
set_range_style
|
||||
),
|
||||
@@ -57,7 +57,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Get Text Range",
|
||||
[],
|
||||
None,
|
||||
"{id, start?, end?, effective?: bool = false} → {text, runs: [{text, style}], length}; effective uses document-point size/leading/baseline shift and transformed horizontal scale; default returns stored local styles for rich-text copying",
|
||||
"{id, start?, end?, effective?: bool = false, story?: bool = false (the whole text of the threaded story `id` is a frame of, its frames in thread order; no `start` or `end`)} → {text, runs: [{text, style}], length}; effective uses document-point size/leading/baseline shift and transformed horizontal scale; default returns stored local styles for rich-text copying",
|
||||
has_doc,
|
||||
get_range
|
||||
),
|
||||
@@ -253,7 +253,16 @@ fn edit_range(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Some(v) => Some(serde_json::from_value(v.clone()).map_err(|e| bad(C, format!("bad `runs`: {e}")))?),
|
||||
};
|
||||
let insert = str_param(p, "insert").unwrap_or("").to_string();
|
||||
let clear = clear_param(s, p, id, C)?;
|
||||
let caret = s.edit("Typing", |d, _| {
|
||||
for other in &clear {
|
||||
let t = text_mut(d, *other).ok_or(EngineError::NoNode(*other))?;
|
||||
let len = edit::runs_len(&t.runs);
|
||||
t.splice_paras(0, len, "");
|
||||
edit::replace_range(&mut t.runs, 0, len, "");
|
||||
t.ruby_after_replace(0, len, 0);
|
||||
refresh_bounds(t);
|
||||
}
|
||||
let t = text_mut(d, id).ok_or(EngineError::NoNode(id))?;
|
||||
// Paragraph styles follow the edit: a split paragraph's new one continues its style, a
|
||||
// merge keeps the first paragraph's.
|
||||
@@ -274,6 +283,30 @@ fn edit_range(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({"id": id.0, "caret": caret}))
|
||||
}
|
||||
|
||||
/// The `clear` param of `text.editRange`: other type objects whose text the edit removes, each
|
||||
/// once. An error when it is not a list of ids of type objects other than `id`.
|
||||
fn clear_param(s: &Session, p: &Value, id: NodeId, c: &str) -> Result<Vec<NodeId>> {
|
||||
let ids = match p.get("clear") {
|
||||
None | Some(Value::Null) => return Ok(vec![]),
|
||||
Some(Value::Array(ids)) => ids,
|
||||
Some(v) => return Err(bad(c, format!("`clear` must be a list of type object ids, not {v}"))),
|
||||
};
|
||||
let mut out = vec![];
|
||||
for v in ids {
|
||||
let other = v.as_u64().map(NodeId).ok_or_else(|| bad(c, format!("`clear` must be a list of type object ids, not {v}")))?;
|
||||
if other == id {
|
||||
return Err(bad(c, "`clear` must not list `id`"));
|
||||
}
|
||||
if !matches!(s.doc()?.doc.node(other).map(|n| &n.kind), Some(NodeKind::Text(_))) {
|
||||
return Err(bad(c, format!("`clear` must list type objects; {} is not one", other.0)));
|
||||
}
|
||||
if !out.contains(&other) {
|
||||
out.push(other);
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn start_list(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "text.startList";
|
||||
let id = id_param(p, "id").ok_or_else(|| bad(C, "missing `id`"))?;
|
||||
@@ -306,6 +339,9 @@ fn start_list(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
fn get_range(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let id = id_param(p, "id").ok_or_else(|| bad("text.getRange", "missing `id`"))?;
|
||||
if switch_or(p, "story", false, "text.getRange")? {
|
||||
return get_story(s, p, id);
|
||||
}
|
||||
let t = text_ref(s, id)?;
|
||||
let len = edit::runs_len(&t.runs);
|
||||
let (a, b) = range_of(p, len, "text.getRange")?;
|
||||
@@ -319,6 +355,31 @@ fn get_range(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({"text": text, "runs": runs, "length": len}))
|
||||
}
|
||||
|
||||
/// `text.getRange {id, story: true}`: the whole text of the story `id` is a frame of, its thread's
|
||||
/// frames in order (`id` alone when it is in no thread).
|
||||
fn get_story(s: &mut Session, p: &Value, id: NodeId) -> Result<Value> {
|
||||
const C: &str = "text.getRange";
|
||||
if ["start", "end"].iter().any(|k| p.get(*k).is_some_and(|v| !v.is_null())) {
|
||||
return Err(bad(C, "`story` takes the whole story: give no `start` or `end`"));
|
||||
}
|
||||
let effective = switch_or(p, "effective", false, C)?;
|
||||
let story = super::threads::story_of(&s.doc()?.doc, id);
|
||||
let frames = if story.is_empty() { vec![id] } else { story };
|
||||
let mut runs = vec![];
|
||||
for f in frames {
|
||||
let t = text_ref(s, f)?;
|
||||
for mut run in edit::slice_runs(&t.runs, 0, edit::runs_len(&t.runs)) {
|
||||
if effective {
|
||||
run.style = t.effective_char_style(&run.style).ok_or_else(|| bad(C, "text transform is collapsed or unrepresentable"))?;
|
||||
}
|
||||
runs.push(run);
|
||||
}
|
||||
}
|
||||
let text: String = runs.iter().map(|r| r.text.as_str()).collect();
|
||||
let len = text.len();
|
||||
Ok(json!({"text": text, "runs": runs, "length": len}))
|
||||
}
|
||||
|
||||
/// Keep `st`'s font style when its family has it, else take the family's closest one: a family
|
||||
/// whose styles are numbered (W3, W6) has no Regular (#4128). A family that isn't installed keeps
|
||||
/// the style as it is.
|
||||
@@ -913,37 +974,56 @@ fn set_range_style(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
_ => return Err(bad(C, "missing `id`: give one, or select one type object")),
|
||||
},
|
||||
};
|
||||
let mut change = CharChange::parse(p, C, &s.doc()?.doc.setup)?;
|
||||
super::paint::check_keep_model(p, C)?;
|
||||
change.store_colors(s, p);
|
||||
if change.is_empty() {
|
||||
return Err(bad(C, "nothing to change"));
|
||||
let story = switch_or(p, "story", false, C)?;
|
||||
if story && ["start", "end"].iter().any(|k| p.get(*k).is_some_and(|v| !v.is_null())) {
|
||||
return Err(bad(C, "`story` styles the whole story: give no `start` or `end`"));
|
||||
}
|
||||
let protect = s.prefs.missing_glyph_protection && (change.font.is_some() || change.style.is_some());
|
||||
let t = text_ref(s, id)?;
|
||||
change.localize(&t, C)?;
|
||||
let (a, b) = range_of(p, edit::runs_len(&t.runs), C)?;
|
||||
let frames = match super::threads::story_of(&s.doc()?.doc, id) {
|
||||
all if story && !all.is_empty() => all,
|
||||
_ => vec![id],
|
||||
};
|
||||
// Each frame's change (sizes are in its own text space) and range.
|
||||
let mut parts = Vec::with_capacity(frames.len());
|
||||
for f in frames {
|
||||
let mut change = CharChange::parse(p, C, &s.doc()?.doc.setup)?;
|
||||
super::paint::check_keep_model(p, C)?;
|
||||
change.store_colors(s, p);
|
||||
if change.is_empty() {
|
||||
return Err(bad(C, "nothing to change"));
|
||||
}
|
||||
let t = text_ref(s, f)?;
|
||||
change.localize(&t, C)?;
|
||||
let (a, b) = if story { (0, edit::runs_len(&t.runs)) } else { range_of(p, edit::runs_len(&t.runs), C)? };
|
||||
parts.push((f, change, a, b));
|
||||
}
|
||||
let sets_font = parts.first().is_some_and(|(_, change, _, _)| change.font.is_some() || change.style.is_some());
|
||||
let protect = s.prefs.missing_glyph_protection && sets_font;
|
||||
// The fonts the styled characters are set in, for the warning.
|
||||
let mut set = std::collections::BTreeSet::new();
|
||||
let sets_font = change.font.is_some() || change.style.is_some();
|
||||
let n = s.edit("Character", |d, _| {
|
||||
let t = text_mut(d, id).ok_or(EngineError::NoNode(id))?;
|
||||
if a == b && !t.plain_text().is_empty() {
|
||||
// An empty range inside text styles nothing (Illustrator keeps it for the next typing).
|
||||
return Ok(t.runs.len());
|
||||
let mut n = 0;
|
||||
for (f, change, a, b) in &parts {
|
||||
let (a, b) = (*a, *b);
|
||||
let t = text_mut(d, *f).ok_or(EngineError::NoNode(*f))?;
|
||||
if a == b && !t.plain_text().is_empty() {
|
||||
// An empty range inside text styles nothing (Illustrator keeps it for the next typing).
|
||||
n = if *f == id { t.runs.len() } else { n };
|
||||
continue;
|
||||
}
|
||||
let before = protect.then(|| t.runs.clone());
|
||||
edit::style_range(&mut t.runs, a, b, |st| change.apply(st));
|
||||
if let Some(before) = before {
|
||||
protect_missing_glyphs(&before, &mut t.runs);
|
||||
}
|
||||
if sets_font {
|
||||
// Empty text has one run, which the empty range styles.
|
||||
let styled = if a == b { t.runs.clone() } else { edit::slice_runs(&t.runs, a, b) };
|
||||
set.extend(styled.iter().map(|r| super::fonts::font_of(&r.style)));
|
||||
}
|
||||
refresh_bounds(t);
|
||||
n = if *f == id { t.runs.len() } else { n };
|
||||
}
|
||||
let before = protect.then(|| t.runs.clone());
|
||||
edit::style_range(&mut t.runs, a, b, |st| change.apply(st));
|
||||
if let Some(before) = before {
|
||||
protect_missing_glyphs(&before, &mut t.runs);
|
||||
}
|
||||
if sets_font {
|
||||
// Empty text has one run, which the empty range styles.
|
||||
let styled = if a == b { t.runs.clone() } else { edit::slice_runs(&t.runs, a, b) };
|
||||
set.extend(styled.iter().map(|r| super::fonts::font_of(&r.style)));
|
||||
}
|
||||
refresh_bounds(t);
|
||||
Ok(t.runs.len())
|
||||
Ok(n)
|
||||
})?;
|
||||
let mut out = json!({"id": id.0, "runs": n});
|
||||
if let Some(w) = super::fonts::set_font_warning(set) {
|
||||
@@ -1317,8 +1397,20 @@ pub(crate) fn editing_range(s: &mut Session) -> Result<Option<(NodeId, usize, us
|
||||
Ok(Some((id, at("start"), at("end"))))
|
||||
}
|
||||
|
||||
/// The frames of the story whose whole text the Type tool has selected after Select All in a
|
||||
/// frame of threaded text, in thread order; empty when its selection is in one type object.
|
||||
pub(crate) fn editing_story(s: &Session) -> Vec<NodeId> {
|
||||
let o = s.tool_options();
|
||||
let Some(id) = o.get("editing").and_then(Value::as_u64).map(NodeId) else { return vec![] };
|
||||
if !o.get("story").and_then(Value::as_array).is_some_and(|ids| !ids.is_empty()) {
|
||||
return vec![];
|
||||
}
|
||||
s.active().map(|st| super::threads::story_of(&st.doc, id)).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// The text ranges a type shortcut changes: the range of `id`, else all the text of the type
|
||||
/// objects in `ids`, else the text the Type tool has selected, else all the text of every selected
|
||||
/// objects in `ids`, else the text the Type tool has selected (all the text of every frame of the
|
||||
/// story after Select All in threaded text, [`editing_story`]), else all the text of every selected
|
||||
/// type object. At a caret, `at_caret` gives the range from the plain text and the caret's byte
|
||||
/// (None: nothing changes there). Each range comes with whether it came from a caret.
|
||||
pub(crate) fn type_ranges(
|
||||
@@ -1336,7 +1428,10 @@ pub(crate) fn type_ranges(
|
||||
Some(id) => vec![(id, Some(range_of(p, edit::runs_len(&text_ref(s, id)?.runs), c)?))],
|
||||
None => {
|
||||
let editing = editing_range(s)?.filter(|_| p.get("ids").filter(|v| !v.is_null()).is_none());
|
||||
let story = if editing.is_some() { editing_story(s) } else { vec![] };
|
||||
match editing {
|
||||
// Select All in threaded text: every frame's text.
|
||||
Some(_) if !story.is_empty() => story.into_iter().map(|id| (id, None)).collect(),
|
||||
Some((id, a, b)) => vec![(id, Some((a.min(b), a.max(b))))],
|
||||
None => super::typecmd::text_targets(s, p, c)?.into_iter().map(|id| (id, None)).collect(),
|
||||
}
|
||||
|
||||
@@ -276,6 +276,14 @@ pub(crate) fn mark_continued(doc: &mut Document) {
|
||||
}
|
||||
}
|
||||
|
||||
/// The frames of the thread that `id` is a frame of, in thread order, each area type or type on a
|
||||
/// path; empty when `id` is in no thread or the thread has fewer than two such frames.
|
||||
pub(crate) fn story_of(d: &Document, id: NodeId) -> Vec<NodeId> {
|
||||
let Some(thread) = d.text_threads.iter().find(|t| t.contains(&id)) else { return vec![] };
|
||||
let frames: Vec<NodeId> = thread.iter().copied().filter(|f| text_of(d, *f).is_some()).collect();
|
||||
if frames.len() >= 2 && frames.contains(&id) { frames } else { vec![] }
|
||||
}
|
||||
|
||||
/// `ids` followed by the other frames of the threads they are in, each once, in thread order.
|
||||
pub(crate) fn with_threads(d: &Document, ids: &[NodeId]) -> Vec<NodeId> {
|
||||
let mut out = ids.to_vec();
|
||||
|
||||
@@ -1496,6 +1496,128 @@ fn select_all_and_cut_like_ui() {
|
||||
assert_eq!(s.execute("select.all", &json!({})).unwrap(), json!({"count": 2}));
|
||||
}
|
||||
|
||||
/// A story threaded from area type `a` into frame `b`, with the Type tool editing `b` at its start.
|
||||
fn threaded_story_edited_in_its_second_frame() -> (Session, NodeId, NodeId, String) {
|
||||
let mut s = session();
|
||||
let story = "The quick brown fox jumps over the lazy dog and keeps running far beyond the first frame. ".repeat(4);
|
||||
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": 200})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("select.set", &json!({"ids": [a, b]})).unwrap();
|
||||
s.execute("text.thread.create", &json!({})).unwrap();
|
||||
let (a, b) = (NodeId(a), NodeId(b));
|
||||
assert!(!obj(&s, a).plain_text().is_empty() && !obj(&s, b).plain_text().is_empty(), "the story runs through both frames");
|
||||
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}));
|
||||
assert!(s.tool_wants_text());
|
||||
(s, a, b, story)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn select_all_in_a_threaded_frame_selects_the_whole_story() {
|
||||
let (mut s, a, b, story) = threaded_story_edited_in_its_second_frame();
|
||||
let a_len = obj(&s, a).plain_text().len();
|
||||
let o = s.execute("select.all", &json!({})).unwrap();
|
||||
assert_eq!(o, json!({"editing": a.0, "start": 0, "end": a_len, "story": [a.0, b.0]}));
|
||||
assert_eq!(s.doc().unwrap().selection.objects, [a], "the first frame, which the tool edits, is selected");
|
||||
// Copy takes the whole story.
|
||||
let r = s.execute("text.getRange", &json!({"id": o["editing"], "story": true})).unwrap();
|
||||
assert_eq!(r["text"], json!(story));
|
||||
assert_eq!(r["length"], json!(story.len()));
|
||||
// The selection is highlighted in both frames.
|
||||
let v = ViewInfo::default();
|
||||
let in_b = |o: &vectorcraft_tools::Overlay| matches!(o, vectorcraft_tools::Overlay::Highlight { quad, color: [60, 120, 255, 90] } if quad.iter().all(|p| p.x > 190.0));
|
||||
assert!(s.overlays(v).iter().any(in_b), "the text of the second frame is highlighted");
|
||||
// A fill reaches the text of both frames, as one undo step.
|
||||
let steps = undo_labels(&s).len();
|
||||
s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap();
|
||||
assert_eq!(undo_labels(&s).len(), steps + 1);
|
||||
let red = obj(&s, a).runs[0].style.fill.clone();
|
||||
for id in [a, b] {
|
||||
assert!(obj(&s, id).runs.iter().all(|r| r.style.fill == red), "every character of frame {} is filled", id.0);
|
||||
}
|
||||
// So do a baseline shift step (Alt+Shift+Up) and a size step.
|
||||
key(&mut s, ToolKey::Up, Mods { alt: true, shift: true, ..Mods::default() });
|
||||
for id in [a, b] {
|
||||
assert!(obj(&s, id).runs.iter().all(|r| r.style.baseline_shift > 0.0), "every character of frame {} is raised", id.0);
|
||||
}
|
||||
let size = obj(&s, b).runs[0].style.size;
|
||||
s.execute("type.step", &json!({"attribute": "size", "by": 1})).unwrap();
|
||||
for id in [a, b] {
|
||||
assert!(obj(&s, id).runs.iter().all(|r| r.style.size > size), "every character of frame {} is larger", id.0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn select_all_edits_the_first_frame_that_holds_text() {
|
||||
let mut s = session();
|
||||
// The first frame is too short for a line: the story starts in the second.
|
||||
let a =
|
||||
s.execute("text.create", &json!({"x": 10, "y": 10, "text": "One two three", "size": 12, "area": {"width": 150, "height": 4}})).unwrap()["id"]
|
||||
.as_u64()
|
||||
.unwrap();
|
||||
let b = s.execute("shape.rectangle", &json!({"x": 200, "y": 10, "width": 150, "height": 200})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("select.set", &json!({"ids": [a, b]})).unwrap();
|
||||
s.execute("text.thread.create", &json!({})).unwrap();
|
||||
let (a, b) = (NodeId(a), NodeId(b));
|
||||
assert_eq!((obj(&s, a).plain_text(), obj(&s, b).plain_text()), (String::new(), "One two three".to_string()));
|
||||
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}));
|
||||
let o = s.execute("select.all", &json!({})).unwrap();
|
||||
assert_eq!(o, json!({"editing": b.0, "start": 0, "end": 13, "story": [a.0, b.0]}));
|
||||
key(&mut s, ToolKey::Delete, Mods::default());
|
||||
assert_eq!((obj(&s, a).plain_text(), obj(&s, b).plain_text()), (String::new(), String::new()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_eyedropper_styles_the_whole_story_that_select_all_selected() {
|
||||
let (mut s, a, b, _) = threaded_story_edited_in_its_second_frame();
|
||||
let source = s.execute("text.create", &json!({"x": 400, "y": 250, "text": "Big", "size": 30})).unwrap()["id"].as_u64().unwrap();
|
||||
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();
|
||||
let r = s.execute("appearance.copyFrom", &json!({"source": source})).unwrap();
|
||||
assert_eq!(r, json!({"ids": [a.0, b.0]}));
|
||||
for id in [a, b] {
|
||||
assert!(obj(&s, id).runs.iter().all(|r| (r.style.size - 30.0).abs() < 1e-9), "every character of frame {} takes the size", id.0);
|
||||
}
|
||||
}
|
||||
|
||||
#[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();
|
||||
s.execute("select.all", &json!({})).unwrap();
|
||||
let steps = undo_labels(&s).len();
|
||||
key(&mut s, ToolKey::Delete, Mods::default());
|
||||
assert_eq!((obj(&s, a).plain_text(), obj(&s, b).plain_text()), (String::new(), String::new()));
|
||||
s.tool_text("New", ViewInfo::default()).unwrap();
|
||||
key(&mut s, ToolKey::Escape, Mods::default());
|
||||
assert_eq!((obj(&s, a).plain_text(), obj(&s, b).plain_text()), ("New".to_string(), String::new()));
|
||||
assert_eq!(undo_labels(&s).len(), steps + 1, "the delete and the typing are one step");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(format!("{}{}", obj(&s, a).plain_text(), obj(&s, b).plain_text()), story);
|
||||
assert!(!obj(&s, b).plain_text().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn select_all_in_one_frame_selects_only_its_text_once_the_caret_moves() {
|
||||
let (mut s, a, b, story) = threaded_story_edited_in_its_second_frame();
|
||||
s.execute("select.all", &json!({})).unwrap();
|
||||
let a_len = obj(&s, a).plain_text().len();
|
||||
// Shift+Left ends the story selection: the selection is the first frame's text but its last
|
||||
// character, and Delete removes only that.
|
||||
key(&mut s, ToolKey::Left, Mods { shift: true, ..Mods::default() });
|
||||
assert!(s.tool_options().get("story").is_none());
|
||||
key(&mut s, ToolKey::Delete, Mods::default());
|
||||
assert_eq!(format!("{}{}", obj(&s, a).plain_text(), obj(&s, b).plain_text()), story[a_len - 1..]);
|
||||
assert!(s.execute("text.getRange", &json!({"id": a.0, "story": true, "start": 0})).is_err(), "`story` takes no range");
|
||||
assert!(s.execute("text.editRange", &json!({"id": a.0, "start": 0, "end": 0, "clear": [a.0]})).is_err());
|
||||
assert!(s.execute("text.editRange", &json!({"id": a.0, "start": 0, "end": 0, "clear": "x"})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_frame_dragged_with_the_type_tools_is_the_dragged_rectangle() {
|
||||
for tool in ["type", "verticalType"] {
|
||||
|
||||
@@ -71,6 +71,9 @@ struct Typing {
|
||||
/// previewed span starts no later, so Return at a paragraph's end splits that paragraph (its
|
||||
/// style continues), not the next one.
|
||||
lo: usize,
|
||||
/// The other frames of the story whose text the session removed: it began by replacing the
|
||||
/// whole story that Select All selected ([`TypeTool::story`]).
|
||||
clear: Vec<NodeId>,
|
||||
}
|
||||
|
||||
/// IME marked text inside the typing session, not yet committed.
|
||||
@@ -171,6 +174,10 @@ pub struct TypeTool {
|
||||
/// Caret and selection anchor (byte offsets into the plain text).
|
||||
caret: usize,
|
||||
anchor: usize,
|
||||
/// After Select All in a frame of threaded text: the story's frames in thread order, the edited
|
||||
/// one included. While the selection is the edited frame's whole text, the text of the other
|
||||
/// frames is selected too. Empty otherwise.
|
||||
story: Vec<NodeId>,
|
||||
/// Horizontal position kept by Up/Down (text space).
|
||||
goal_x: Option<f64>,
|
||||
typing: Option<Typing>,
|
||||
@@ -218,15 +225,22 @@ struct BeforePress {
|
||||
}
|
||||
|
||||
impl TypeTool {
|
||||
pub(super) fn step_attribute(&mut self, key: ToolKey, mods: Mods, a: usize, b: usize, vertical: bool) -> Option<Vec<Action>> {
|
||||
pub(super) fn step_attribute(&mut self, cx: &ToolContext, key: ToolKey, mods: Mods, a: usize, b: usize, vertical: bool) -> Option<Vec<Action>> {
|
||||
if !mods.alt {
|
||||
return None;
|
||||
}
|
||||
let id = self.editing?;
|
||||
let (attribute, by) = plain::step_key(key, mods.shift, a == b, vertical)?;
|
||||
let story = !self.story_others(cx).is_empty();
|
||||
let mut out = self.commit();
|
||||
let by = if mods.cmd { by * 5.0 } else { by };
|
||||
out.push(Action::Exec("type.step".into(), json!({"id": id.0, "start": a, "end": b, "attribute": attribute, "by": by})));
|
||||
// With the whole story selected, `type.step` steps the text of every frame of it.
|
||||
let p = if story {
|
||||
json!({"attribute": attribute, "by": by})
|
||||
} else {
|
||||
json!({"id": id.0, "start": a, "end": b, "attribute": attribute, "by": by})
|
||||
};
|
||||
out.push(Action::Exec("type.step".into(), p));
|
||||
Some(out)
|
||||
}
|
||||
pub fn new(id: &str) -> Self {
|
||||
@@ -375,6 +389,7 @@ impl TypeTool {
|
||||
out.push(Action::Exec("text.discardEmpty".into(), json!({"id": id.0})));
|
||||
}
|
||||
self.editing = None;
|
||||
self.story.clear();
|
||||
self.selecting = false;
|
||||
self.goal_x = None;
|
||||
out
|
||||
@@ -388,8 +403,12 @@ impl TypeTool {
|
||||
return vec![];
|
||||
};
|
||||
let mut out = vec![];
|
||||
// The whole story is selected: the edit replaces it, so the other frames' text goes too, in
|
||||
// a typing session of its own.
|
||||
let others = self.story_others(cx);
|
||||
self.story.clear();
|
||||
// The document changed under us (undo, a panel edit): start a new session.
|
||||
if self.typing.as_ref().is_some_and(|ty| ty.cur != t.runs) {
|
||||
if self.typing.as_ref().is_some_and(|ty| ty.cur != t.runs || !others.is_empty()) {
|
||||
out.push(Action::Commit);
|
||||
self.typing = None;
|
||||
self.preedit = None;
|
||||
@@ -399,7 +418,7 @@ impl TypeTool {
|
||||
out.push(Action::Begin("Typing".into()));
|
||||
self.typing_ended = false;
|
||||
}
|
||||
let ty = self.typing.get_or_insert_with(|| Typing { base: t.runs.clone(), cur: t.runs.clone(), lo: usize::MAX });
|
||||
let ty = self.typing.get_or_insert_with(|| Typing { base: t.runs.clone(), cur: t.runs.clone(), lo: usize::MAX, clear: others });
|
||||
let len = edit::runs_len(&ty.cur);
|
||||
let (a, b) = (self.caret.min(self.anchor).min(len), self.caret.max(self.anchor).min(len));
|
||||
ty.lo = ty.lo.min(a);
|
||||
@@ -416,11 +435,24 @@ impl TypeTool {
|
||||
let (p, s) = common_affixes(&base, &cur, ty.lo);
|
||||
let runs = edit::slice_runs(&ty.cur, p, cur.len() - s);
|
||||
let runs = if runs.is_empty() { json!([]) } else { serde_json::to_value(&runs).unwrap_or(json!([])) };
|
||||
out.push(Action::Preview("text.editRange".into(), json!({"id": id.0, "start": p, "end": base.len() - s, "runs": runs})));
|
||||
out.push(Action::Preview("text.editRange".into(), edit_range_params(id, p, base.len() - s, runs, &ty.clear)));
|
||||
self.note_edit(fresh, id, (&base, base.len() - s), (&cur, cur.len() - s));
|
||||
out
|
||||
}
|
||||
|
||||
/// 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.
|
||||
fn story_others(&self, cx: &ToolContext) -> Vec<NodeId> {
|
||||
let Some(id) = self.editing.filter(|id| self.story.contains(id)) else { return vec![] };
|
||||
let Some(t) = Self::text(cx, id) else { return vec![] };
|
||||
let (a, b) = self.sel();
|
||||
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()
|
||||
}
|
||||
|
||||
/// Move the caret (Shift keeps the anchor). Moving ends the typing session.
|
||||
fn move_to(&mut self, byte: usize, extend: bool) -> Vec<Action> {
|
||||
let out = self.commit();
|
||||
@@ -429,6 +461,7 @@ impl TypeTool {
|
||||
self.anchor = byte;
|
||||
}
|
||||
self.listed = None;
|
||||
self.story.clear();
|
||||
out
|
||||
}
|
||||
|
||||
@@ -442,6 +475,7 @@ impl TypeTool {
|
||||
self.known = None;
|
||||
self.caret = caret;
|
||||
self.anchor = caret;
|
||||
self.story.clear();
|
||||
self.goal_x = None;
|
||||
self.typing = None;
|
||||
self.preedit = None;
|
||||
@@ -522,6 +556,7 @@ impl TypeTool {
|
||||
/// leaves the caret and the selection where they are, within the text.
|
||||
fn after_history_step(&mut self, cx: &ToolContext, undo: bool) {
|
||||
self.stepped = true;
|
||||
self.story.clear();
|
||||
let Some(id) = self.editing else { return };
|
||||
let Some(text) = Self::text(cx, id).map(TextObject::plain_text) else { return };
|
||||
match self.known.take() {
|
||||
@@ -621,6 +656,7 @@ impl TypeTool {
|
||||
fn select_word_or_para(&mut self, cx: &ToolContext, para: bool) {
|
||||
let s = self.plain(cx);
|
||||
let r = if para { edit::paragraph_at(&s, self.caret) } else { edit::word_at(&s, self.caret) };
|
||||
self.story.clear();
|
||||
self.anchor = r.start;
|
||||
self.caret = r.end;
|
||||
}
|
||||
@@ -765,7 +801,7 @@ impl TypeTool {
|
||||
p.range.end = shift(p.range.end.min(len));
|
||||
}
|
||||
self.goal_x = None;
|
||||
let ty = self.typing.get_or_insert_with(|| Typing { base: t.runs.clone(), cur: t.runs.clone(), lo: usize::MAX });
|
||||
let ty = self.typing.get_or_insert_with(|| Typing { base: t.runs.clone(), cur: t.runs.clone(), lo: usize::MAX, clear: vec![] });
|
||||
// Delete from the end so earlier offsets stay valid; each call floors to a char boundary
|
||||
// and the one Preview below still makes the whole session one undo step.
|
||||
for &(a, b) in sorted.iter().rev() {
|
||||
@@ -778,11 +814,21 @@ impl TypeTool {
|
||||
let (p, s) = common_affixes(&base, &cur, ty.lo);
|
||||
let runs = edit::slice_runs(&ty.cur, p, cur.len() - s);
|
||||
let runs = if runs.is_empty() { json!([]) } else { serde_json::to_value(&runs).unwrap_or(json!([])) };
|
||||
out.push(Action::Preview("text.editRange".into(), json!({"id": id.0, "start": p, "end": base.len() - s, "runs": runs})));
|
||||
out.push(Action::Preview("text.editRange".into(), edit_range_params(id, p, base.len() - s, runs, &ty.clear)));
|
||||
self.note_edit(fresh, id, (&base, base.len() - s), (&cur, cur.len() - s));
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
/// The params of a typing session's `text.editRange` preview: bytes `start..end` of `id` become
|
||||
/// `runs`, and the text of the frames in `clear` goes.
|
||||
fn edit_range_params(id: NodeId, start: usize, end: usize, runs: Value, clear: &[NodeId]) -> Value {
|
||||
let mut p = json!({"id": id.0, "start": start, "end": end, "runs": runs});
|
||||
if !clear.is_empty() {
|
||||
p["clear"] = json!(clear.iter().map(|f| f.0).collect::<Vec<_>>());
|
||||
}
|
||||
p
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
@@ -201,7 +201,7 @@ impl Tool for TypeTool {
|
||||
self.anchor = self.anchor.min(len);
|
||||
let (a, b) = self.sel();
|
||||
// Alt+arrows step the type by the Preferences › Type increments (Cmd/Ctrl too: five steps).
|
||||
if let Some(out) = self.step_attribute(key, mods, a, b, t.vertical) {
|
||||
if let Some(out) = self.step_attribute(cx, key, mods, a, b, t.vertical) {
|
||||
return out;
|
||||
}
|
||||
let word = mods.cmd || mods.alt;
|
||||
@@ -506,14 +506,14 @@ impl Tool for TypeTool {
|
||||
self.caret = t.map_or(0, |t| edit::runs_len(&t.runs));
|
||||
}
|
||||
}
|
||||
/// `{editing, start, end, caret, anchor, typing, composing}` — the Character panel styles
|
||||
/// `start..end`.
|
||||
/// `{editing, start, end, caret, anchor, typing, composing, story?}` — the Character panel styles
|
||||
/// `start..end`; `story` lists the frames of a threaded story whose whole text Select All selected.
|
||||
fn options(&self) -> Value {
|
||||
match self.editing {
|
||||
Some(id) => {
|
||||
// Offsets may exceed the text after Select All; callers clamp to the text length.
|
||||
let (a, b) = self.sel();
|
||||
json!({
|
||||
let mut o = json!({
|
||||
"editing": id.0,
|
||||
"start": a,
|
||||
"end": b,
|
||||
@@ -522,7 +522,12 @@ impl Tool for TypeTool {
|
||||
"typing": self.typing.is_some(),
|
||||
"composing": self.preedit.is_some(),
|
||||
"prevTool": self.prev_tool,
|
||||
})
|
||||
});
|
||||
// After Select All in threaded text, the frames whose whole text is selected too.
|
||||
if !self.story.is_empty() {
|
||||
o["story"] = json!(self.story.iter().map(|f| f.0).collect::<Vec<_>>());
|
||||
}
|
||||
o
|
||||
}
|
||||
None => json!({"editing": null, "prevTool": self.prev_tool}),
|
||||
}
|
||||
@@ -551,7 +556,23 @@ impl Tool for TypeTool {
|
||||
self.preedit = None;
|
||||
self.typing_ended = true;
|
||||
}
|
||||
// `{story: [id…], editing?: id}`: the frames of the threaded story being edited, in
|
||||
// thread order. The tool edits frame `editing` of them (default: the first), and the
|
||||
// whole story's text is selected.
|
||||
"selectAll" if self.editing.is_some() => {
|
||||
let story: Vec<NodeId> =
|
||||
v.get("story").and_then(Value::as_array).into_iter().flatten().filter_map(Value::as_u64).map(NodeId).collect();
|
||||
if story.len() >= 2
|
||||
&& let Some(first) = story.first().copied()
|
||||
{
|
||||
let target = v.get("editing").and_then(Value::as_u64).map(NodeId).filter(|t| story.contains(t)).unwrap_or(first);
|
||||
if self.editing != Some(target) {
|
||||
self.start_editing(target, 0);
|
||||
}
|
||||
self.story = story;
|
||||
} else {
|
||||
self.story.clear();
|
||||
}
|
||||
self.anchor = 0;
|
||||
self.caret = usize::MAX / 4;
|
||||
}
|
||||
@@ -560,6 +581,7 @@ impl Tool for TypeTool {
|
||||
if let (Some(a), Some(b)) = (g("start"), g("end")) {
|
||||
self.anchor = a;
|
||||
self.caret = b;
|
||||
self.story.clear();
|
||||
}
|
||||
}
|
||||
"copy" => {
|
||||
@@ -571,6 +593,7 @@ impl Tool for TypeTool {
|
||||
self.typing = None;
|
||||
self.preedit = None;
|
||||
self.editing = None;
|
||||
self.story.clear();
|
||||
self.prev_tool = None;
|
||||
}
|
||||
_ => {}
|
||||
@@ -649,6 +672,14 @@ impl Tool for TypeTool {
|
||||
for q in vectorcraft_text::selection_quads(&lay, a, b) {
|
||||
o.push(Overlay::Highlight { quad: q.map(|p| t.xf * p), color: [60, 120, 255, 90] });
|
||||
}
|
||||
// The whole story is selected: the other frames' text is highlighted too.
|
||||
for f in self.story_others(cx) {
|
||||
let Some(other) = Self::text(cx, f) else { continue };
|
||||
let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), other);
|
||||
for q in vectorcraft_text::selection_quads(&lay, 0, edit::runs_len(&other.runs)) {
|
||||
o.push(Overlay::Highlight { quad: q.map(|p| other.xf * p), color: [60, 120, 255, 90] });
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(id) = self.editing.filter(|id| shown && ported(cx.doc, *id).is_some()) {
|
||||
ports(cx, id, &t, lay.overflow, &mut o);
|
||||
|
||||
@@ -87,7 +87,11 @@ pub fn open(app: &mut VectorcraftApp) -> Result<Value, String> {
|
||||
let mut targets = json!({});
|
||||
if let Some((id, a, b)) = crate::panels::character::text_editing(app) {
|
||||
crate::panels::character::end_typing(app);
|
||||
targets = json!({"ids": [id.0], "start": a, "end": b});
|
||||
targets = match crate::panels::character::text_story(app) {
|
||||
// After Select All in threaded text: every paragraph of every frame.
|
||||
Some(story) => json!({"ids": story.iter().map(|f| f.0).collect::<Vec<_>>()}),
|
||||
None => json!({"ids": [id.0], "start": a, "end": b}),
|
||||
};
|
||||
}
|
||||
let mut values = Map::new();
|
||||
for (_, prefix, _) in ROWS {
|
||||
|
||||
@@ -1214,6 +1214,12 @@ fn font_command(app: &VectorcraftApp, family: &str, style: Option<&str>) -> (&'s
|
||||
if let Some(s) = style {
|
||||
p["style"] = json!(s);
|
||||
}
|
||||
// After Select All in threaded text: every frame of the story.
|
||||
if let Some(first) = crate::panels::character::text_story(app).and_then(|story| story.first().copied()) {
|
||||
p["id"] = json!(first.0);
|
||||
p["story"] = json!(true);
|
||||
return ("text.setRangeStyle", p);
|
||||
}
|
||||
match crate::panels::character::text_editing(app) {
|
||||
Some((id, a, b)) => {
|
||||
p["id"] = json!(id.0);
|
||||
|
||||
@@ -141,7 +141,15 @@ fn shared_styles<T: PartialEq>(
|
||||
let para = t.plain_text().get(..a).map_or(0, |s| s.bytes().filter(|&c| c == b'\n').count());
|
||||
return f(t, t.para_at(para), &vectorcraft_text::edit::insertion_style(&t.runs, a, b), false);
|
||||
}
|
||||
each_paragraph_run(t, Some((a, b)), |para, style, inline| acc.add(f(t, para, style, inline)));
|
||||
match text_story(app) {
|
||||
// After Select All in threaded text: all the text of every frame of the story.
|
||||
Some(story) => {
|
||||
for t in story.into_iter().filter_map(text) {
|
||||
each_paragraph_run(t, None, |para, style, inline| acc.add(f(t, para, style, inline)));
|
||||
}
|
||||
}
|
||||
None => each_paragraph_run(t, Some((a, b)), |para, style, inline| acc.add(f(t, para, style, inline))),
|
||||
}
|
||||
} else {
|
||||
for n in st.selection.objects.iter().filter_map(|id| st.doc.node(*id)) {
|
||||
n.walk(&mut |c| {
|
||||
@@ -227,6 +235,9 @@ pub(crate) fn format(app: &mut VectorcraftApp, p: Value) {
|
||||
/// `text.setFormat` on the selected text objects.
|
||||
pub(crate) fn run_format(app: &mut VectorcraftApp, mut p: Value) -> Result<Value, String> {
|
||||
let Some((id, a, b)) = text_editing(app) else { return app.run("text.setFormat", p) };
|
||||
if let Some(first) = text_story(app).and_then(|story| story.first().copied()) {
|
||||
return story_style(app, first, p);
|
||||
}
|
||||
end_typing(app);
|
||||
p["id"] = json!(id.0);
|
||||
if b > a {
|
||||
@@ -451,6 +462,10 @@ pub(crate) fn toggle_underline(app: &mut VectorcraftApp) -> Result<Value, String
|
||||
/// nothing is selected). Does nothing unless the Type tool edits text.
|
||||
pub(crate) fn range_style(app: &mut VectorcraftApp, p: Value) {
|
||||
let Some((id, a, b)) = text_editing(app) else { return };
|
||||
if let Some(first) = text_story(app).and_then(|story| story.first().copied()) {
|
||||
story_style(app, first, p).ok();
|
||||
return;
|
||||
}
|
||||
end_typing(app);
|
||||
let mut q = p;
|
||||
q["id"] = json!(id.0);
|
||||
@@ -475,6 +490,31 @@ pub(crate) fn text_editing(app: &VectorcraftApp) -> Option<(NodeId, usize, usize
|
||||
Some((id, g("start"), g("end")))
|
||||
}
|
||||
|
||||
/// The frames of the threaded story whose whole text the Type tool has selected after Select All,
|
||||
/// in thread order; `None` when its selection is in one type object.
|
||||
pub(crate) fn text_story(app: &VectorcraftApp) -> Option<Vec<NodeId>> {
|
||||
let o = app.session.tool_options();
|
||||
let st = app.session.active()?;
|
||||
let ids: Vec<NodeId> = o
|
||||
.get("story")?
|
||||
.as_array()?
|
||||
.iter()
|
||||
.filter_map(Value::as_u64)
|
||||
.map(NodeId)
|
||||
.filter(|id| st.doc.node(*id).is_some_and(|n| matches!(n.kind, NodeKind::Text(_))))
|
||||
.collect();
|
||||
(ids.len() >= 2).then_some(ids)
|
||||
}
|
||||
|
||||
/// `text.setRangeStyle` with `p` on all the text of every frame of the story that `first` is a
|
||||
/// frame of, as one undo step.
|
||||
fn story_style(app: &mut VectorcraftApp, first: NodeId, mut p: Value) -> Result<Value, String> {
|
||||
end_typing(app);
|
||||
p["id"] = json!(first.0);
|
||||
p["story"] = json!(true);
|
||||
app.run("text.setRangeStyle", p)
|
||||
}
|
||||
|
||||
/// Close the Type tool's typing session (one undo step) before another command edits the text.
|
||||
pub(crate) fn end_typing(app: &mut VectorcraftApp) {
|
||||
if app.session.tool_options().get("typing").and_then(Value::as_bool) == Some(true) {
|
||||
@@ -495,7 +535,9 @@ fn text_copy(app: &mut VectorcraftApp) -> bool {
|
||||
if a == b {
|
||||
return false;
|
||||
}
|
||||
let Ok(r) = app.session.execute("text.getRange", &json!({"id": id.0, "start": a, "end": b})) else { return false };
|
||||
// After Select All in threaded text: the whole story.
|
||||
let range = if text_story(app).is_some() { json!({"id": id.0, "story": true}) } else { json!({"id": id.0, "start": a, "end": b}) };
|
||||
let Ok(r) = app.session.execute("text.getRange", &range) else { return false };
|
||||
let runs = r["runs"].clone();
|
||||
app.session.set_tool_option("copy", &runs);
|
||||
*CLIP_RUNS.lock().unwrap_or_else(|e| e.into_inner()) = Some(runs.clone());
|
||||
@@ -893,7 +935,11 @@ fn axes_cmd(app: &mut VectorcraftApp, axes: Value, phase: widgets::Live) {
|
||||
Some((id, a, b)) => {
|
||||
end_typing(app);
|
||||
let mut q = json!({"id": id.0, "fontAxes": axes});
|
||||
if b > a {
|
||||
// After Select All in threaded text: every frame of the story.
|
||||
if let Some(first) = text_story(app).and_then(|story| story.first().copied()) {
|
||||
q["id"] = json!(first.0);
|
||||
q["story"] = json!(true);
|
||||
} else if b > a {
|
||||
q["start"] = json!(a);
|
||||
q["end"] = json!(b);
|
||||
}
|
||||
|
||||
@@ -1171,3 +1171,45 @@ fn snap_to_glyph_options_show_from_the_menu_and_set_the_options() {
|
||||
toggle(&mut app, "Hide Snap to Glyph Options");
|
||||
assert!(!section(&mut app).iter().any(|(t, _)| t == "Proximity Guides"));
|
||||
}
|
||||
|
||||
/// After Select All in a frame of threaded text, the Character and Paragraph panels and Copy act on
|
||||
/// the text of every frame of the story.
|
||||
#[test]
|
||||
fn select_all_in_threaded_text_formats_and_copies_the_whole_story() {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.run("file.new", json!({"width": 600, "height": 300})).unwrap();
|
||||
let story = "The quick brown fox jumps over the lazy dog and keeps running far beyond the first frame. ".repeat(4);
|
||||
let area = json!({"x": 10, "y": 10, "text": story, "size": 12, "area": {"width": 150, "height": 60}});
|
||||
let a = app.run("text.create", area).unwrap()["id"].as_u64().unwrap();
|
||||
let b = app.run("shape.rectangle", json!({"x": 200, "y": 10, "width": 150, "height": 200})).unwrap()["id"].as_u64().unwrap();
|
||||
app.run("select.set", json!({"ids": [a, b]})).unwrap();
|
||||
app.run("text.thread.create", json!({})).unwrap();
|
||||
app.run("select.set", json!({"ids": [b]})).unwrap();
|
||||
app.session.select_tool("type", Default::default()).unwrap();
|
||||
app.session.set_tool_option("textSelection", &json!({"editing": b, "anchor": 0, "caret": 0}));
|
||||
app.run("select.all", json!({})).unwrap();
|
||||
assert_eq!(text_story(&app), Some(vec![NodeId(a), NodeId(b)]));
|
||||
let frame = |app: &VectorcraftApp, id: u64| match &app.session.active().unwrap().doc.node(NodeId(id)).unwrap().kind {
|
||||
NodeKind::Text(t) => (**t).clone(),
|
||||
_ => panic!("not text"),
|
||||
};
|
||||
format(&mut app, json!({"size": 9}));
|
||||
crate::panels::paragraph::run_on_paragraphs(&mut app, "text.setFormat", json!({"spaceBefore": 4})).unwrap();
|
||||
for id in [a, b] {
|
||||
let t = frame(&app, id);
|
||||
assert!(t.runs.iter().all(|r| (r.style.size - 9.0).abs() < 1e-9), "every character of frame {id} is 9 pt");
|
||||
assert!((t.para_at(0).space_before - 4.0).abs() < 1e-9, "frame {id}'s paragraphs take the space");
|
||||
}
|
||||
assert!(intercept_text_command(&mut app, "edit.copy").is_some());
|
||||
let copied: Vec<String> = app
|
||||
.session
|
||||
.clipboard
|
||||
.nodes
|
||||
.iter()
|
||||
.filter_map(|n| match &n.kind {
|
||||
NodeKind::Text(t) => Some(t.plain_text()),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(copied, [story]);
|
||||
}
|
||||
|
||||
@@ -156,10 +156,13 @@ fn set(app: &mut VectorcraftApp, f: OtFeatures) {
|
||||
/// Send `tags` as the `key` param (`features` or `featureChanges`) for the Type tool's range or
|
||||
/// caret, or else for the selected type.
|
||||
fn run_features(app: &mut VectorcraftApp, key: &str, tags: Vec<String>) {
|
||||
let (cmd, mut p) = match text_editing(app) {
|
||||
Some((id, a, b)) if b > a => ("text.setRangeStyle", json!({ "id": id.0, "start": a, "end": b })),
|
||||
Some((id, _, _)) => ("text.setRangeStyle", json!({ "id": id.0 })),
|
||||
None => ("text.setStyle", json!({})),
|
||||
// After Select All in threaded text: every frame of the story.
|
||||
let story = super::character::text_story(app).and_then(|story| story.first().copied());
|
||||
let (cmd, mut p) = match (text_editing(app), story) {
|
||||
(Some(_), Some(first)) => ("text.setRangeStyle", json!({ "id": first.0, "story": true })),
|
||||
(Some((id, a, b)), None) if b > a => ("text.setRangeStyle", json!({ "id": id.0, "start": a, "end": b })),
|
||||
(Some((id, _, _)), None) => ("text.setRangeStyle", json!({ "id": id.0 })),
|
||||
(None, _) => ("text.setStyle", json!({})),
|
||||
};
|
||||
p[key] = json!(tags);
|
||||
if let Err(e) = app.run(cmd, p) {
|
||||
|
||||
@@ -77,9 +77,15 @@ fn para_cmd(app: &mut VectorcraftApp, cmd: &str, p: Value) {
|
||||
pub(crate) fn run_on_paragraphs(app: &mut VectorcraftApp, cmd: &str, mut p: Value) -> Result<Value, String> {
|
||||
if let Some((id, a, b)) = super::character::text_editing(app) {
|
||||
super::character::end_typing(app);
|
||||
p["ids"] = json!([id.0]);
|
||||
p["start"] = json!(a);
|
||||
p["end"] = json!(b);
|
||||
match super::character::text_story(app) {
|
||||
// After Select All in threaded text: every paragraph of every frame.
|
||||
Some(story) => p["ids"] = json!(story.iter().map(|f| f.0).collect::<Vec<_>>()),
|
||||
None => {
|
||||
p["ids"] = json!([id.0]);
|
||||
p["start"] = json!(a);
|
||||
p["end"] = json!(b);
|
||||
}
|
||||
}
|
||||
}
|
||||
app.run(cmd, p)
|
||||
}
|
||||
@@ -164,9 +170,33 @@ fn collect_text_alignment(text: &vectorcraft_doc::TextObject, range: Option<(usi
|
||||
record(index, &plain, start, end) // Includes empty text and the paragraph after a final newline.
|
||||
}
|
||||
|
||||
/// The frames of the threaded story whose whole text the Type tool has selected after Select All
|
||||
/// ([`super::character::text_story`]), in thread order.
|
||||
fn story_texts(app: &VectorcraftApp) -> Option<Vec<&vectorcraft_doc::TextObject>> {
|
||||
let st = app.session.active()?;
|
||||
let story = super::character::text_story(app)?;
|
||||
Some(
|
||||
story
|
||||
.into_iter()
|
||||
.filter_map(|id| match st.doc.node(id).map(|n| &n.kind) {
|
||||
Some(NodeKind::Text(t)) => Some(t.as_ref()),
|
||||
_ => None,
|
||||
})
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
/// Visit every paragraph the Paragraph panel acts on.
|
||||
fn visit_paragraphs(app: &VectorcraftApp, mut f: impl FnMut(&ParaStyle)) {
|
||||
let Some(st) = app.session.active() else { return };
|
||||
if let Some(texts) = story_texts(app) {
|
||||
for t in texts {
|
||||
for i in 0..t.paragraph_count() {
|
||||
f(t.para_at(i));
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
if let Some((id, a, b)) = super::character::text_editing(app)
|
||||
&& let Some(NodeKind::Text(text)) = st.doc.node(id).map(|n| &n.kind)
|
||||
{
|
||||
@@ -288,6 +318,9 @@ fn selected_paragraph_direction(app: &VectorcraftApp, fallback: &ParaStyle) -> O
|
||||
let default = Some(resolved_rtl(app, fallback));
|
||||
let Some(st) = app.session.active() else { return default };
|
||||
let mut shared = None;
|
||||
if let Some(texts) = story_texts(app) {
|
||||
return if texts.into_iter().all(|t| collect_text_direction(t, None, &mut shared)) { shared.or(default) } else { None };
|
||||
}
|
||||
if let Some((id, a, b)) = super::character::text_editing(app)
|
||||
&& let Some(NodeKind::Text(text)) = st.doc.node(id).map(|n| &n.kind)
|
||||
{
|
||||
@@ -322,6 +355,9 @@ fn selected_alignment(app: &VectorcraftApp, fallback: &ParaStyle) -> Option<Just
|
||||
let default = Some(shown_alignment(fallback.justify, fallback.direction == Some(ParaDirection::RightToLeft)));
|
||||
let Some(st) = app.session.active() else { return default };
|
||||
let mut shared = None;
|
||||
if let Some(texts) = story_texts(app) {
|
||||
return if texts.into_iter().all(|t| collect_text_alignment(t, None, &mut shared)) { shared.or(default) } else { None };
|
||||
}
|
||||
if let Some((id, a, b)) = super::character::text_editing(app)
|
||||
&& let Some(NodeKind::Text(text)) = st.doc.node(id).map(|n| &n.kind)
|
||||
{
|
||||
|
||||
@@ -324,7 +324,12 @@ fn stops_json(stops: &[TabStop]) -> Value {
|
||||
fn apply(app: &mut VectorcraftApp, stops: &[TabStop]) -> bool {
|
||||
if let Some((id, a, b)) = super::character::text_editing(app) {
|
||||
super::character::end_typing(app);
|
||||
app.run("text.tabs.set", json!({"stops": stops_json(stops), "ids": [id.0], "start": a, "end": b})).is_ok()
|
||||
let p = match super::character::text_story(app) {
|
||||
// After Select All in threaded text: every paragraph of every frame.
|
||||
Some(story) => json!({"stops": stops_json(stops), "ids": story.iter().map(|f| f.0).collect::<Vec<_>>()}),
|
||||
None => json!({"stops": stops_json(stops), "ids": [id.0], "start": a, "end": b}),
|
||||
};
|
||||
app.run("text.tabs.set", p).is_ok()
|
||||
} else {
|
||||
app.run("text.tabs.set", json!({"stops": stops_json(stops)})).is_ok()
|
||||
}
|
||||
|
||||
@@ -191,6 +191,18 @@ fn target(app: &VectorcraftApp, kind: Kind, mut p: Value) -> Value {
|
||||
p
|
||||
}
|
||||
|
||||
/// [`target`] for applying a style: after Select All in threaded text, all the text of every
|
||||
/// frame of the story.
|
||||
fn apply_target(app: &VectorcraftApp, kind: Kind, mut p: Value) -> Value {
|
||||
match super::character::text_story(app) {
|
||||
Some(story) if text_editing(app).is_some() => {
|
||||
p["ids"] = json!(story.iter().map(|f| f.0).collect::<Vec<_>>());
|
||||
p
|
||||
}
|
||||
_ => target(app, kind, p),
|
||||
}
|
||||
}
|
||||
|
||||
/// Open New Character Style (`Kind::Para`: New Paragraph Style) to name a style made from the
|
||||
/// selected text: `ui.newCharStyle` and `ui.newParaStyle`.
|
||||
pub(crate) fn open_new(app: &mut VectorcraftApp, kind: Kind) -> Result<Value, String> {
|
||||
@@ -277,7 +289,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui, kind: Kind) {
|
||||
sel = super::swatches::click_selection(ui, kind.anchor_key(), sel, &listed, &name, m);
|
||||
// Shift and Cmd/Ctrl clicks only highlight, as in the Graphic Styles panel.
|
||||
if has_text && !(m.shift || m.command) {
|
||||
let p = target(app, kind, json!({ "name": name, "clearOverrides": m.alt }));
|
||||
let p = apply_target(app, kind, json!({ "name": name, "clearOverrides": m.alt }));
|
||||
app.run(&format!("{}.apply", kind.prefix()), p).ok();
|
||||
}
|
||||
}
|
||||
@@ -364,7 +376,7 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui, kind: Kind) {
|
||||
if menu_item(ui, tl!("Clear Overrides"), sel.is_some() && has_text, false)
|
||||
&& let Some(n) = &sel
|
||||
{
|
||||
app.run(&format!("{p}.apply"), target(app, kind, json!({ "name": n, "clearOverrides": true }))).ok();
|
||||
app.run(&format!("{p}.apply"), apply_target(app, kind, json!({ "name": n, "clearOverrides": true }))).ok();
|
||||
}
|
||||
if menu_item(ui, &crate::i18n::fmt(tl!("Redefine {kind} Style"), &[("kind", kind.what_label())]), sel.is_some() && has_text, false)
|
||||
&& let Some(n) = &sel
|
||||
|
||||
+8
-2
@@ -1179,7 +1179,7 @@ These commands refuse a string, a number or another value that is not true or fa
|
||||
`clockwise` in `shape.spiral`; `closed` in `shape.arc` and `path.create`; `vertical` and `placeholder` in `text.create`
|
||||
and `text.createInPath`; `underline`, `strikethrough`, `allCaps`, `proportionalMetrics`, `tateChuYoko`, `standardVerticalRoman` and `noBreak`
|
||||
in `text.setRangeStyle` and `text.setFormat`, which also checks `hyphenate`, `hyphenateCapitalized` and
|
||||
`romanHangingPunctuation`; `effective` in `text.getRange`; `on` in `type.opticalMarginAlignment`, `type.systemLayout`
|
||||
`romanHangingPunctuation`; `effective` and `story` in `text.getRange`; `story` in `text.setRangeStyle`; `on` in `type.opticalMarginAlignment`, `type.systemLayout`
|
||||
and `type.fractionalWidths`; `remove` and `overhang` in `type.ruby`; `expandInstances` in `symbol.delete`; `alt` in
|
||||
`symbol.spray` and `symbol.adjust`; `spot` and `global` in `swatch.new` and `swatch.edit`; `unlink` in
|
||||
`swatch.delete`; `fromArtwork`, `toGlobal` and `includeTints` in `swatch.newGroup`; `global` and `selection` in
|
||||
@@ -4881,7 +4881,13 @@ While the Type tool edits text (a click into type, `pointer_gesture` with `"tool
|
||||
`press_key {key: "A", mods: {cmd: true}}`) selects all of that text instead of the art, as in the reference app, and
|
||||
returns `{editing, start, end}` (the text's id and the selected byte range, which `text.getRange` and
|
||||
`text.setRangeStyle` take); the art selection stays as it is. Without text being edited it selects every object and
|
||||
returns `{count}`. Text in threaded frames is selected one frame at a time.
|
||||
returns `{count}`. In a frame of threaded text, `select.all` selects the whole story: the Type tool then edits the
|
||||
thread's first frame that holds text (which is selected when it was not) with the text of every frame selected, and the reply adds
|
||||
`story`, the frames in thread order. `text.getRange {id, story: true}` returns the story's text and runs, which Copy
|
||||
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.
|
||||
|
||||
```json
|
||||
{"name":"press_key","arguments":{"key":"A","mods":{"cmd":true}}}
|
||||
|
||||
@@ -680,7 +680,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
|
||||
- Shift+Tab (`window.panels`) shows and hides the dock and the floating panels and leaves the toolbar and the Control bar as they are; hiding the panels with Tab or Shift+Tab also closes a panel popped out of the icon column.
|
||||
- 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). Not yet: Select All across threaded frames (one frame's text is selected).
|
||||
- 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`).
|
||||
- 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).
|
||||
|
||||
Reference in New Issue
Block a user