mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-12 07:45:38 +00:00
Merge pull request #6719 from dotCooCoo/fix/tabs-indent-targets-story-order
Tabs: indent drags on threaded frames set each story paragraph once, a split paragraph included
This commit is contained in:
@@ -4,9 +4,11 @@
|
||||
//! same distance. A stop placed or dragged on the ruler lands on the nearest whole point. With Snap
|
||||
//! to Unit on, or Shift held, it lands on the ruler's nearest tick.
|
||||
|
||||
use std::collections::HashSet;
|
||||
|
||||
use egui::{Sense, Stroke, Ui, vec2};
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::{Node, NodeKind, ParaStyle, TabAlign, TabStop, TextKind};
|
||||
use vectorcraft_doc::{Document, Node, NodeId, NodeKind, ParaStyle, TabAlign, TabStop, TextKind, TextObject};
|
||||
|
||||
use super::{first_selected, pstate, set_pstate};
|
||||
use crate::VectorcraftApp;
|
||||
@@ -64,40 +66,95 @@ struct IndentTarget {
|
||||
level: f64,
|
||||
}
|
||||
|
||||
fn indent_target(id: NodeId, text: &TextObject, i: usize, start: usize) -> IndentTarget {
|
||||
let para = text.para_at(i);
|
||||
let left = para.laid_out_left_indent();
|
||||
IndentTarget { id: id.0, start, left, first_line: para.first_line_indent, level: left - para.left_indent }
|
||||
}
|
||||
|
||||
fn append_indent_targets(node: &Node, range: std::ops::Range<usize>, targets: &mut Vec<IndentTarget>) {
|
||||
let NodeKind::Text(text) = &node.kind else { return };
|
||||
let plain = text.plain_text();
|
||||
let mut starts = vec![0];
|
||||
starts.extend(plain.bytes().enumerate().filter_map(|(i, c)| (c == b'\n').then_some(i + 1)));
|
||||
targets.extend(range.filter_map(|i| {
|
||||
let para = text.para_at(i);
|
||||
let left = para.laid_out_left_indent();
|
||||
Some(IndentTarget { id: node.id.0, start: *starts.get(i)?, left, first_line: para.first_line_indent, level: left - para.left_indent })
|
||||
}));
|
||||
targets.extend(range.filter_map(|i| Some(indent_target(node.id, text, i, *starts.get(i)?))));
|
||||
}
|
||||
|
||||
/// Paragraphs touched by the Type selection (every paragraph of every frame after Select All in
|
||||
/// threaded text), or all paragraphs of selected text objects.
|
||||
/// One target for each paragraph of `story` (its frames in thread order), in story order. Where
|
||||
/// one frame ends and the next begins, the last paragraph of the one and the first of the next are
|
||||
/// pieces of one story paragraph: a paragraph that runs on into the next frame, or an empty piece
|
||||
/// after a frame's last paragraph break. Such a paragraph is targeted once, by its last piece with
|
||||
/// text in a frame that `selected` takes, the piece whose style the story keeps when it flows again.
|
||||
/// A paragraph whose text lies only in frames that `selected` leaves out is not targeted. An empty
|
||||
/// paragraph takes its style from its last piece when the story flows again, so it is targeted by
|
||||
/// that piece when `selected` takes its frame, and otherwise not.
|
||||
fn append_story_targets(doc: &Document, story: &[NodeId], selected: impl Fn(NodeId) -> bool, targets: &mut Vec<IndentTarget>) {
|
||||
/// The part of a story paragraph in one frame: paragraph `index` there, starting at byte `start`.
|
||||
struct Piece<'a> {
|
||||
id: NodeId,
|
||||
text: &'a TextObject,
|
||||
index: usize,
|
||||
start: usize,
|
||||
has_text: bool,
|
||||
}
|
||||
// Each story paragraph's pieces, in thread order.
|
||||
let mut paragraphs: Vec<Vec<Piece<'_>>> = vec![];
|
||||
for frame in story.iter().filter_map(|id| doc.node(*id)) {
|
||||
let NodeKind::Text(text) = &frame.kind else { continue };
|
||||
let plain = text.plain_text();
|
||||
let mut start = 0;
|
||||
for (index, part) in plain.split('\n').enumerate() {
|
||||
let piece = Piece { id: frame.id, text, index, start, has_text: !part.is_empty() };
|
||||
start += part.len() + 1;
|
||||
match paragraphs.last_mut() {
|
||||
Some(last) if index == 0 => last.push(piece),
|
||||
_ => paragraphs.push(vec![piece]),
|
||||
}
|
||||
}
|
||||
}
|
||||
for pieces in ¶graphs {
|
||||
let pick = if pieces.iter().any(|p| p.has_text) {
|
||||
pieces.iter().rev().find(|p| p.has_text && selected(p.id))
|
||||
} else {
|
||||
pieces.last().filter(|p| selected(p.id))
|
||||
};
|
||||
if let Some(p) = pick {
|
||||
targets.push(indent_target(p.id, p.text, p.index, p.start));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Paragraphs touched by the Type selection (every paragraph of the story after Select All in
|
||||
/// threaded text), or all paragraphs of selected text objects. Each paragraph of a threaded story
|
||||
/// is listed once, in story order ([`append_story_targets`]).
|
||||
fn indent_targets(app: &VectorcraftApp, node: &Node) -> Vec<IndentTarget> {
|
||||
let mut targets = Vec::new();
|
||||
if let Some((_, a, b)) = super::character::text_editing(app) {
|
||||
if let (Some(story), Some(st)) = (super::character::text_story(app), app.session.active()) {
|
||||
for frame in story.iter().filter_map(|id| st.doc.node(*id)) {
|
||||
if let NodeKind::Text(text) = &frame.kind {
|
||||
append_indent_targets(frame, 0..text.paragraph_count(), &mut targets);
|
||||
}
|
||||
}
|
||||
append_story_targets(&st.doc, &story, |_| true, &mut targets);
|
||||
} else if let NodeKind::Text(text) = &node.kind {
|
||||
append_indent_targets(node, text.paragraphs_in(a, b), &mut targets);
|
||||
}
|
||||
} else if let Some(st) = app.session.active() {
|
||||
// The selected text objects and the text in selected groups, each once.
|
||||
let mut frames = Vec::new();
|
||||
let mut taken = HashSet::new();
|
||||
for selected in st.selection.objects.iter().filter_map(|id| st.doc.node(*id)) {
|
||||
selected.walk(&mut |child| {
|
||||
if let NodeKind::Text(text) = &child.kind {
|
||||
append_indent_targets(child, 0..text.paragraph_count(), &mut targets);
|
||||
if matches!(child.kind, NodeKind::Text(_)) && taken.insert(child.id) {
|
||||
frames.push(child.id);
|
||||
}
|
||||
});
|
||||
}
|
||||
let mut done = HashSet::new();
|
||||
for id in frames {
|
||||
if done.contains(&id) {
|
||||
continue;
|
||||
}
|
||||
let story = st.doc.story_of(id);
|
||||
done.extend(story.iter().copied());
|
||||
append_story_targets(&st.doc, &story, |f| taken.contains(&f), &mut targets);
|
||||
}
|
||||
}
|
||||
targets
|
||||
}
|
||||
|
||||
@@ -538,6 +538,139 @@ fn indent_markers_after_select_all_follow_text_that_reflows_into_the_next_frame(
|
||||
}
|
||||
}
|
||||
|
||||
/// A story of twelve paragraphs that wrap, threaded through three frames: the first two frames each
|
||||
/// end inside a paragraph, which runs on into the next frame. → the app and the frames in thread
|
||||
/// order.
|
||||
fn split_story_app() -> (VectorcraftApp, [u64; 3]) {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.run("file.new", json!({"width": 900, "height": 1000})).unwrap();
|
||||
// Each paragraph takes more lines than a short frame holds.
|
||||
let words = "aaa bbb ccc ddd eee fff ggg hhh iii jjj ".repeat(3);
|
||||
let story = (1..=12).map(|i| format!("p{i} {}", words.trim_end())).collect::<Vec<_>>().join("\n");
|
||||
let a = app.run("text.create", json!({"x": 10, "y": 10, "text": story, "size": 12, "area": {"width": 150, "height": 50}})).unwrap()["id"]
|
||||
.as_u64()
|
||||
.unwrap();
|
||||
let b = app.run("text.thread.add", json!({"from": a, "rect": [300, 10, 150, 50]})).unwrap()["id"].as_u64().unwrap();
|
||||
let c = app.run("text.thread.add", json!({"from": b, "rect": [600, 10, 250, 950]})).unwrap()["id"].as_u64().unwrap();
|
||||
for id in [b, c] {
|
||||
assert!(text_of(&app, id).first_para_continues, "frame {id} starts inside a paragraph: {:?}", text_of(&app, id).plain_text());
|
||||
}
|
||||
(app, [a, b, c])
|
||||
}
|
||||
|
||||
fn text_of(app: &VectorcraftApp, id: u64) -> vectorcraft_doc::TextObject {
|
||||
match &app.session.active().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap().kind {
|
||||
NodeKind::Text(t) => (**t).clone(),
|
||||
_ => panic!("not text"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Each paragraph of the story of `frames` by its first word, with its left indent and first-line
|
||||
/// position, counted once; each piece of a paragraph split between frames has the same values.
|
||||
fn story_paragraphs(app: &VectorcraftApp, frames: &[u64]) -> Vec<(String, f64, f64)> {
|
||||
let mut out: Vec<(String, f64, f64)> = vec![];
|
||||
for (k, id) in frames.iter().enumerate() {
|
||||
let t = text_of(app, *id);
|
||||
for (i, text) in t.plain_text().split('\n').enumerate() {
|
||||
let p = t.para_at(i);
|
||||
let values = (p.left_indent, p.left_indent + p.first_line_indent);
|
||||
if i == 0 && k > 0 && t.first_para_continues {
|
||||
let last = out.last().unwrap();
|
||||
assert_eq!((last.1, last.2), values, "both pieces of {} agree", last.0);
|
||||
} else if !text.is_empty() {
|
||||
out.push((text.split(' ').next().unwrap_or_default().to_string(), values.0, values.1));
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Where each target is in the story: its frame's place in `frames` and its start there.
|
||||
fn story_positions(targets: &[IndentTarget], frames: &[u64]) -> Vec<(usize, usize)> {
|
||||
targets.iter().map(|p| (frames.iter().position(|f| *f == p.id).unwrap(), p.start)).collect()
|
||||
}
|
||||
|
||||
/// With the Selection tool on every frame of a threaded story, selected last frame first, the
|
||||
/// indent markers list each paragraph of the story once, in the story's order, and a marker drag
|
||||
/// over mixed indents sets every paragraph in one undo step, the two split paragraphs included.
|
||||
#[test]
|
||||
fn indent_markers_on_selected_threaded_frames_follow_the_story_and_set_a_split_paragraph_once() {
|
||||
let (mut app, [a, b, c]) = split_story_app();
|
||||
app.run("text.setFormat", json!({"ids": [c], "leftIndent": 10})).unwrap();
|
||||
app.run("select.set", json!({"ids": [c, b, a]})).unwrap();
|
||||
assert_eq!(app.session.active().unwrap().selection.objects.iter().map(|id| id.0).collect::<Vec<_>>(), vec![c, b, a]);
|
||||
let before = story_paragraphs(&app, &[a, b, c]);
|
||||
assert_eq!(before.len(), 12, "{before:?}");
|
||||
assert!(before.iter().any(|p| p.1 == 0.0) && before.iter().any(|p| p.1 == 10.0), "{before:?}");
|
||||
let (node, para, span) = current_para(&app).unwrap();
|
||||
let targets = indent_targets(&app, &node);
|
||||
let positions = story_positions(&targets, &[a, b, c]);
|
||||
assert_eq!(positions.len(), 12, "one target for each paragraph of the story: {positions:?}");
|
||||
assert!(positions.windows(2).all(|w| w[0] < w[1]), "the targets follow the story: {positions:?}");
|
||||
let undo = app.session.active().unwrap().history.undo.len();
|
||||
set_marker(&mut app, IndentMarker::Upper, 60.0, ¶, span, &targets, false);
|
||||
assert_eq!(app.session.active().unwrap().history.undo.len(), undo + 1);
|
||||
let after = story_paragraphs(&app, &[a, b, c]);
|
||||
assert_eq!(after.iter().map(|p| &p.0).collect::<Vec<_>>(), before.iter().map(|p| &p.0).collect::<Vec<_>>());
|
||||
for (was, now) in before.iter().zip(&after) {
|
||||
assert_eq!((now.1, now.2), (was.1, 60.0), "{} keeps its left indent and starts at 60 pt: {after:?}", now.0);
|
||||
}
|
||||
}
|
||||
|
||||
/// After Select All in threaded text, a paragraph split between two frames is one target.
|
||||
#[test]
|
||||
fn indent_markers_after_select_all_list_a_split_paragraph_once() {
|
||||
let (mut app, [a, b, c]) = split_story_app();
|
||||
app.run("select.set", json!({"ids": [a]})).unwrap();
|
||||
app.session.select_tool("type", Default::default()).unwrap();
|
||||
app.session.set_tool_option("textSelection", &json!({"editing": a, "anchor": 0, "caret": 0}));
|
||||
app.run("select.all", json!({})).unwrap();
|
||||
let ids = [a, b, c].map(vectorcraft_doc::NodeId);
|
||||
assert_eq!(super::super::character::text_story(&app), Some(ids.to_vec()));
|
||||
let (node, _, _) = current_para(&app).unwrap();
|
||||
let positions = story_positions(&indent_targets(&app, &node), &[a, b, c]);
|
||||
assert_eq!(positions.len(), 12, "{positions:?}");
|
||||
assert!(positions.windows(2).all(|w| w[0] < w[1]), "{positions:?}");
|
||||
}
|
||||
|
||||
/// With the Selection tool on some frames of a threaded story, only the paragraphs with text in
|
||||
/// those frames are listed, each once: a selected frame that starts inside a paragraph lists that
|
||||
/// paragraph, and so does a selected frame that ends inside one. Text in a selected group is
|
||||
/// listed with the story's frames, and an empty text object keeps its one paragraph.
|
||||
#[test]
|
||||
fn indent_markers_on_some_threaded_frames_list_their_paragraphs_once() {
|
||||
let (mut app, [a, b, c]) = split_story_app();
|
||||
let count = |app: &VectorcraftApp, id: u64| text_of(app, id).paragraph_count();
|
||||
let targets_of = |app: &mut VectorcraftApp, ids: Vec<u64>| {
|
||||
app.run("select.set", json!({"ids": ids})).unwrap();
|
||||
let (node, _, _) = current_para(app).unwrap();
|
||||
story_positions(&indent_targets(app, &node), &[a, b, c])
|
||||
};
|
||||
// The continuing frame alone: its first paragraph is the one the frame before ends inside.
|
||||
let only_b = targets_of(&mut app, vec![b]);
|
||||
assert_eq!(only_b.len(), count(&app, b), "{only_b:?}");
|
||||
assert_eq!(only_b.first(), Some(&(1, 0)), "{only_b:?}");
|
||||
let only_a = targets_of(&mut app, vec![a]);
|
||||
assert_eq!(only_a.len(), count(&app, a), "{only_a:?}");
|
||||
// Two frames that share a paragraph list it once, by the later frame's piece.
|
||||
let both = targets_of(&mut app, vec![c, a, b]);
|
||||
assert_eq!(both.len(), count(&app, a) + count(&app, b) + count(&app, c) - 2, "{both:?}");
|
||||
assert!(both.windows(2).all(|w| w[0] < w[1]), "{both:?}");
|
||||
// The first and last frames of the story, and a grouped empty text object besides.
|
||||
let empty = app.run("text.create", json!({"x": 10, "y": 900, "text": "", "area": {"width": 100, "height": 40}})).unwrap()["id"].as_u64().unwrap();
|
||||
app.run("select.set", json!({"ids": [empty, c]})).unwrap();
|
||||
let group = app.run("object.group", json!({})).unwrap()["id"].as_u64().unwrap();
|
||||
app.run("select.set", json!({"ids": [group, a]})).unwrap();
|
||||
let node = app.session.active().unwrap().doc.node(vectorcraft_doc::NodeId(a)).unwrap().clone();
|
||||
let targets = indent_targets(&app, &node);
|
||||
let ids: Vec<u64> = targets.iter().map(|p| p.id).collect();
|
||||
assert_eq!(ids.iter().filter(|id| **id == empty).count(), 1, "{ids:?}");
|
||||
let story: Vec<IndentTarget> = targets.into_iter().filter(|p| p.id != empty).collect();
|
||||
let positions = story_positions(&story, &[a, b, c]);
|
||||
assert_eq!(positions.len(), count(&app, a) + count(&app, c), "{positions:?}");
|
||||
assert!(positions.windows(2).all(|w| w[0] < w[1]), "{positions:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lower_marker_bounds_cover_each_touched_first_line() {
|
||||
let targets = [
|
||||
|
||||
@@ -642,6 +642,7 @@ since 2026-10-01.
|
||||
- **Names of new styles and symbols:** `charStyle.new` and `paraStyle.new` trim the white space around the name they are given, as `charStyle.rename` and `paraStyle.rename` do, and refuse a name that is then empty; Duplicate names its copy from the trimmed name. `symbol.new` and `symbol.duplicate` trim the name as Symbol Options does, and `library.use` adds a library's text style under its trimmed name. Public reference: [Character and paragraph styles](https://web.archive.org/web/20260825113735/https://helpx.adobe.com/illustrator/using/character-paragraph-styles.html).
|
||||
- **Naming a new text style first:** Alt-click on New in the Character Styles or Paragraph Styles panel opens New Character Style or New Paragraph Style (`ui.newCharStyle`, `ui.newParaStyle`) with the name a plain click's style gets; OK makes the style from the text selected when the dialog opened, and Cancel adds none. Tests `panels::text_styles::tests::alt_clicking_new_character_style_asks_for_its_name` and `alt_clicking_new_paragraph_style_asks_for_its_name`. Public reference: [Default keyboard shortcuts](https://web.archive.org/web/20260726060015/https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html), which lists Alt-click for opening panel options and names Actions, Brushes, Swatches and Symbols as exceptions.
|
||||
- **Moving all tab stops:** in the Tabs panel, a stop dragged on the ruler with Ctrl held (Command on macOS) moves every stop by the same distance, in one `text.tabs.set` and one undo step; the stops keep their order, alignment and leaders, the ruler shows them moving during the drag, and a drag that would take the leftmost stop below 0 leaves it at 0, or the rightmost past 100,000 pt leaves it at 100,000 pt, with the other stops at their spacing from it. A drag that leaves every stop where it was records no undo step. Ctrl must be held when the drag starts. Tests `panels::tabs::tests::command_drag_moves_all_the_stops_together`, `command_drag_stops_at_zero_and_keeps_the_spacing`, `command_drag_stops_at_the_largest_position_and_keeps_the_spacing` and `a_drag_that_moves_no_stop_records_no_undo_step`. Public reference: [Manage tab stops](https://helpx.adobe.com/illustrator/desktop/measure-and-align/grids-and-guides/manage-tab-stops.html), which says that a Ctrl-drag (Windows) or Command-drag (macOS) of a tab moves all the tab stops at the same time.
|
||||
- **Tabs indent markers on threaded frames:** when a marker drag gives the paragraphs of a threaded story values of their own, the Tabs panel sends one `text.setFormat` for each paragraph of the story, from its last paragraph to its first, whatever order the frames were selected in with the Selection tool; a paragraph that runs on from one frame into the next gets one step, after Type tool Select All too. With the Selection tool only the paragraphs with text in a selected frame change, so a frame selected alone that starts or ends inside a paragraph sets that paragraph. Tests `panels::tabs::tests::indent_markers_on_selected_threaded_frames_follow_the_story_and_set_a_split_paragraph_once`, `indent_markers_after_select_all_list_a_split_paragraph_once` and `indent_markers_on_some_threaded_frames_list_their_paragraphs_once`. Public reference: [Indent text](https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/indent-text.html).
|
||||
- **Width Point Edit:** Shift-double-click enables Adjust Adjoining Width Points; plain double-click and direct opening retain the default. Tool dispatch and headless dialog tests cover neighboring widths, Undo/Redo and Cancel. Public reference: [Variable-width strokes](https://helpx.adobe.com/illustrator/using/stroke-object.html).
|
||||
Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a feature lands.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user