mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-28 11:47:21 -06:00
Type: typing "1. ", "A) " or "* " at the start of a paragraph starts a list
The reference app detects lists typed as 1., 1), A. or A), and makes custom bullets from *, #, > or + followed by a space. In VectorCraft the characters stayed as text. When the Type tool types a space on its own after one of these at the start of a paragraph in no list, the typing so far becomes one undo step. The new command text.startList then removes the typed characters and the space and makes the paragraph a numbered list in 1, 2, 3 or A, B, C, or a bulleted list with the filled dot. A paragraph with no indents gets the 18 pt hanging indent. One Undo puts the characters back as text with no list, and the caret goes on after them; Redo removes them again. The same characters later in a paragraph, list characters pasted in one piece with the space and a paragraph already in a list start nothing. Docs: docs/mcp.md, docs/target-app-parity.md.
This commit is contained in:
@@ -22,6 +22,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
has_doc,
|
||||
edit_range
|
||||
),
|
||||
cmd!(
|
||||
"text.startList",
|
||||
"Bullets and Numbering",
|
||||
[],
|
||||
None,
|
||||
"{id, at?: byte (default: the end of the text)} start a list from typed characters, what the Type tool runs when a space is typed on its own: when the text from the start of the paragraph that `at` is in up to `at` is \"1. \" or \"1) \" (a numbered list in 1, 2, 3), \"A. \" or \"A) \" (a numbered list in A, B, C), or \"* \", \"# \", \"> \" or \"+ \" (a bulleted list with the filled dot), and the paragraph is in no list, those characters are removed and the paragraph joins that list as one undo step (left and first-line indents of 0 become 18 and -18, as text.setFormat sets them); otherwise nothing changes → {id, started: bool, caret: byte (the paragraph's start when started, else `at`)}",
|
||||
has_doc,
|
||||
start_list
|
||||
),
|
||||
cmd!(
|
||||
"text.setRangeStyle",
|
||||
"Character",
|
||||
@@ -220,6 +229,36 @@ fn edit_range(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({"id": id.0, "caret": caret}))
|
||||
}
|
||||
|
||||
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`"))?;
|
||||
check_numbers(p, &["at"], C)?;
|
||||
let t = text_ref(s, id)?;
|
||||
let len = edit::runs_len(&t.runs);
|
||||
let at = byte_param(p, "at").unwrap_or(len).min(len);
|
||||
let Some((start, list)) = t.typed_list_at(at) else {
|
||||
return Ok(json!({"id": id.0, "started": false, "caret": at}));
|
||||
};
|
||||
s.edit("Bullets and Numbering", |d, _| {
|
||||
let t = text_mut(d, id).ok_or(EngineError::NoNode(id))?;
|
||||
t.splice_paras(start, at, "");
|
||||
edit::replace_range(&mut t.runs, start, at, "");
|
||||
t.ruby_after_replace(start, at, 0);
|
||||
let i = t.paragraphs_in(start, start).start;
|
||||
t.edit_paras(Some(i..i + 1), |para| {
|
||||
para.list = Some(list);
|
||||
// A paragraph with no left or first-line indent gets the hanging indent.
|
||||
if para.left_indent == 0.0 && para.first_line_indent == 0.0 {
|
||||
para.left_indent = vectorcraft_doc::text::LIST_HANGING_INDENT;
|
||||
para.first_line_indent = -vectorcraft_doc::text::LIST_HANGING_INDENT;
|
||||
}
|
||||
});
|
||||
refresh_bounds(t);
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(json!({"id": id.0, "started": true, "caret": start}))
|
||||
}
|
||||
|
||||
fn get_range(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let id = id_param(p, "id").ok_or_else(|| bad("text.getRange", "missing `id`"))?;
|
||||
let t = text_ref(s, id)?;
|
||||
|
||||
@@ -545,6 +545,164 @@ fn undo_while_typing_takes_back_only_the_typing_and_redo_returns_it() {
|
||||
assert_eq!(obj(&s, id).plain_text(), "abc!");
|
||||
}
|
||||
|
||||
fn list_markers(s: &Session, id: NodeId) -> Vec<String> {
|
||||
vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &obj(s, id)).markers.iter().map(|m| m.text.clone()).collect()
|
||||
}
|
||||
|
||||
/// Typed list detection: at the start of a paragraph, "1.", "1)", "A." or "A)" and a space start
|
||||
/// a numbered list, and "*", "#", ">" or "+" and a space start a bulleted list with the filled dot.
|
||||
/// The typed characters go, the paragraph takes the hanging indent, and the text typed next is the
|
||||
/// list item's. One Undo puts the typed characters back with no list, and typing goes on after
|
||||
/// them, also after a Backspace that deleted nothing.
|
||||
#[test]
|
||||
fn typing_a_list_start_and_a_space_starts_a_list() {
|
||||
use vectorcraft_doc::text::{Bullet, NumberStyle, ParaList};
|
||||
let v = ViewInfo::default();
|
||||
let (arabic, letters, dot) =
|
||||
(ParaList::numbered(NumberStyle::Arabic), ParaList::numbered(NumberStyle::UpperLetters), ParaList::bulleted(Bullet::Dot));
|
||||
let cases = [("1.", arabic, "1."), ("1)", arabic, "1."), ("A.", letters, "A."), ("A)", letters, "A.")];
|
||||
let bullets = ["*", "#", ">", "+"].map(|b| (b, dot, "\u{2022}"));
|
||||
for (typed, list, marker) in cases.into_iter().chain(bullets) {
|
||||
let mut s = session();
|
||||
s.select_tool("type", v).unwrap();
|
||||
click(&mut s, 200.0, 200.0);
|
||||
let id = s.doc().unwrap().selection.objects[0];
|
||||
for c in typed.chars() {
|
||||
s.tool_text(&c.to_string(), v).unwrap();
|
||||
}
|
||||
s.tool_text(" ", v).unwrap();
|
||||
let t = obj(&s, id);
|
||||
assert_eq!((t.plain_text().as_str(), t.para_at(0).list), ("", Some(list)), "{typed}");
|
||||
assert_eq!((t.para_at(0).left_indent, t.para_at(0).first_line_indent), (18.0, -18.0), "{typed}");
|
||||
assert_eq!(undo_labels(&s).last().map(String::as_str), Some("Bullets and Numbering"), "{typed}");
|
||||
s.tool_text("Apples", v).unwrap();
|
||||
assert_eq!((obj(&s, id).plain_text().as_str(), obj(&s, id).para_at(0).list), ("Apples", Some(list)), "{typed}");
|
||||
assert_eq!(list_markers(&s, id), [marker], "{typed}");
|
||||
key(&mut s, ToolKey::Enter, Mods::default());
|
||||
s.tool_text("Pears", v).unwrap();
|
||||
assert_eq!(obj(&s, id).para_at(1).list, Some(list), "Return continues the list: {typed}");
|
||||
// One Undo after the space puts the characters back with no list; typing goes on after them.
|
||||
let mut s = session();
|
||||
s.select_tool("type", v).unwrap();
|
||||
click(&mut s, 200.0, 200.0);
|
||||
let id = s.doc().unwrap().selection.objects[0];
|
||||
for c in typed.chars() {
|
||||
s.tool_text(&c.to_string(), v).unwrap();
|
||||
}
|
||||
s.tool_text(" ", v).unwrap();
|
||||
// A Backspace at the start of the empty item deletes nothing and leaves that as it is.
|
||||
key(&mut s, ToolKey::Backspace, Mods::default());
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
let t = obj(&s, id);
|
||||
assert_eq!((t.plain_text(), t.para_at(0).list), (format!("{typed} "), None), "{typed}");
|
||||
assert_eq!((t.para_at(0).left_indent, t.para_at(0).first_line_indent), (0.0, 0.0), "{typed}");
|
||||
s.tool_text("x", v).unwrap();
|
||||
assert_eq!((obj(&s, id).plain_text(), obj(&s, id).para_at(0).list), (format!("{typed} x"), None), "{typed}");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(obj(&s, id).plain_text(), "", "the typing before the list is a step of its own: {typed}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A list typed before a paragraph's text: the caret stays before the text, goes after the typed
|
||||
/// characters when Undo puts them back, also after undoing what was typed after the list, and
|
||||
/// before the text again after Redo, also when the text starts with the same characters.
|
||||
#[test]
|
||||
fn the_caret_follows_undo_and_redo_of_a_typed_list() {
|
||||
let v = ViewInfo::default();
|
||||
for start in ["Apples", "* Apples"] {
|
||||
let mut s = session();
|
||||
let id = text(&mut s, start);
|
||||
s.select_tool("type", v).unwrap();
|
||||
click(&mut s, 101.0, 95.0);
|
||||
s.set_tool_option("select", &json!({"start": 0, "end": 0}));
|
||||
for c in "* ".chars() {
|
||||
s.tool_text(&c.to_string(), v).unwrap();
|
||||
}
|
||||
let state = |s: &Session| (obj(s, id).plain_text(), s.tool_options()["caret"].clone());
|
||||
assert_eq!(state(&s), (start.to_string(), json!(0)));
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(state(&s), (format!("* {start}"), json!(2)));
|
||||
s.execute("edit.redo", &json!({})).unwrap();
|
||||
assert_eq!(state(&s), (start.to_string(), json!(0)));
|
||||
s.tool_text("x", v).unwrap();
|
||||
assert_eq!(obj(&s, id).plain_text(), format!("x{start}"));
|
||||
// Undo of the typing and then of the list: typing goes on after the characters again.
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
s.tool_text("y", v).unwrap();
|
||||
assert_eq!(obj(&s, id).plain_text(), format!("* y{start}"));
|
||||
}
|
||||
}
|
||||
|
||||
/// The same characters later in a paragraph, without the space, typed in one piece with the space
|
||||
/// (a paste), or at the start of a paragraph that is already in a list start nothing.
|
||||
#[test]
|
||||
fn list_starts_typed_elsewhere_or_pasted_start_no_list() {
|
||||
use vectorcraft_doc::text::{NumberStyle, ParaList};
|
||||
let v = ViewInfo::default();
|
||||
let mut s = session();
|
||||
s.select_tool("type", v).unwrap();
|
||||
click(&mut s, 200.0, 200.0);
|
||||
let id = s.doc().unwrap().selection.objects[0];
|
||||
for c in "Fruit 1. * A) x".chars() {
|
||||
s.tool_text(&c.to_string(), v).unwrap();
|
||||
}
|
||||
key(&mut s, ToolKey::Enter, Mods::default());
|
||||
s.tool_text("1. Apples", v).unwrap();
|
||||
key(&mut s, ToolKey::Enter, Mods::default());
|
||||
for c in "1.Pears".chars() {
|
||||
s.tool_text(&c.to_string(), v).unwrap();
|
||||
}
|
||||
key(&mut s, ToolKey::Enter, Mods::default());
|
||||
s.tool_text("* ", v).unwrap();
|
||||
let t = obj(&s, id);
|
||||
assert_eq!(t.plain_text(), "Fruit 1. * A) x\n1. Apples\n1.Pears\n* ");
|
||||
assert_eq!((0..4).map(|i| t.para_at(i).list).collect::<Vec<_>>(), [None, None, None, None]);
|
||||
assert!(!undo_labels(&s).iter().any(|l| l == "Bullets and Numbering"));
|
||||
// A paragraph in a list keeps its list, and the typed characters stay as text.
|
||||
let id = text(&mut s, "Item");
|
||||
s.execute("text.setFormat", &json!({"ids": [id.0], "list": "number"})).unwrap();
|
||||
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
|
||||
click(&mut s, 101.0, 95.0);
|
||||
s.set_tool_option("select", &json!({"start": 0, "end": 0}));
|
||||
for c in "* ".chars() {
|
||||
s.tool_text(&c.to_string(), v).unwrap();
|
||||
}
|
||||
let t = obj(&s, id);
|
||||
assert_eq!((t.plain_text().as_str(), t.para_at(0).list), ("* Item", Some(ParaList::numbered(NumberStyle::Arabic))));
|
||||
}
|
||||
|
||||
/// `text.startList`, which the Type tool runs, works from scripts too: by default it reads the
|
||||
/// text up to the end, and it changes nothing (and records no step) when the text there starts no
|
||||
/// list.
|
||||
#[test]
|
||||
fn start_list_command_removes_the_typed_characters_and_starts_the_list() {
|
||||
use vectorcraft_doc::text::{Bullet, NumberStyle, ParaList};
|
||||
let mut s = session();
|
||||
let id = text(&mut s, "Fruit\n1) Apples");
|
||||
let r = s.execute("text.startList", &json!({"id": id.0, "at": 9})).unwrap();
|
||||
assert_eq!(r, json!({"id": id.0, "started": true, "caret": 6}));
|
||||
let t = obj(&s, id);
|
||||
assert_eq!(
|
||||
(t.plain_text().as_str(), t.para_at(0).list, t.para_at(1).list),
|
||||
("Fruit\nApples", None, Some(ParaList::numbered(NumberStyle::Arabic)))
|
||||
);
|
||||
assert_eq!(list_markers(&s, id), ["1."]);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!((obj(&s, id).plain_text().as_str(), obj(&s, id).para_at(1).list), ("Fruit\n1) Apples", None));
|
||||
let id = text(&mut s, "x\n# ");
|
||||
let r = s.execute("text.startList", &json!({"id": id.0})).unwrap();
|
||||
assert_eq!((r["started"].clone(), obj(&s, id).plain_text()), (json!(true), "x\n".to_string()));
|
||||
assert_eq!(obj(&s, id).para_at(1).list, Some(ParaList::bulleted(Bullet::Dot)));
|
||||
let steps = undo_labels(&s).len();
|
||||
let id = text(&mut s, "1.x");
|
||||
assert_eq!(s.execute("text.startList", &json!({"id": id.0, "at": 3})).unwrap()["started"], json!(false));
|
||||
assert_eq!(undo_labels(&s).len(), steps + 1, "only text.create's step");
|
||||
assert!(s.execute("text.startList", &json!({"id": id.0, "at": "3"})).is_err(), "`at` is a number");
|
||||
assert!(s.execute("text.startList", &json!({"at": 3})).is_err(), "id required");
|
||||
}
|
||||
|
||||
/// #1881: a Cmd/Ctrl drag while typing (the Selection tool lent to the Type tool) moves the type
|
||||
/// and keeps what was typed, and typing goes on after it.
|
||||
#[test]
|
||||
|
||||
Reference in New Issue
Block a user