mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-13 22:21:59 +00:00
Type: a threaded frame's empty last paragraph keeps a format given to it
A paragraph format given to the empty paragraph after a frame's last paragraph break was lost when the story flowed again: the paragraph is shared with the next frame's first paragraph, and the next frame's old style replaced it, in that frame and in the frames after it. - The thread reflow also notes which frames' last paragraph changed style, and story_paras_restyled keeps that style against the next frame's unedited first paragraph, across unedited empty frames too. When the same edit formats the next frame's first paragraph, that format applies, as before. - story_paras keeps its behavior for its other callers. - Tests cover area type and type on a path on either side, a next frame holding text, three frames, undo and redo, a format that changes nothing, and numbered list counts.
This commit is contained in:
@@ -135,6 +135,7 @@ pub(crate) fn reflow(before: &Document, doc: &mut Document) {
|
||||
let mut story: Vec<TextRun> = vec![];
|
||||
let mut sources: Vec<TextObject> = vec![];
|
||||
let mut later_wins = vec![];
|
||||
let mut earlier_wins = vec![];
|
||||
let mut live = vec![];
|
||||
for id in &thread {
|
||||
match (text_of(doc, *id), text_of(before, *id)) {
|
||||
@@ -143,6 +144,9 @@ pub(crate) fn reflow(before: &Document, doc: &mut Document) {
|
||||
// A frame whose first paragraph was just restyled gives the paragraph it
|
||||
// shares with the frame before it that style.
|
||||
later_wins.push(old.is_some_and(|o| o.para_at(0) != t.para_at(0)));
|
||||
// A frame whose last paragraph was just restyled keeps that style in the
|
||||
// paragraph it shares with the frame after it, even when it is empty.
|
||||
earlier_wins.push(old.is_some_and(|o| o.para_at(o.paragraph_count() - 1) != t.para_at(t.paragraph_count() - 1)));
|
||||
sources.push(t.clone());
|
||||
live.push(*id);
|
||||
}
|
||||
@@ -151,6 +155,7 @@ pub(crate) fn reflow(before: &Document, doc: &mut Document) {
|
||||
(None, Some(old)) => {
|
||||
story.extend(old.runs.iter().cloned());
|
||||
later_wins.push(false);
|
||||
earlier_wins.push(false);
|
||||
sources.push(old.clone());
|
||||
}
|
||||
(None, None) => {}
|
||||
@@ -160,7 +165,7 @@ pub(crate) fn reflow(before: &Document, doc: &mut Document) {
|
||||
continue;
|
||||
}
|
||||
let source_refs: Vec<&TextObject> = sources.iter().collect();
|
||||
let paras = vectorcraft_text::thread::story_paras(&source_refs, &later_wins);
|
||||
let paras = vectorcraft_text::thread::story_paras_restyled(&source_refs, &later_wins, &earlier_wins);
|
||||
vectorcraft_text::edit::normalize(&mut story);
|
||||
if live.len() >= 2 {
|
||||
// Threaded frames keep their size: Auto Size is off for them (as in Illustrator).
|
||||
|
||||
@@ -60,6 +60,130 @@ fn a_restyled_empty_frame_keeps_its_style_before_an_unedited_empty_frame() {
|
||||
assert_eq!([hyphenates(a, 0), hyphenates(a, 1), hyphenates(b, 0), hyphenates(c, 0)], [false, true, true, true]);
|
||||
}
|
||||
|
||||
/// Every paragraph's left indent in frame `id`.
|
||||
fn left_indents(s: &Session, id: u64) -> Vec<f64> {
|
||||
match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind {
|
||||
NodeKind::Text(t) => t.paragraph_styles().iter().map(|p| p.left_indent).collect(),
|
||||
_ => panic!("not text"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Area type `height` high holding `text`, threaded into `empties` empty rectangles after it.
|
||||
fn tail_thread(text: &str, height: f64, empties: usize) -> (Session, Vec<u64>) {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 800, "height": 300})).unwrap();
|
||||
let a = new_id(s.execute("text.create", &json!({"x": 10, "y": 10, "text": text, "area": {"width": 150, "height": height}})).unwrap());
|
||||
let mut ids = vec![a];
|
||||
for k in 0..empties {
|
||||
let x = 200.0 * (k + 1) as f64;
|
||||
ids.push(new_id(s.execute("shape.rectangle", &json!({"x": x, "y": 10, "width": 150, "height": 60})).unwrap()));
|
||||
}
|
||||
s.execute("select.set", &json!({"ids": ids})).unwrap();
|
||||
s.execute("text.thread.create", &json!({})).unwrap();
|
||||
(s, ids)
|
||||
}
|
||||
|
||||
/// Restyling a frame's empty last paragraph (after its final paragraph break) gives that
|
||||
/// paragraph the style in the next frame as well, instead of the next frame's old style.
|
||||
#[test]
|
||||
fn editing_a_frames_empty_last_paragraph_survives_thread_reflow() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 600, "height": 300})).unwrap();
|
||||
let a =
|
||||
s.execute("text.create", &json!({"x": 10, "y": 10, "text": "a\n", "area": {"width": 150, "height": 60}})).unwrap()["id"].as_u64().unwrap();
|
||||
let b = s.execute("shape.rectangle", &json!({"x": 200, "y": 10, "width": 150, "height": 60})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("select.set", &json!({"ids": [a, b]})).unwrap();
|
||||
s.execute("text.thread.create", &json!({})).unwrap();
|
||||
assert_eq!([text(&s, a), text(&s, b)], ["a\n", ""]);
|
||||
let steps = s.doc().unwrap().history.undo.len();
|
||||
s.execute("text.setFormat", &json!({"ids": [a], "start": 2, "end": 2, "leftIndent": 30})).unwrap();
|
||||
assert_eq!((left_indents(&s, a), left_indents(&s, b)), (vec![0.0, 30.0], vec![30.0]));
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), steps + 1);
|
||||
// The same format again changes nothing and records no undo step.
|
||||
s.execute("text.setFormat", &json!({"ids": [a], "start": 2, "end": 2, "leftIndent": 30})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), steps + 1);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!((left_indents(&s, a), left_indents(&s, b)), (vec![0.0, 0.0], vec![0.0]));
|
||||
s.execute("edit.redo", &json!({})).unwrap();
|
||||
assert_eq!((left_indents(&s, a), left_indents(&s, b)), (vec![0.0, 30.0], vec![30.0]));
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
|
||||
// A format of the whole frame changes its last paragraph as well.
|
||||
s.execute("text.setFormat", &json!({"ids": [a], "leftIndent": 20})).unwrap();
|
||||
assert_eq!((left_indents(&s, a), left_indents(&s, b)), (vec![20.0, 20.0], vec![20.0]));
|
||||
// Both frames formatted in one command agree.
|
||||
s.execute("text.setFormat", &json!({"ids": [a, b], "leftIndent": 40})).unwrap();
|
||||
assert_eq!((left_indents(&s, a), left_indents(&s, b)), (vec![40.0, 40.0], vec![40.0]));
|
||||
}
|
||||
|
||||
/// The restyled empty last paragraph keeps its style when the next frame holds text that
|
||||
/// continues it, and across an unedited empty frame; a later restyle of a later frame still wins.
|
||||
#[test]
|
||||
fn a_restyled_empty_last_paragraph_keeps_its_style_against_later_frames() {
|
||||
// One line high: the text added to the next frame stays there.
|
||||
let (mut s, ids) = tail_thread("a\n", 20.0, 1);
|
||||
let (a, b) = (ids[0], ids[1]);
|
||||
s.execute("text.editRange", &json!({"id": b, "start": 0, "end": 0, "insert": "def"})).unwrap();
|
||||
assert_eq!([text(&s, a), text(&s, b)], ["a\n", "def"]);
|
||||
s.execute("text.setFormat", &json!({"ids": [a], "start": 2, "end": 2, "leftIndent": 30})).unwrap();
|
||||
assert_eq!([text(&s, a), text(&s, b)], ["a\n", "def"]);
|
||||
assert_eq!((left_indents(&s, a), left_indents(&s, b)), (vec![0.0, 30.0], vec![30.0]));
|
||||
|
||||
let (mut s, ids) = tail_thread("a\n", 60.0, 2);
|
||||
s.execute("text.setFormat", &json!({"ids": [ids[0]], "start": 2, "end": 2, "leftIndent": 30})).unwrap();
|
||||
assert_eq!(ids.iter().map(|id| left_indents(&s, *id)).collect::<Vec<_>>(), [vec![0.0, 30.0], vec![30.0], vec![30.0]]);
|
||||
s.execute("text.setFormat", &json!({"ids": [ids[2]], "leftIndent": 10})).unwrap();
|
||||
assert_eq!(ids.iter().map(|id| left_indents(&s, *id)).collect::<Vec<_>>(), [vec![0.0, 10.0], vec![10.0], vec![10.0]]);
|
||||
}
|
||||
|
||||
/// The restyled last paragraph keeps its style with type on a path on either side of the boundary.
|
||||
#[test]
|
||||
fn a_restyled_empty_last_paragraph_flows_onto_type_on_a_path() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 800, "height": 300})).unwrap();
|
||||
let a = new_id(s.execute("text.create", &json!({"x": 10, "y": 10, "text": "a\n", "area": {"width": 150, "height": 60}})).unwrap());
|
||||
let on_path = type_on_a_line(&mut s);
|
||||
s.execute("text.thread.add", &json!({"from": a, "to": on_path})).unwrap();
|
||||
assert_eq!([text(&s, a), text(&s, on_path)], ["a\n", ""]);
|
||||
s.execute("text.setFormat", &json!({"ids": [a], "start": 2, "end": 2, "leftIndent": 30})).unwrap();
|
||||
assert_eq!((left_indents(&s, a), left_indents(&s, on_path)), (vec![0.0, 30.0], vec![30.0]));
|
||||
|
||||
// Type on a path before area type.
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 800, "height": 300})).unwrap();
|
||||
let on_path = type_on_a_line(&mut s);
|
||||
s.execute("text.editRange", &json!({"id": on_path, "start": 0, "end": 0, "insert": "a\n"})).unwrap();
|
||||
let b = new_id(s.execute("shape.rectangle", &json!({"x": 400, "y": 10, "width": 150, "height": 60})).unwrap());
|
||||
s.execute("text.thread.add", &json!({"from": on_path, "to": b})).unwrap();
|
||||
assert_eq!([text(&s, on_path), text(&s, b)], ["a\n", ""]);
|
||||
s.execute("text.setFormat", &json!({"ids": [on_path], "start": 2, "end": 2, "leftIndent": 30})).unwrap();
|
||||
assert_eq!((left_indents(&s, on_path), left_indents(&s, b)), (vec![0.0, 30.0], vec![30.0]));
|
||||
}
|
||||
|
||||
/// A restyled empty last paragraph in a numbered list is counted once, and the next frame's
|
||||
/// first paragraph does not continue the paragraph before the break.
|
||||
#[test]
|
||||
fn a_restyled_empty_last_list_paragraph_keeps_the_counts() {
|
||||
let (mut s, ids) = tail_thread("One\nTwo\n", 60.0, 1);
|
||||
let (a, b) = (ids[0], ids[1]);
|
||||
s.execute("text.setFormat", &json!({"ids": [a, b], "list": "number"})).unwrap();
|
||||
s.execute("text.setFormat", &json!({"ids": [a], "start": 8, "end": 8, "leftIndent": 30})).unwrap();
|
||||
let doc = &s.doc().unwrap().doc;
|
||||
let frame = |id| match &doc.node(NodeId(id)).unwrap().kind {
|
||||
NodeKind::Text(t) => (**t).clone(),
|
||||
_ => panic!("not text"),
|
||||
};
|
||||
let (ta, tb) = (frame(a), frame(b));
|
||||
let shown = [ta.plain_text(), tb.plain_text()].concat();
|
||||
assert_eq!(shown, "One\nTwo\n");
|
||||
let split = ta.paragraph_count() - 1;
|
||||
assert_eq!(ta.para_at(split).left_indent, 30.0, "{:?}", [ta.plain_text(), tb.plain_text()]);
|
||||
assert_eq!(tb.para_at(0).left_indent, 30.0);
|
||||
assert!(tb.para_at(0).list.is_some());
|
||||
assert_eq!(tb.list_count as usize, split, "the paragraphs before the shared one are counted once");
|
||||
assert_eq!(tb.first_para_continues, !ta.plain_text().ends_with('\n'));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn thread_flows_reflows_on_edit_and_survives_delete() {
|
||||
let (mut s, a, b) = setup();
|
||||
|
||||
@@ -23,16 +23,26 @@ pub fn story(frames: &[&TextObject]) -> Vec<TextRun> {
|
||||
/// the earlier frame's part of the paragraph is empty (its slice ends with a paragraph break).
|
||||
/// An empty frame that was just restyled keeps its style against unedited empty frames after it.
|
||||
pub fn story_paras(frames: &[&TextObject], later_wins: &[bool]) -> Vec<ParaStyle> {
|
||||
story_paras_restyled(frames, later_wins, &[])
|
||||
}
|
||||
|
||||
/// [`story_paras`] that also knows which frames' last paragraph was just restyled
|
||||
/// (`earlier_wins[k]`, read for frames with text). Such a frame's last paragraph keeps its style
|
||||
/// against the unedited first paragraph of the frame after it, and of frames after unedited empty
|
||||
/// frames, even when the frame ends with a paragraph break. When the later frame's first paragraph
|
||||
/// was restyled too (`later_wins`), the later frame's style wins.
|
||||
pub fn story_paras_restyled(frames: &[&TextObject], later_wins: &[bool], earlier_wins: &[bool]) -> Vec<ParaStyle> {
|
||||
let mut out: Vec<ParaStyle> = vec![];
|
||||
let mut prev_empty_tail = true;
|
||||
let mut empty_tail_restyled = false;
|
||||
let mut tail_restyled = false;
|
||||
for (k, f) in frames.iter().enumerate() {
|
||||
let text = f.plain_text();
|
||||
let restyled = later_wins.get(k).copied().unwrap_or(false);
|
||||
let mut styles = f.paragraph_styles().into_iter();
|
||||
if k > 0
|
||||
&& let Some(first) = styles.next()
|
||||
&& (restyled || (prev_empty_tail && !(text.is_empty() && empty_tail_restyled)))
|
||||
&& (restyled || (prev_empty_tail && !tail_restyled && !(text.is_empty() && empty_tail_restyled)))
|
||||
&& let Some(last) = out.last_mut()
|
||||
{
|
||||
*last = first;
|
||||
@@ -43,6 +53,7 @@ pub fn story_paras(frames: &[&TextObject], later_wins: &[bool]) -> Vec<ParaStyle
|
||||
if !text.is_empty() {
|
||||
prev_empty_tail = text.ends_with('\n') || out.is_empty();
|
||||
empty_tail_restyled = false;
|
||||
tail_restyled = earlier_wins.get(k).copied().unwrap_or(false);
|
||||
} else if restyled {
|
||||
empty_tail_restyled = true;
|
||||
}
|
||||
|
||||
@@ -196,6 +196,42 @@ fn a_restyled_empty_frame_keeps_its_style_after_a_paragraph_break() {
|
||||
assert_eq!(tail_style(&[&first, &restyled, &right], &[false, true, true]), Some(Justify::Right));
|
||||
}
|
||||
|
||||
/// A frame whose empty last paragraph (after a final paragraph break) was just restyled keeps that
|
||||
/// style against the unedited first paragraph of the next frame, empty or with text, and across an
|
||||
/// unedited empty frame. A restyled first paragraph in the next frame still wins.
|
||||
#[test]
|
||||
fn restyled_empty_last_paragraph_wins_over_unedited_next_frame() {
|
||||
let style = |j| ParaStyle { justify: j, ..Default::default() };
|
||||
let mut first = frame(0.0);
|
||||
first.runs = vec![TextRun { text: "a\n".into(), style: CharStyle::default(), inline: None }];
|
||||
first.set_paragraph_styles(vec![style(Justify::Left), style(Justify::Center)]);
|
||||
let empty = frame(200.0);
|
||||
let mut with_text = frame(400.0);
|
||||
with_text.runs = vec![TextRun { text: "def".into(), style: CharStyle::default(), inline: None }];
|
||||
let justify = |frames: &[&TextObject], later: &[bool], earlier: &[bool]| {
|
||||
story_paras_restyled(frames, later, earlier).iter().map(|p| p.justify).collect::<Vec<_>>()
|
||||
};
|
||||
use Justify::*;
|
||||
|
||||
assert_eq!(justify(&[&first, &empty], &[false, false], &[true, false]), [Left, Center]);
|
||||
assert_eq!(justify(&[&first, &with_text], &[false, false], &[true, false]), [Left, Center]);
|
||||
assert_eq!(justify(&[&first, &empty, &with_text], &[false, false, false], &[true, false, false]), [Left, Center]);
|
||||
assert_eq!(justify(&[&first, &empty, &empty], &[false, false, false], &[true, false, false]), [Left, Center]);
|
||||
// Without the edit, the next frame gives the paragraph its style, as before.
|
||||
assert_eq!(justify(&[&first, &empty], &[false, false], &[false, false]), [Left, Left]);
|
||||
assert_eq!(story_paras(&[&first, &with_text], &[]).iter().map(|p| p.justify).collect::<Vec<_>>(), [Left, Left]);
|
||||
// A restyled first paragraph in the next frame wins over the restyled tail.
|
||||
let mut right = with_text.clone();
|
||||
right.set_all_paras(style(Right));
|
||||
assert_eq!(justify(&[&first, &right], &[false, true], &[true, true]), [Left, Right]);
|
||||
assert_eq!(justify(&[&first, &empty, &right], &[false, false, true], &[true, false, true]), [Left, Right]);
|
||||
// A middle frame is the later side at one boundary and the earlier side at the next.
|
||||
let mut middle = frame(200.0);
|
||||
middle.runs = vec![TextRun { text: "b\n".into(), style: CharStyle::default(), inline: None }];
|
||||
middle.set_paragraph_styles(vec![style(Right), style(JustifyAll)]);
|
||||
assert_eq!(justify(&[&first, &middle, &with_text], &[false, true, false], &[false, true, false]), [Left, Right, JustifyAll]);
|
||||
}
|
||||
|
||||
/// Numbered list paragraphs count on from one frame to the next: the second frame's first
|
||||
/// whole paragraph takes the next number. A paragraph split between the frames has its number
|
||||
/// in the first frame only, and the paragraph after it takes the next number.
|
||||
|
||||
@@ -1240,6 +1240,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
|
||||
Exports to PDF, EPS, SVG, EMF, WMF and the raster formats warn about the area type and type on a path that they draw without the text that doesn't fit, and give their ids, as Print and Print Preview do.
|
||||
An edit copies a thread's story only when it changes one of the thread's frames: with a story of 50,000 characters threaded through 8 frames with a style change at every word, moving another object took 7.8 ms and now takes 0.013 ms, and a frame of a drag took 7.2 ms and now takes 0.012 ms (release build, `threaded_story_edit_timing`).
|
||||
A paragraph that continues from one threaded frame into the next has its first-line indent and bullet or number in the frame where it starts; in the next frame its first line starts where its wrapped lines start. In frames that are no longer threaded, copies of a frame and point type converted from one, the first paragraph has its indent and bullet or number again.
|
||||
In threaded text, a paragraph format given to the empty paragraph after a frame's last paragraph break stays after the story flows again, in that frame and in the frames the paragraph continues into, unless the same edit also formats the next frame's first paragraph, whose format then applies (VectorCraft's own rule; `thread::tests::restyled_empty_last_paragraph_wins_over_unedited_next_frame`, `cmd::threads::tests::editing_a_frames_empty_last_paragraph_survives_thread_reflow`).
|
||||
Type › Convert To Point Type on a frame of threaded text makes point type of the text that frame holds and takes it out of the thread; the story flows through the other frames without that text, so each character is in the document once.
|
||||
Object › Rasterize on a frame of threaded text takes it out of the thread with the text it shows, which the image draws, and the other frames keep the text they hold. When the last frame is rasterized, the text it has no room for goes after the text of the last frame that stays.
|
||||
Type › Create Outlines, Object › Expand and Object › Expand Appearance on a frame of threaded text take it out of the thread with the text it shows, which the outlines draw, and the other frames keep the text they hold. When the last frame is outlined, the text it has no room for goes after the text of the last frame that stays. When Expand Appearance keeps the frame as type in a group with its shadow, outer glow or Crop Marks, the type stays in the thread. The public help on threads ([Create text threads between objects](https://helpx.adobe.com/illustrator/desktop/design-with-text/add-manage-text/create-text-threads-between-objects.html), [Remove or break text threads between objects](https://helpx.adobe.com/illustrator/desktop/design-with-text/add-manage-text/remove-or-break-text-threads-between-objects.html)) does not describe outlining a threaded frame; this handling is VectorCraft's own behavior.
|
||||
|
||||
+1
-1
@@ -25,7 +25,7 @@ against Illustrator's public documentation; **estimated** unless noted.
|
||||
| Native font-family labels | partial | With Show Font Names in English off, localized legacy names are used when typographic names contain no native label (#2208); loaded-font and catalog-only regressions. Choosing among multiple native languages is unchanged. |
|
||||
| Point, area and type-on-a-path type; vertical variants | done / partial | vertical point, area and path type have initial support; fullwidth `!` / `?` keep the nominal glyph upright when no vertical alternate exists (Unicode Tu); in a non-rectangular area a word too wide for the top lines moves down to the first line that holds it (#1615); Escape while typing ends the edit, keeps the type selected and switches back to the tool whose double-click on type chose the Type tool (kept until another tool is chosen or another document or window becomes active, so later edits with the Type tool return there too), or to the Selection tool when there is no such tool, as when the Type tool was chosen from the toolbar or after such a switch (#1802), and Cmd+Enter (Ctrl+Enter on Windows and Linux) does the same unless a command is bound to it; `text.create` and `text.createInPath` take `justify` (#1752); MCP `add_text` forwards `vertical` to both commands; Type › Type Orientation sets every frame of a thread, and a thread's frames share one writing direction; the Type and Vertical Type tools make area type when a closed path's outline is clicked and type on a path when an open one is, with pointers that say which, as the public help describes ([Create text](https://web.archive.org/web/20220127090642/https://helpx.adobe.com/illustrator/using/creating-text.html), [Create type on a path](https://web.archive.org/web/20220127090635/https://helpx.adobe.com/illustrator/using/creating-type-path.html)) |
|
||||
| Area Type Options (rows, columns, inset, first baseline, Text Flow by rows or by columns, Auto Size, Shrink Text to Fit) | done | Text Flow fills the cells down each column first (By Columns, the default, as files from before it open) or across each row first (By Rows), in the dialog and `text.areaOptions {flow}` (#4978); the commands that make or restyle type say when it does not fit its frame; MCP `add_text` takes Auto Size and Shrink Text to Fit (#1471) |
|
||||
| Threaded text, Fit Headline, Text Wrap | done | Fit Headline sees wrapping in a frame one line tall (#1444); a thread holds area type and type on a path in any mix, as Illustrator links type in an area or along a path ([help](https://helpx.adobe.com/illustrator/using/manage-text-area.html)) |
|
||||
| Threaded text, Fit Headline, Text Wrap | done | Fit Headline sees wrapping in a frame one line tall (#1444); a thread holds area type and type on a path in any mix, as Illustrator links type in an area or along a path ([help](https://helpx.adobe.com/illustrator/using/manage-text-area.html)); a frame's empty last paragraph keeps a format given to it when the story flows again |
|
||||
| Threading from the ports: selected area type and type on a path show an in port and an out port, empty, with an arrow on a side where the frame is threaded, or with a red "+" in the out port when the text doesn't fit; a click on either port loads the text; a click on a closed path, area type or type on a path threads into it, a click on empty canvas makes a frame of the same kind, shape and size there, a drag draws a rectangular frame; the frame placed comes after the clicked one from its out port and before it from its in port (the story then starts there); Escape or another tool cancels; a double-click on a threaded port breaks the thread there | partial | #2572: the Selection tool, `text.thread.add {from, to?, rect?, at?, before?}`, as the reference app's help describes ports and threading ([archived help, Threading text between objects](https://web.archive.org/web/20210119051048/https://helpx.adobe.com/illustrator/using/creating-text.html), [create text threads](https://helpx.adobe.com/illustrator/desktop/design-with-text/add-manage-text/create-text-threads-between-objects.html)); area type's in port sits on the left side below the top-left corner, type on a path's just before its start bracket; Show Text Threads draws its lines between the same ports; with more than 1,000 such objects selected no ports are drawn. a double-click on a port where the frame is threaded breaks the thread there, the story flowing into the frames before the break and the frames after it left empty, still threaded with each other when two or more (`text.thread.break`, [archived help](https://web.archive.org/web/20220127090642/https://helpx.adobe.com/illustrator/using/creating-text.html)). Remaining: the ports only take clicks with the Selection tool, and the loaded cursor borrows the I-beam until it's drawn |
|
||||
| Type on a Path effects and options; brackets | done | #429; Type on a Path Options has a Preview checkbox (#5360, as public tutorials of the dialog show); Direct Selection shows the anchors and direction handles of selected type on a path's path and drags both, the brackets keeping their fractions of the path and the text reflowing along it (#3980, #3565; `text.reshapePath`, `text.setPathHandle`), as the [Direct Selection page](https://helpx.adobe.com/illustrator/using/tool-techniques/direct-selection-tool.html) describes for a path; Type › Dynamic Text (`type.dynamicText`, also on the Contextual Task Bar) sizes type on a path to fill the span between its brackets and fits it again after each edit, as the [Dynamic Text overview](https://helpx.adobe.com/photoshop/desktop/text-typography/work-with-dynamic-text/dynamic-text-overview.html) describes; the 0.1 pt steps, the 0.1 to 1,296 pt range and keeping the drawn sizes when it is turned off or threaded are VectorCraft's own rules (`crates/text/src/tests/fit.rs`, `crates/engine/src/tests/dynamic_text.rs`) |
|
||||
| Character and Paragraph panels, styles with overrides | done | Character-style Auto-leading resets, partial definitions, local leading overrides, override markers, Undo/Redo and native serialization have regression coverage; differing paragraph alignments leave the shared alignment buttons unselected (headless regressions, full integration CI and visual review pass); mixed-value display for other paragraph fields remains missing. Double-clicking a user character style's row opens its Character Style Options, and the dialog keeps a fixed width while its sections scroll when the window is shorter, so its buttons stay on screen; a paragraph style's row opens its Paragraph Style Options, the rename dialog the panel menu opens. Evidence: `panels::text_styles::tests::double_clicking_a_character_style_row_opens_its_options`, `dialogs::char_style_options::tests::the_sections_scroll_in_a_window_shorter_than_the_general_list` |
|
||||
|
||||
Reference in New Issue
Block a user