Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

content: Add start attribute support for ordered list #1329

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 28 additions & 19 deletions lib/model/content.dart
Original file line number Diff line number Diff line change
Expand Up @@ -251,21 +251,11 @@ class HeadingNode extends BlockInlineContainerNode {
}
}

enum ListStyle { ordered, unordered }
sealed class ListNode extends BlockContentNode {
const ListNode(this.items, {super.debugHtmlNode});

class ListNode extends BlockContentNode {
const ListNode(this.style, this.items, {super.debugHtmlNode});

final ListStyle style;
final List<List<BlockContentNode>> items;

@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
super.debugFillProperties(properties);
properties.add(FlagProperty('ordered', value: style == ListStyle.ordered,
ifTrue: 'ordered', ifFalse: 'unordered'));
}

@override
List<DiagnosticsNode> debugDescribeChildren() {
return items
Expand All @@ -275,6 +265,22 @@ class ListNode extends BlockContentNode {
}
}

class UnorderedListNode extends ListNode {
const UnorderedListNode(super.items, {super.debugHtmlNode});
}

class OrderedListNode extends ListNode {
const OrderedListNode(super.items, {required this.start, super.debugHtmlNode});

final int start;

@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
super.debugFillProperties(properties);
properties.add(IntProperty('start', start));
}
}

