Merge fix/695

This commit is contained in:
bflatastic
2026-10-17 14:35:14 +07:00
12 changed files with 228 additions and 26 deletions
+44 -8
View File
@@ -215,6 +215,10 @@ pub struct ParaList {
pub list: ListStyle,
/// Nesting level (0 = outermost; plain paragraphs are always 0).
pub indent: u8,
/// A numbered list that starts counting at this paragraph counts from `start_offset + 1`
/// (items that go on counting ignore it; other paragraphs are always 0).
#[serde(skip_serializing_if = "crate::node::is_default")]
pub start_offset: u32,
}
/// Character style. In runs, every field is an override; `None` means "inherit the base".
@@ -637,13 +641,13 @@ impl TextData {
/// Paragraph `p`'s list settings.
pub fn para_list(&self, p: usize) -> ParaList {
self.paragraphs.get(p).copied().unwrap_or(ParaList { list: self.list, indent: 0 })
self.paragraphs.get(p).copied().unwrap_or(ParaList { list: self.list, ..ParaList::default() })
}
/// Spell out every paragraph's list settings in `paragraphs` (and drop extra entries).
fn fill_paragraphs(&mut self) {
let n = self.paragraph_count();
let fill = ParaList { list: self.list, indent: 0 };
let fill = ParaList { list: self.list, ..ParaList::default() };
self.paragraphs.resize(n, fill);
}
@@ -657,17 +661,23 @@ impl TextData {
}
/// Apply `f` to the list settings of the paragraphs chars `start..end` touch (plain
/// paragraphs stay at level 0, levels stop at [`MAX_LIST_INDENT`]); one style for all with
/// no nesting folds back into `list`.
/// paragraphs stay at level 0, only numbered ones keep a start, levels stop at
/// [`MAX_LIST_INDENT`]); one style for all with no nesting or start folds back into `list`.
pub fn set_para_lists(&mut self, start: usize, end: usize, f: impl Fn(&mut ParaList)) {
let (a, b) = self.para_span(start, end);
self.fill_paragraphs();
for pl in self.paragraphs.get_mut(a..=b).unwrap_or(&mut []) {
f(pl);
pl.indent = if pl.list == ListStyle::None { 0 } else { pl.indent.min(MAX_LIST_INDENT) };
if pl.list == ListStyle::None {
pl.indent = 0;
}
if pl.list != ListStyle::Numbered {
pl.start_offset = 0;
}
pl.indent = pl.indent.min(MAX_LIST_INDENT);
}
if let Some(first) = self.paragraphs.first().copied()
&& first.indent == 0
&& first == (ParaList { list: first.list, ..ParaList::default() })
&& self.paragraphs.iter().all(|p| *p == first)
{
self.list = first.list;
@@ -675,11 +685,12 @@ impl TextData {
}
}
/// The list settings shared by the paragraphs chars `start..end` touch, or None when they differ.
/// The list style and level shared by the paragraphs chars `start..end` touch (the first
/// one's settings), or None when they differ. Start numbers don't count.
pub fn common_para_list(&self, start: usize, end: usize) -> Option<ParaList> {
let (a, b) = self.para_span(start, end);
let first = self.para_list(a);
(a..=b).all(|p| self.para_list(p) == first).then_some(first)
(a..=b).all(|p| self.para_list(p).list == first.list && self.para_list(p).indent == first.indent).then_some(first)
}
/// Link chars `start..end` (all of them when the range is empty or the whole text) to text
@@ -782,4 +793,29 @@ mod case_tests {
assert_eq!(st.variations.keys().collect::<Vec<_>>(), ["wdth"]);
assert!(valid_axis_tag("wght") && !valid_axis_tag("weight"));
}
#[test]
fn list_starts_load_from_old_files_and_follow_edits() {
use ListStyle::{Bulleted as B, None as P, Numbered as N};
// Files written before start numbers load with none, and write the same bytes.
let old = r#"{"list":"numbered","indent":1}"#;
let pl: ParaList = serde_json::from_str(old).unwrap();
assert_eq!(pl, ParaList { list: N, indent: 1, start_offset: 0 });
assert_eq!(serde_json::to_string(&pl).unwrap(), old);
// One numbered paragraph with a start keeps it (it doesn't fold into `list`).
let mut t = TextData { characters: "Next".into(), ..Default::default() };
t.set_para_lists(0, 0, |p| *p = ParaList { list: N, indent: 0, start_offset: 1 });
assert_eq!(t.para_list(0), ParaList { list: N, indent: 0, start_offset: 1 });
// Enter copies it to the new item; different starts don't make the list mixed.
t.replace_range(4, 4, "\nMore");
assert_eq!(t.para_list(1).start_offset, 1);
t.set_para_lists(5, 5, |p| p.start_offset = 0);
assert!(t.common_para_list(0, t.char_count()).is_some_and(|p| p.list == N));
// Bullets have no number, so no start.
t.set_para_lists(0, 0, |p| *p = ParaList { list: B, indent: 1, start_offset: 4 });
assert_eq!(t.para_list(0), ParaList { list: B, indent: 1, start_offset: 0 });
// A plain paragraph has none.
t.set_para_lists(0, 0, |p| p.list = P);
assert_eq!(t.para_list(0), ParaList::default());
}
}
+4 -1
View File
@@ -191,7 +191,7 @@ pub fn node_json(doc: &Document, id: NodeId, depth: usize) -> Value {
o.insert("textDecorationSkipInk".into(), json!(l.skip_ink));
o.insert("textDecorationColor".into(), l.color.map_or(json!("auto"), |c| json!(format!("#{}", c.to_hex()))));
}
// Lists: one style for the layer, or each paragraph's type and nesting level.
// Lists: one style for the layer, or each paragraph's type, nesting level and start.
if t.paragraphs.is_empty() {
if t.list != figcraft_doc::ListStyle::None {
o.insert("listStyle".into(), json!(t.list));
@@ -199,6 +199,9 @@ pub fn node_json(doc: &Document, id: NodeId, depth: usize) -> Value {
} else {
o.insert("lineTypes".into(), t.paragraphs.iter().map(|p| json!(p.list)).collect());
o.insert("lineIndentations".into(), t.paragraphs.iter().map(|p| json!(p.indent)).collect());
if t.paragraphs.iter().any(|p| p.start_offset > 0) {
o.insert("listStartOffsets".into(), t.paragraphs.iter().map(|p| json!(p.start_offset)).collect());
}
}
if t.list_spacing != 0.0 {
o.insert("listSpacing".into(), json!(t.list_spacing));
+21 -3
View File
@@ -57,6 +57,24 @@ fn list_buttons_act_on_the_selected_paragraphs() {
assert_eq!(data(&s, t).list, P, "toggled off");
}
#[test]
fn a_list_with_a_start_number_toggles_off() {
// An imported list that starts at 2 has the start on its first item only; it is still one
// numbered list, so the button turns it back to plain (#686).
let mut s = Session::new();
let t = text(&mut s, "two\nthree\nfour");
if let figcraft_doc::NodeKind::Text(td) = &mut s.doc_mut().unwrap().get_mut(t).unwrap().kind {
let n = |start_offset| ParaList { list: N, indent: 0, start_offset };
td.paragraphs = vec![n(1), n(0), n(0)];
}
let j = crate::inspect::node_json(s.doc().unwrap(), t, 0);
assert_eq!(j["listStartOffsets"], json!([1, 0, 0]), "inspect reports the start");
s.execute("text.numberList", &json!({"ids": [t]})).unwrap();
assert_eq!(lists(&s, t), [(P, 0); 3]);
assert_eq!(data(&s, t).para_list(0).start_offset, 0, "a plain paragraph has no start");
assert!(crate::inspect::node_json(s.doc().unwrap(), t, 0).get("listStartOffsets").is_none());
}
#[test]
fn typed_list_starts() {
// Typing "- " or "* " at the start of a paragraph starts a bulleted list, "1. " a numbered one
@@ -117,7 +135,7 @@ fn list_keys_at_list_items() {
for (chars, before, caret, k, shift, after, want, why) in cases {
let mut s = Session::new();
let t = text(&mut s, chars);
let ps: Vec<ParaList> = before.iter().map(|(list, indent)| ParaList { list: *list, indent: *indent }).collect();
let ps: Vec<ParaList> = before.iter().map(|(list, indent)| ParaList { list: *list, indent: *indent, start_offset: 0 }).collect();
if let figcraft_doc::NodeKind::Text(td) = &mut s.doc_mut().unwrap().get_mut(t).unwrap().kind {
td.paragraphs = ps;
}
@@ -141,7 +159,7 @@ fn paragraph_lists_follow_edits() {
// A paragraph's list stays with it as text is typed, pasted and deleted: new paragraphs copy
// the one they split from; joined paragraphs keep the first one's.
let mut t = TextData::new("ab\ncd\nef");
t.paragraphs = vec![ParaList { list: B, indent: 1 }, ParaList::default(), ParaList { list: N, indent: 0 }];
t.paragraphs = vec![ParaList { list: B, indent: 1, start_offset: 0 }, ParaList::default(), ParaList { list: N, indent: 0, start_offset: 0 }];
let get = |t: &TextData| (0..t.paragraph_count()).map(|p| (t.para_list(p).list, t.para_list(p).indent)).collect::<Vec<_>>();
t.replace_range(1, 1, "x\ny");
assert_eq!(get(&t), [(B, 1), (B, 1), (P, 0), (N, 0)], "a pasted newline: a new item like it");
@@ -156,7 +174,7 @@ fn paragraph_lists_follow_edits() {
(seed >> 33) as usize % n.max(1)
};
let mut t = TextData::new("a\nb\nc\nd");
t.paragraphs = vec![ParaList { list: B, indent: 2 }; 4];
t.paragraphs = vec![ParaList { list: B, indent: 2, start_offset: 0 }; 4];
for _ in 0..500 {
let n = t.char_count();
let (a, b) = (rnd(n + 2), rnd(n + 2));
+18 -3
View File
@@ -1565,6 +1565,20 @@ impl<'d> Exporter<'d> {
data.extend([("characterStyleIDs", arr(ids)), ("styleOverrideTable", arr(entries))]);
}
let lists: Vec<ListStyle> = (0..td.paragraph_count()).map(|p| td.para_list(p).list).collect();
let first = |i: usize, l: &ListStyle| *l != ListStyle::None && (i == 0 || lists.get(i - 1) != Some(l));
// Where each line's numbered list starts counting, less one: its first item's start, on
// every line of the list.
let mut run = 0;
let starts: Vec<u32> = lists
.iter()
.enumerate()
.map(|(i, l)| {
if first(i, l) {
run = td.para_list(i).start_offset;
}
if *l == ListStyle::Numbered { run } else { 0 }
})
.collect();
// One entry per paragraph, each with every field a reader expects.
let line = |(i, l): (usize, &ListStyle)| {
let ty = match l {
@@ -1573,14 +1587,15 @@ impl<'d> Exporter<'d> {
ListStyle::Bulleted => "UNORDERED_LIST",
};
let listed = *l != ListStyle::None;
let first = listed && (i == 0 || lists.get(i - 1) != Some(l));
let begins = first(i, l);
let start = starts.get(i).copied().unwrap_or(0);
o(vec![
("lineType", e(ty)),
("styleId", int(0)),
("indentationLevel", int(i64::from(listed))),
("sourceDirectionality", e("AUTO")),
("listStartOffset", int(0)),
("isFirstLineOfList", b(first)),
("listStartOffset", int(i64::from(start))),
("isFirstLineOfList", b(begins)),
])
};
data.push(("lines", arr(lists.iter().enumerate().map(line).collect())));
+1 -1
View File
@@ -680,7 +680,7 @@ fn normalize(n: &mut Node, ix: &dyn Fn(NodeId) -> NodeId) {
for r in &mut td.runs {
px(&mut r.style);
}
td.paragraphs = (0..td.paragraph_count()).map(|p| ParaList { list: td.para_list(p).list, indent: 0 }).collect();
td.paragraphs = (0..td.paragraph_count()).map(|p| ParaList { indent: 0, ..td.para_list(p) }).collect();
td.list = ListStyle::None;
}
NodeKind::Vector { network } => {
+8 -4
View File
@@ -827,10 +827,8 @@ impl<'a> Mapper<'a> {
o.insert("characterStyleOverrides".into(), json!(ids));
o.insert("styleOverrideTable".into(), Value::Object(table));
}
let lines: Vec<&str> = td
.get("lines")
.map(Kv::list)
.unwrap_or_default()
let raw = td.get("lines").map(Kv::list).unwrap_or_default();
let lines: Vec<&str> = raw
.iter()
.map(|l| match l.get("lineType").and_then(Kv::str) {
Some("ORDERED_LIST") => "ORDERED",
@@ -841,6 +839,12 @@ impl<'a> Mapper<'a> {
if lines.iter().any(|l| *l != "NONE") {
o.insert("lineTypes".into(), json!(lines));
}
// Where a numbered list starts counting, less one (not a REST field).
let starts: Vec<u32> =
raw.iter().map(|l| num(l, "listStartOffset").filter(|n| n.is_finite()).map_or(0, |n| n.clamp(0.0, f64::from(u32::MAX)) as u32)).collect();
if starts.iter().any(|n| *n > 0) {
o.insert("listStartOffsets".into(), json!(starts));
}
}
/// `fillGeometry` / `strokeGeometry` as SVG path data, and star/polygon parameters.
+3
View File
@@ -876,6 +876,9 @@ fn nested_instances_resized_again_drop_the_layout_derived_for_their_old_size() {
#[path = "tail_tests.rs"]
mod tail;
#[path = "text_list_tests.rs"]
mod text_list;
/// `.fig` files in the folder `FIGCRAFT_FIG_SAMPLES` names (none when it's unset).
pub(super) fn samples() -> Vec<std::path::PathBuf> {
let Some(dir) = std::env::var_os("FIGCRAFT_FIG_SAMPLES") else { return Vec::new() };
+93
View File
@@ -0,0 +1,93 @@
//! Numbered lists that start past 1 (#686): each text line says where its list starts counting,
//! less one (`listStartOffset`). Fixtures built in code with the parent module's helpers.
use figcraft_doc::{ListStyle, NodeKind, ParaList};
use super::*;
const LIST_SCHEMA: &str = "
enum NodeType NONE DOCUMENT CANVAS TEXT
enum LineType PLAIN ORDERED_LIST UNORDERED_LIST
struct GUID uint sessionID, uint localID
struct Vector float x, float y
struct Matrix float m00, float m01, float m02, float m10, float m11, float m12
message ParentIndex GUID guid, string position
message TextLineData LineType lineType, int indentationLevel, int listStartOffset, bool isFirstLineOfList
message TextData string characters, TextLineData[] lines
message NodeChange GUID guid, ParentIndex parentIndex, NodeType type, string name, Vector size, Matrix transform, float fontSize, TextData textData
message Message NodeChange[] nodeChanges
";
/// A list line: type, start offset, first of its list.
fn line(ty: &'static str, offset: i64, first: bool) -> Kv<'static> {
let level = i64::from(ty != "PLAIN");
o(vec![("lineType", e(ty)), ("indentationLevel", Kv::Int(level)), ("listStartOffset", Kv::Int(offset)), ("isFirstLineOfList", Kv::Bool(first))])
}
fn text_node(id: u64, name: &str, chars: &str, lines: Vec<Kv<'static>>) -> Kv<'static> {
let pos = ["!", "\"", "#", "$", "%", "&"][id as usize % 6];
node(
id,
Some((1, pos)),
"TEXT",
name,
vec![
("size", xy(200.0, 60.0)),
("transform", at(0.0, 0.0)),
("fontSize", f(16.0)),
("textData", o(vec![("characters", s(chars)), ("lines", arr(lines))])),
],
)
}
fn starts(doc: &figcraft_doc::Document, name: &str) -> Vec<(ListStyle, u32)> {
let NodeKind::Text(t) = &named(doc, name).kind else { panic!("{name} is no text") };
(0..t.paragraph_count()).map(|p| t.para_list(p)).map(|p: ParaList| (p.list, p.start_offset)).collect()
}
#[test]
fn numbered_lists_keep_their_start() {
use ListStyle::{Bulleted as B, None as P, Numbered as N};
let nodes = vec![
node(0, None, "DOCUMENT", "Document", vec![]),
node(1, Some((0, "!")), "CANVAS", "Page", vec![]),
text_node(2, "From 1", "Start here", vec![line("ORDERED_LIST", 0, true)]),
text_node(3, "From 2", "Next try this", vec![line("ORDERED_LIST", 1, true)]),
text_node(4, "First only", "a\nb\nc", vec![line("ORDERED_LIST", 3, true), line("ORDERED_LIST", 0, false), line("ORDERED_LIST", 0, false)]),
text_node(5, "Every line", "a\nb\nc", vec![line("ORDERED_LIST", 3, true), line("ORDERED_LIST", 3, false), line("ORDERED_LIST", 3, false)]),
text_node(6, "Mixed", "x\na\nb", vec![line("PLAIN", 0, false), line("ORDERED_LIST", 4, true), line("UNORDERED_LIST", 2, true)]),
text_node(7, "Junk", "a", vec![line("ORDERED_LIST", -5, true)]),
];
let sc = Schema::parse(LIST_SCHEMA).unwrap();
let (doc, warnings) = import(&canvas(&sc, &o(vec![("nodeChanges", arr(nodes))])), "x").unwrap();
assert!(warnings.is_empty(), "{warnings:?}");
assert_eq!(starts(&doc, "From 1"), [(N, 0)]);
assert_eq!(starts(&doc, "From 2"), [(N, 1)], "shows 2.");
assert_eq!(starts(&doc, "First only"), [(N, 3), (N, 0), (N, 0)]);
assert_eq!(starts(&doc, "Every line"), [(N, 3), (N, 3), (N, 3)]);
assert_eq!(starts(&doc, "Mixed"), [(P, 0), (N, 4), (B, 0)], "bullets have no number, so no start");
assert_eq!(starts(&doc, "Junk"), [(N, 0)], "a negative offset counts from 1");
}
#[test]
fn the_export_writes_where_a_list_starts() {
let mut doc = figcraft_doc::Document::new("Lists");
let page = doc.first_page().unwrap();
use ListStyle::{Bulleted as B, None as P, Numbered as N};
let mut td = figcraft_doc::TextData::new("two\nthree\nplain\nsix\ndot");
let pl = |list, start_offset| ParaList { list, indent: 0, start_offset };
td.paragraphs = vec![pl(N, 1), pl(N, 0), pl(P, 0), pl(N, 5), pl(B, 4)];
doc.create(page, NodeKind::Text(td), "List", 0.0, 0.0, 200.0, 80.0).unwrap();
let (bytes, _) = super::super::export(&doc, None).unwrap();
let a = Archive::open(&bytes).unwrap();
let (schema, message) = decode_canvas(&a.canvas).unwrap();
let msg = schema.decode_message(schema.find("Message").unwrap(), &message).unwrap();
let text = msg.get("nodeChanges").unwrap().list().iter().find(|n| n.get("name").and_then(Kv::str) == Some("List")).unwrap();
let lines = text.get("textData").and_then(|d| d.get("lines")).map(Kv::list).unwrap();
let offsets: Vec<f64> = lines.iter().map(|l| l.get("listStartOffset").and_then(Kv::f64).unwrap()).collect();
// Every line of a numbered list carries its first item's start (a reader that takes it per
// line or per list counts the same); bullets and plain lines carry none.
assert_eq!(offsets, [1.0, 1.0, 0.0, 5.0, 0.0]);
let (back, _) = import(&bytes, "x").unwrap();
assert_eq!(starts(&back, "List"), [(N, 1), (N, 1), (P, 0), (N, 5), (B, 0)]);
}
+4 -1
View File
@@ -210,10 +210,12 @@ fn runs(cx: &mut Ctx, v: &Value, td: &TextData) -> Vec<StyleRun> {
out
}
/// Each paragraph's list type and nesting level from `lineTypes` and `lineIndentations`.
/// Each paragraph's list type, nesting level and start number from `lineTypes`,
/// `lineIndentations` and (from `.fig` files) `listStartOffsets`.
fn lists(v: &Value, td: &mut TextData) {
let types = list(v, "lineTypes");
let indents = list(v, "lineIndentations");
let starts = list(v, "listStartOffsets");
td.paragraphs = types
.iter()
.enumerate()
@@ -224,6 +226,7 @@ fn lists(v: &Value, td: &mut TextData) {
_ => ListStyle::None,
},
indent: indents.get(i).and_then(Value::as_f64).filter(|d| d.is_finite()).map_or(0, |d| d.clamp(0.0, 255.0) as u8),
start_offset: starts.get(i).and_then(Value::as_f64).filter(|n| n.is_finite()).map_or(0, |n| n.clamp(0.0, f64::from(u32::MAX)) as u32),
})
.collect();
// Clamp the levels and fold one style for all back into the layer's list style.
+4 -3
View File
@@ -827,8 +827,8 @@ pub fn layout(text: &TextData, box_width: Option<f64>, box_height: Option<f64>)
}
/// The markers of a text's paragraphs ("" for plain ones). Numbers count the items of one list at
/// one level, restarting after a plain paragraph, a shallower item or the other kind of list;
/// levels go 1. a. i. and • ◦ ▪, then round again.
/// one level, restarting after a plain paragraph, a shallower item or the other kind of list,
/// from the first item's start number; levels go 1. a. i. and • ◦ ▪, then round again.
pub(crate) fn list_markers(lists: &[ParaList]) -> Vec<String> {
const BULLETS: [&str; 3] = ["\u{2022}", "\u{25E6}", "\u{25AA}"];
let mut count = [(ListStyle::None, 0usize); figcraft_doc::MAX_LIST_INDENT as usize + 1];
@@ -844,7 +844,8 @@ pub(crate) fn list_markers(lists: &[ParaList]) -> Vec<String> {
*c = (ListStyle::None, 0);
}
let Some(c) = count.get_mut(d) else { return String::new() };
*c = if c.0 == pl.list { (pl.list, c.1 + 1) } else { (pl.list, 1) };
let first = usize::try_from(pl.start_offset).unwrap_or(usize::MAX).saturating_add(1);
*c = if c.0 == pl.list { (pl.list, c.1.saturating_add(1)) } else { (pl.list, first) };
match (pl.list, d % 3) {
(ListStyle::Numbered, 1) => format!("{}.", alpha(c.1)),
(ListStyle::Numbered, 2) => format!("{}.", roman(c.1)),
+26 -1
View File
@@ -43,7 +43,32 @@ fn starts(l: &TextLayout) -> Vec<(f64, Option<f64>)> {
}
fn pl(list: ListStyle, indent: u8) -> ParaList {
ParaList { list, indent }
ParaList { list, indent, start_offset: 0 }
}
fn from(list: ListStyle, indent: u8, start_offset: u32) -> ParaList {
ParaList { list, indent, start_offset }
}
#[test]
fn numbered_lists_count_from_their_start() {
use ListStyle::{Bulleted as B, None as P, Numbered as N};
let cases: Vec<(Vec<ParaList>, Vec<&str>, &str)> = vec![
(vec![from(N, 0, 1)], vec!["2."], "a one-item list started at 2 (#686)"),
(vec![from(N, 0, 3), pl(N, 0), pl(N, 0)], vec!["4.", "5.", "6."], "the items after the first count on from it"),
(vec![from(N, 0, 3), from(N, 0, 3), from(N, 0, 3)], vec!["4.", "5.", "6."], "items that go on counting ignore their own start"),
(vec![from(N, 0, 1), pl(P, 0), from(N, 0, 1)], vec!["2.", "", "2."], "a list after a plain paragraph starts from its own start"),
(vec![pl(N, 0), from(N, 1, 1), pl(N, 1)], vec!["1.", "b.", "c."], "a nested level starts from its first item's start"),
(vec![from(B, 0, 4), pl(B, 0)], vec!["\u{2022}", "\u{2022}"], "bullets have no number"),
(vec![from(N, 0, 4_000), pl(N, 2)], vec!["4001.", "i."], "big starts"),
];
for (lists, want, why) in cases {
assert_eq!(list_markers(&lists), want, "{why}");
}
// The largest start never overflows, on 32-bit targets too.
let max = list_markers(&[from(N, 0, u32::MAX), pl(N, 0), pl(N, 0)]);
assert_eq!(max.len(), 3);
assert!(max.iter().all(|m| m.starts_with("429496729")), "{max:?}");
}
#[test]
+2 -1
View File
@@ -179,7 +179,8 @@ Or in a project's `.mcp.json`:
- **Lists** are per paragraph: `listStyle` (`none`, `bulleted`, `numbered`) and `indentation` (the
nesting level, 0–8) apply to the paragraphs chars `start`..`end` touch (all of them without a
range); `get_node` reports one `listStyle` for the layer, or `lineTypes` and `lineIndentations`
per paragraph. `text.bulletList`, `text.numberList` and `text.listStyle` act on the selected
per paragraph, plus `listStartOffsets` when a numbered list starts past 1 (from a `.fig` file;
1 shows "2."). `text.bulletList`, `text.numberList` and `text.listStyle` act on the selected
paragraphs while text is edited. Layer props: `listSpacing` (px between list items, in place of
`paragraphSpacing`), `hangingList` (markers outside the box) and `hangingPunctuation`. While
editing, `key` Tab / Shift+Tab nest an item or move it out, Enter on an empty item and Backspace