class QuotationNode extends BlockContentNode {
const QuotationNode(this.nodes, {super.debugHtmlNode});

Expand Down Expand Up @@ -1056,12 +1062,7 @@ class _ZulipContentParser {
}

BlockContentNode parseListNode(dom.Element element) {
ListStyle? listStyle;
switch (element.localName) {
case 'ol': listStyle = ListStyle.ordered; break;
case 'ul': listStyle = ListStyle.unordered; break;
}
assert(listStyle != null);
Comment on lines -1060 to -1064
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should still have an assert for this method's expectations on element.localName.

If you look at other methods on this class, you can see that that's the pattern they follow: any facts they assume (which their callers are expected to ensure), they assert at the top. And then down at the if/else below, this assumption is essential for understanding why the logic makes sense, so it's important to make explicit within the method.

(See also #1329 (comment) — this is why I said there that the references to ListStyle could switch to referring to element.localName directly, instead of saying they'd be deleted.)

assert(element.localName == 'ol' || element.localName == 'ul');
assert(element.className.isEmpty);

final debugHtmlNode = kDebugMode ? element : null;
Expand All @@ -1074,7 +1075,15 @@ class _ZulipContentParser {
items.add(parseImplicitParagraphBlockContentList(item.nodes));
}

return ListNode(listStyle!, items, debugHtmlNode: debugHtmlNode);
if (element.localName == 'ol') {
final startAttr = element.attributes['start'];
final start = startAttr == null ? 1
: int.tryParse(startAttr, radix: 10);
if (start == null) return UnimplementedBlockContentNode(htmlNode: element);
return OrderedListNode(items, start: start, debugHtmlNode: debugHtmlNode);
} else {
return UnorderedListNode(items, debugHtmlNode: debugHtmlNode);
}
}

BlockContentNode parseSpoilerNode(dom.Element divElement) {
Expand Down
46 changes: 18 additions & 28 deletions lib/widgets/content.dart
Original file line number Diff line number Diff line change
Expand Up @@ -482,45 +482,35 @@ class ListNodeWidget extends StatelessWidget {
final items = List.generate(node.items.length, (index) {
final item = node.items[index];
String marker;
switch (node.style) {
switch (node) {
// TODO(#161): different unordered marker styles at different levels of nesting
// see:
// https://html.spec.whatwg.org/multipage/rendering.html#lists
// https://www.w3.org/TR/css-counter-styles-3/#simple-symbolic
// TODO proper alignment of unordered marker; should be "• ", one space,
// but that comes out too close to item; not sure what's fixing that
// in a browser
case ListStyle.unordered: marker = "• "; break;
// TODO(#59) ordered lists starting not at 1
case ListStyle.ordered: marker = "${index+1}. "; break;
case UnorderedListNode(): marker = "• "; break;
case OrderedListNode(:final start): marker = "${start + index}. "; break;
}
return ListItemWidget(marker: marker, nodes: item);
return TableRow(children: [
Align(
alignment: AlignmentDirectional.topEnd,
child: Text(marker)),
BlockContentList(nodes: item),
]);
});

return Padding(
padding: const EdgeInsets.only(top: 2, bottom: 5),
child: Column(children: items));
}
}

class ListItemWidget extends StatelessWidget {
const ListItemWidget({super.key, required this.marker, required this.nodes});

final String marker;
final List<BlockContentNode> nodes;

@override
Widget build(BuildContext context) {
return Row(
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.baseline,
textBaseline: localizedTextBaseline(context),
children: [
SizedBox(
width: 20, // TODO handle long numbers in <ol>, like https://github.com/zulip/zulip/pull/25063
child: Align(
alignment: AlignmentDirectional.topEnd, child: Text(marker))),
Expanded(child: BlockContentList(nodes: nodes)),
]);
child: Table(
defaultVerticalAlignment: TableCellVerticalAlignment.baseline,
textBaseline: localizedTextBaseline(context),
columnWidths: const <int, TableColumnWidth>{
0: IntrinsicColumnWidth(),
1: FlexColumnWidth(),
},
children: items));
}
}

Expand Down
66 changes: 44 additions & 22 deletions test/model/content_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -269,6 +269,26 @@ class ContentExample {
url: '/#narrow/channel/378-api-design/topic/notation.20for.20near.20links/near/1972281',
nodes: [TextNode('#api design > notation for near links @ 💬')]));

static const orderedListCustomStart = ContentExample(
'ordered list with custom start',
'5. fifth\n6. sixth',
'<ol start="5">\n<li>fifth</li>\n<li>sixth</li>\n</ol>',
[OrderedListNode([
[ParagraphNode(wasImplicit: true, links: null, nodes: [TextNode('fifth')])],
[ParagraphNode(wasImplicit: true, links: null, nodes: [TextNode('sixth')])],
], start: 5)],
);

static const orderedListLargeStart = ContentExample(
'ordered list with large start number',
'9999. first\n10000. second',
'<ol start="9999">\n<li>first</li>\n<li>second</li>\n</ol>',
[OrderedListNode([
[ParagraphNode(wasImplicit: true, links: null, nodes: [TextNode('first')])],
[ParagraphNode(wasImplicit: true, links: null, nodes: [TextNode('second')])],
], start: 9999)],
);

static const spoilerDefaultHeader = ContentExample(
'spoiler with default header',
'```spoiler\nhello world\n```',
Expand Down Expand Up @@ -306,13 +326,13 @@ class ContentExample {
'<p><em>italic</em> <a href="https://zulip.com/">zulip</a></p>\n'
'</div></div>',
[SpoilerNode(
header: [ListNode(ListStyle.ordered, [
[ListNode(ListStyle.unordered, [
[HeadingNode(level: HeadingLevel.h2, links: null, nodes: [
TextNode('hello'),
header: [OrderedListNode([[
UnorderedListNode([
[HeadingNode(level: HeadingLevel.h2, links: null, nodes: [
TextNode('hello'),
])]
])]
])],
])],
], start: 1)],
content: [ParagraphNode(links: null, nodes: [
EmphasisNode(nodes: [TextNode('italic')]),
TextNode(' '),
Expand Down Expand Up @@ -836,7 +856,7 @@ class ContentExample {
'<div class="message_inline_image">'
'<a href="https://chat.zulip.org/user_avatars/2/realm/icon.png">'
'<img src="https://chat.zulip.org/user_avatars/2/realm/icon.png"></a></div></li>\n</ul>', [
ListNode(ListStyle.unordered, [[
UnorderedListNode([[
ImageNodeList([
ImageNode(srcUrl: 'https://chat.zulip.org/user_avatars/2/realm/icon.png',
thumbnailUrl: null, loading: false,
Expand All @@ -858,7 +878,7 @@ class ContentExample {
'<div class="message_inline_image">'
'<a href="https://chat.zulip.org/user_avatars/2/realm/icon.png?version=2" title="icon.png">'
'<img src="https://chat.zulip.org/user_avatars/2/realm/icon.png?version=2"></a></div></li>\n</ul>', [
ListNode(ListStyle.unordered, [[
UnorderedListNode([[
ParagraphNode(wasImplicit: true, links: null, nodes: [
LinkNode(url: 'https://chat.zulip.org/user_avatars/2/realm/icon.png', nodes: [TextNode('icon.png')]),
TextNode(' '),
Expand Down Expand Up @@ -887,7 +907,7 @@ class ContentExample {
'<a href="https://chat.zulip.org/user_avatars/2/realm/icon.png" title="icon.png">'
'<img src="https://chat.zulip.org/user_avatars/2/realm/icon.png"></a></div>'
'more text</li>\n</ul>', [
ListNode(ListStyle.unordered, [[
UnorderedListNode([[
const ParagraphNode(wasImplicit: true, links: null, nodes: [
LinkNode(url: 'https://chat.zulip.org/user_avatars/2/realm/icon.png', nodes: [TextNode('icon.png')]),
TextNode(' '),
Expand Down Expand Up @@ -1455,16 +1475,16 @@ void main() {
testParse('<ol>',
// "1. first\n2. then"
'<ol>\n<li>first</li>\n<li>then</li>\n</ol>', const [
ListNode(ListStyle.ordered, [
[ParagraphNode(wasImplicit: true, links: null, nodes: [TextNode('first')])],
[ParagraphNode(wasImplicit: true, links: null, nodes: [TextNode('then')])],
]),
OrderedListNode([
[ParagraphNode(wasImplicit: true, links: null, nodes: [TextNode('first')])],
[ParagraphNode(wasImplicit: true, links: null, nodes: [TextNode('then')])],
], start: 1),
]);

testParse('<ul>',
// "* something\n* another"
'<ul>\n<li>something</li>\n<li>another</li>\n</ul>', const [
ListNode(ListStyle.unordered, [
UnorderedListNode([
[ParagraphNode(wasImplicit: true, links: null, nodes: [TextNode('something')])],
[ParagraphNode(wasImplicit: true, links: null, nodes: [TextNode('another')])],
]),
Expand All @@ -1473,8 +1493,8 @@ void main() {
testParse('implicit paragraph with internal <br>',
// "* a\n b"
'<ul>\n<li>a<br>\n b</li>\n</ul>', const [
ListNode(ListStyle.unordered, [
[ParagraphNode(wasImplicit: true, links: null, nodes: [
UnorderedListNode([[
ParagraphNode(wasImplicit: true, links: null, nodes: [
TextNode('a'),
LineBreakInlineNode(),
TextNode('\n b'), // TODO: this renders misaligned
Expand All @@ -1485,13 +1505,15 @@ void main() {
testParse('explicit paragraphs',
// "* a\n\n b"
'<ul>\n<li>\n<p>a</p>\n<p>b</p>\n</li>\n</ul>', const [
ListNode(ListStyle.unordered, [
[
UnorderedListNode([[
ParagraphNode(links: null, nodes: [TextNode('a')]),
ParagraphNode(links: null, nodes: [TextNode('b')]),
],
]),
]);

testParseExample(ContentExample.orderedListCustomStart);
testParseExample(ContentExample.orderedListLargeStart);
});

testParseExample(ContentExample.spoilerDefaultHeader);
Expand Down Expand Up @@ -1524,7 +1546,7 @@ void main() {
testParse('link in list item',
// "* [t](/u)"
'<ul>\n<li><a href="/u">t</a></li>\n</ul>', const [
ListNode(ListStyle.unordered, [
UnorderedListNode([
[ParagraphNode(links: null, wasImplicit: true, nodes: [
LinkNode(url: '/u', nodes: [TextNode('t')]),
])],
Expand Down Expand Up @@ -1585,18 +1607,18 @@ void main() {
'<ol>\n<li>\n<blockquote>\n<h6>two</h6>\n<ul>\n<li>three</li>\n'
'</ul>\n</blockquote>\n<div class="codehilite"><pre><span></span>'
'<code>four\n</code></pre></div>\n\n</li>\n</ol>', const [
ListNode(ListStyle.ordered, [[
OrderedListNode([[
QuotationNode([
HeadingNode(level: HeadingLevel.h6, links: null, nodes: [TextNode('two')]),
ListNode(ListStyle.unordered, [[
UnorderedListNode([[
ParagraphNode(wasImplicit: true, links: null, nodes: [TextNode('three')]),
]]),
]),
CodeBlockNode([
CodeBlockSpanNode(text: 'four', type: CodeBlockSpanType.text),
]),
ParagraphNode(wasImplicit: true, links: null, nodes: [TextNode('\n\n')]), // TODO avoid this; it renders wrong
]]),
]], start: 1),
]);

test('all content examples are tested', () {
Expand Down
42 changes: 42 additions & 0 deletions test/widgets/content_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,48 @@ void main() {
});
});

group('ListNodeWidget', () {
testWidgets('ordered list with custom start', (tester) async {
await prepareContent(tester, plainContent('<ol start="3">\n<li>third</li>\n<li>fourth</li>\n</ol>'));

expect(find.text('3. '), findsOneWidget);
expect(find.text('4. '), findsOneWidget);
expect(find.text('third'), findsOneWidget);
expect(find.text('fourth'), findsOneWidget);
});

testWidgets('list uses correct text baseline alignment', (tester) async {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, I guess this is good to check too but it's not the thing I was hoping for in #1329 (comment) — it doesn't check that #1356 was (and remains) fixed.

In particular if I make this edit:

--- lib/widgets/content.dart
+++ lib/widgets/content.dart
@@ -507,7 +507,7 @@ class ListNodeWidget extends StatelessWidget {
         defaultVerticalAlignment: TableCellVerticalAlignment.baseline,
         textBaseline: localizedTextBaseline(context),
         columnWidths: const <int, TableColumnWidth>{
-          0: IntrinsicColumnWidth(),
+          0: FixedColumnWidth(20),
           1: FlexColumnWidth(),
         },

then that should reintroduce #1356 — it'd make the new code behave pretty much just like the old code before your fix — but there isn't currently a test that would detect that and fail.

Can you find a way to write a test that checks that #1356 is fixed?

await prepareContent(tester, plainContent(ContentExample.orderedListLargeStart.html));

final table = tester.widget<Table>(find.byType(Table));
check(table.defaultVerticalAlignment).equals(TableCellVerticalAlignment.baseline);
check(table.textBaseline).equals(localizedTextBaseline(tester.element(find.byType(Table))));
});

testWidgets('ordered list markers have enough space to render completely', (tester) async {
await prepareContent(tester, plainContent(ContentExample.orderedListLargeStart.html));

final renderParagraph = tester.renderObject(find.textContaining('9999.')) as RenderParagraph;
final textHeight = renderParagraph.size.height;
final lineHeight = renderParagraph.text.style!.height! * renderParagraph.text.style!.fontSize!;
check(textHeight).equals(lineHeight);
check(renderParagraph.didExceedMaxLines).isFalse();
});

testWidgets('ordered list markers are end-aligned', (tester) async {
await prepareContent(tester, plainContent(ContentExample.orderedListLargeStart.html));

final marker9999 = tester.renderObject(find.textContaining('9999.')) as RenderParagraph;
final marker10000 = tester.renderObject(find.textContaining('10000.')) as RenderParagraph;
check(marker9999.size.width != marker10000.size.width).isTrue();

final marker9999Pos = marker9999.localToGlobal(Offset.zero);
final marker10000Pos = marker10000.localToGlobal(Offset.zero);
check(marker9999Pos.dx + marker9999.size.width)
.equals(marker10000Pos.dx + marker10000.size.width);
});
});

group('Spoiler', () {
testContentSmoke(ContentExample.spoilerDefaultHeader);
testContentSmoke(ContentExample.spoilerPlainCustomHeader);
Expand Down