From 8bbca0067bc497c90d9a033c90a5745bfb52343e Mon Sep 17 00:00:00 2001 From: Rajesh Malviya Date: Tue, 5 Nov 2024 00:36:02 +0530 Subject: [PATCH] content: Handle `` elements Fixes: #360 --- lib/model/content.dart | 175 +++++++++++++++++++++++++++++++ lib/widgets/content.dart | 101 +++++++++++++++++- test/flutter_checks.dart | 12 +++ test/model/content_test.dart | 183 +++++++++++++++++++++++++++++++++ test/widgets/content_test.dart | 67 ++++++++++++ 5 files changed, 533 insertions(+), 5 deletions(-) diff --git a/lib/model/content.dart b/lib/model/content.dart index 1a919d942d..ccc84121dd 100644 --- a/lib/model/content.dart +++ b/lib/model/content.dart @@ -1,3 +1,5 @@ +import 'dart:ui'; + import 'package:collection/collection.dart'; import 'package:flutter/foundation.dart'; import 'package:html/dom.dart' as dom; @@ -729,6 +731,63 @@ class GlobalTimeNode extends InlineContentNode { } } +class TableNode extends BlockContentNode { + const TableNode({super.debugHtmlNode, required this.rows}); + + final List rows; + + @override + List debugDescribeChildren() { + return rows + .mapIndexed((i, row) => row.toDiagnosticsNode(name: 'row $i')) + .toList(); + } +} + +class TableRowNode extends BlockContentNode { + const TableRowNode({ + super.debugHtmlNode, + required this.cells, + required this.isHeader, + }); + + final List cells; + + /// Indicates whether this row is the header row. + final bool isHeader; + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(DiagnosticsProperty('isHeader', isHeader)); + } + + @override + List debugDescribeChildren() { + return cells + .mapIndexed((i, cell) => cell.toDiagnosticsNode(name: 'cell $i')) + .toList(); + } +} + +class TableCellNode extends BlockInlineContainerNode { + const TableCellNode({ + super.debugHtmlNode, + required super.nodes, + required super.links, + required this.textAlign, + }); + + /// The [TextAlign] alignment for the content within this cell. + final TextAlign? textAlign; + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(DiagnosticsProperty('textAlign', textAlign)); + } +} + //////////////////////////////////////////////////////////////// /// What sort of nodes a [_ZulipContentParser] is currently expecting to find. @@ -1220,6 +1279,118 @@ class _ZulipContentParser { return EmbedVideoNode(hrefUrl: href, previewImageSrcUrl: imgSrc, debugHtmlNode: debugHtmlNode); } + BlockContentNode parseTableContent(dom.Element tableElement) { + assert(_debugParserContext == _ParserContext.block); + assert(tableElement.localName == 'table' + && tableElement.className.isEmpty); + + TableCellNode? parseTableCell(dom.Element node, bool isHeader) { + assert(node.localName == (isHeader ? 'th' : 'td')); + assert(node.className.isEmpty); + + final cellStyle = node.attributes['style']; + final TextAlign? textAlign; + switch (cellStyle) { + case null: + // Default text alignment specified in for `tr > th` element + // in `web/styles/rendered_markdown.css`: + // https://github.com/zulip/zulip/blob/d556c0e0a5e9e8ba0ff548354bf2ba6ef2c97d4b/web/styles/rendered_markdown.css#L140 + textAlign = isHeader ? TextAlign.left : null; + case 'text-align: left;': + textAlign = TextAlign.left; + case 'text-align: center;': + textAlign = TextAlign.center; + case 'text-align: right;': + textAlign = TextAlign.right; + default: + return null; + } + final parsed = parseBlockInline(node.nodes); + return TableCellNode( + nodes: parsed.nodes, + links: parsed.links, + textAlign: textAlign); + } + + List? parseTableCells(dom.NodeList cellNodes, bool isHeader) { + final cells = []; + for (final node in cellNodes) { + if (node is dom.Text && (node.text == '\n')) continue; + + if (node is! dom.Element) return null; + if (node.localName != (isHeader ? 'th' : 'td')) return null; + if (node.className.isNotEmpty) return null; + + final cell = parseTableCell(node, isHeader); + if (cell == null) return null; + cells.add(cell); + } + return cells; + } + + bool parseTableBodyRows(dom.NodeList tbodyNodes, int headerColumnCount, List rows) { + for (final node in tbodyNodes) { + if (node is dom.Text && (node.text == '\n')) continue; + + if (node is! dom.Element) return false; + if (node.localName != 'tr') return false; + if (node.className.isNotEmpty) return false; + if (node.nodes.isEmpty) return false; + + final cells = parseTableCells(node.nodes, false); + if (cells == null) return false; + + // Ensure that the number of columns in this row matches + // the header row. + if (cells.length != headerColumnCount) return false; + rows.add(TableRowNode(cells: cells, isHeader: false)); + } + return true; + } + + TableRowNode? parseTableHeaderRow(dom.NodeList theadNodes) { + if (theadNodes case [ + dom.Text(data: '\n'), + dom.Element(localName: 'tr', className: '', nodes: [...] && var nodes), + dom.Text(data: '\n'), + ]) { + final cells = parseTableCells(nodes, true); + if (cells == null) return null; + return TableRowNode(cells: cells, isHeader: true); + } else { + return null; + } + } + + late final int headerColumnCount; + final rows = []; + for (final node in tableElement.nodes) { + if (node is dom.Text && (node.text == '\n')) continue; + if (node is! dom.Element) { + return UnimplementedBlockContentNode(htmlNode: tableElement); + } + + switch (node) { + case dom.Element(localName: 'thead', className: '', nodes: [...] && var nodes): + final headerRow = parseTableHeaderRow(nodes); + if (headerRow == null) { + return UnimplementedBlockContentNode(htmlNode: tableElement); + } + headerColumnCount = headerRow.cells.length; + rows.add(headerRow); + + case dom.Element(localName: 'tbody', className: '', nodes: [...] && var nodes): + if (!parseTableBodyRows(nodes, headerColumnCount, rows)) { + return UnimplementedBlockContentNode(htmlNode: tableElement); + } + + default: + return UnimplementedBlockContentNode(htmlNode: tableElement); + } + } + return TableNode(rows: rows); + } + BlockContentNode parseBlockContent(dom.Node node) { assert(_debugParserContext == _ParserContext.block); final debugHtmlNode = kDebugMode ? node : null; @@ -1288,6 +1459,10 @@ class _ZulipContentParser { parseBlockContentList(element.nodes)); } + if (localName == 'table' && className.isEmpty) { + return parseTableContent(element); + } + if (localName == 'div' && className == 'spoiler-block') { return parseSpoilerNode(element); } diff --git a/lib/widgets/content.dart b/lib/widgets/content.dart index 353ad7ea07..b99fcb30a7 100644 --- a/lib/widgets/content.dart +++ b/lib/widgets/content.dart @@ -48,6 +48,8 @@ class ContentTheme extends ThemeExtension { colorPollVoteCountBackground: const HSLColor.fromAHSL(1, 0, 0, 1).toColor(), colorPollVoteCountBorder: const HSLColor.fromAHSL(1, 156, 0.28, 0.7).toColor(), colorPollVoteCountText: const HSLColor.fromAHSL(1, 156, 0.41, 0.4).toColor(), + colorTableCellBorder: const HSLColor.fromAHSL(1, 0, 0, 0.80).toColor(), + colorTableHeaderBackground: const HSLColor.fromAHSL(1, 0, 0, 0.93).toColor(), colorThematicBreak: const HSLColor.fromAHSL(1, 0, 0, .87).toColor(), textStylePlainParagraph: _plainParagraphCommon(context).copyWith( color: const HSLColor.fromAHSL(1, 0, 0, 0.15).toColor(), @@ -77,6 +79,8 @@ class ContentTheme extends ThemeExtension { colorPollVoteCountBackground: const HSLColor.fromAHSL(0.2, 0, 0, 0).toColor(), colorPollVoteCountBorder: const HSLColor.fromAHSL(1, 185, 0.35, 0.35).toColor(), colorPollVoteCountText: const HSLColor.fromAHSL(1, 185, 0.35, 0.65).toColor(), + colorTableCellBorder: const HSLColor.fromAHSL(1, 0, 0, 0.33).toColor(), + colorTableHeaderBackground: const HSLColor.fromAHSL(0.5, 0, 0, 0).toColor(), colorThematicBreak: const HSLColor.fromAHSL(1, 0, 0, .87).toColor().withValues(alpha: 0.2), textStylePlainParagraph: _plainParagraphCommon(context).copyWith( color: const HSLColor.fromAHSL(1, 0, 0, 0.85).toColor(), @@ -105,6 +109,8 @@ class ContentTheme extends ThemeExtension { required this.colorPollVoteCountBackground, required this.colorPollVoteCountBorder, required this.colorPollVoteCountText, + required this.colorTableCellBorder, + required this.colorTableHeaderBackground, required this.colorThematicBreak, required this.textStylePlainParagraph, required this.codeBlockTextStyles, @@ -134,6 +140,8 @@ class ContentTheme extends ThemeExtension { final Color colorPollVoteCountBackground; final Color colorPollVoteCountBorder; final Color colorPollVoteCountText; + final Color colorTableCellBorder; + final Color colorTableHeaderBackground; final Color colorThematicBreak; /// The complete [TextStyle] we use for plain, unstyled paragraphs. @@ -189,6 +197,8 @@ class ContentTheme extends ThemeExtension { Color? colorPollVoteCountBackground, Color? colorPollVoteCountBorder, Color? colorPollVoteCountText, + Color? colorTableCellBorder, + Color? colorTableHeaderBackground, Color? colorThematicBreak, TextStyle? textStylePlainParagraph, CodeBlockTextStyles? codeBlockTextStyles, @@ -208,6 +218,8 @@ class ContentTheme extends ThemeExtension { colorPollVoteCountBackground: colorPollVoteCountBackground ?? this.colorPollVoteCountBackground, colorPollVoteCountBorder: colorPollVoteCountBorder ?? this.colorPollVoteCountBorder, colorPollVoteCountText: colorPollVoteCountText ?? this.colorPollVoteCountText, + colorTableCellBorder: colorTableCellBorder ?? this.colorTableCellBorder, + colorTableHeaderBackground: colorTableHeaderBackground ?? this.colorTableHeaderBackground, colorThematicBreak: colorThematicBreak ?? this.colorThematicBreak, textStylePlainParagraph: textStylePlainParagraph ?? this.textStylePlainParagraph, codeBlockTextStyles: codeBlockTextStyles ?? this.codeBlockTextStyles, @@ -234,6 +246,8 @@ class ContentTheme extends ThemeExtension { colorPollVoteCountBackground: Color.lerp(colorPollVoteCountBackground, other.colorPollVoteCountBackground, t)!, colorPollVoteCountBorder: Color.lerp(colorPollVoteCountBorder, other.colorPollVoteCountBorder, t)!, colorPollVoteCountText: Color.lerp(colorPollVoteCountText, other.colorPollVoteCountText, t)!, + colorTableCellBorder: Color.lerp(colorTableCellBorder, other.colorTableCellBorder, t)!, + colorTableHeaderBackground: Color.lerp(colorTableHeaderBackground, other.colorTableHeaderBackground, t)!, colorThematicBreak: Color.lerp(colorThematicBreak, other.colorThematicBreak, t)!, textStylePlainParagraph: TextStyle.lerp(textStylePlainParagraph, other.textStylePlainParagraph, t)!, codeBlockTextStyles: CodeBlockTextStyles.lerp(codeBlockTextStyles, other.codeBlockTextStyles, t), @@ -324,6 +338,21 @@ class BlockContentList extends StatelessWidget { }(), InlineVideoNode() => MessageInlineVideo(node: node), EmbedVideoNode() => MessageEmbedVideo(node: node), + TableNode() => MessageTable(node: node), + TableRowNode() => () { + assert(false, + "[TableRowNode] not allowed in [BlockContentList]. " + "It should be wrapped in [TableNode]." + ); + return const SizedBox.shrink(); + }(), + TableCellNode() => () { + assert(false, + "[TableCellNode] not allowed in [BlockContentList]. " + "It should be wrapped in [TableRowNode]." + ); + return const SizedBox.shrink(); + }(), UnimplementedBlockContentNode() => Text.rich(_errorUnimplemented(node, context: context)), }; @@ -806,22 +835,28 @@ class MathBlock extends StatelessWidget { Widget _buildBlockInlineContainer({ required TextStyle style, required BlockInlineContainerNode node, + TextAlign? textAlign, }) { if (node.links == null) { return InlineContent(recognizer: null, linkRecognizers: null, - style: style, nodes: node.nodes); + style: style, nodes: node.nodes, textAlign: textAlign); } return _BlockInlineContainer(links: node.links!, - style: style, nodes: node.nodes); + style: style, nodes: node.nodes, textAlign: textAlign); } class _BlockInlineContainer extends StatefulWidget { - const _BlockInlineContainer( - {required this.links, required this.style, required this.nodes}); + const _BlockInlineContainer({ + required this.links, + required this.style, + required this.nodes, + this.textAlign, + }); final List links; final TextStyle style; final List nodes; + final TextAlign? textAlign; @override State<_BlockInlineContainer> createState() => _BlockInlineContainerState(); @@ -877,6 +912,7 @@ class InlineContent extends StatelessWidget { required this.linkRecognizers, required this.style, required this.nodes, + this.textAlign, }) { assert(style.fontSize != null); assert( @@ -898,13 +934,16 @@ class InlineContent extends StatelessWidget { /// Similarly must set a font weight using [weightVariableTextStyle]. final TextStyle style; + /// A [TextAlign] applied to the root widget of this content. + final TextAlign? textAlign; + final List nodes; late final _InlineContentBuilder _builder; @override Widget build(BuildContext context) { - return Text.rich(_builder.build(context)); + return Text.rich(_builder.build(context), textAlign: textAlign); } } @@ -1196,6 +1235,58 @@ class GlobalTime extends StatelessWidget { } } +class MessageTable extends StatelessWidget { + const MessageTable({super.key, required this.node}); + + final TableNode node; + + @override + Widget build(BuildContext context) { + final contentTheme = ContentTheme.of(context); + return SingleChildScrollViewWithScrollbar( + scrollDirection: Axis.horizontal, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 5), + child: Table( + border: TableBorder.all( + width: 1, + style: BorderStyle.solid, + color: contentTheme.colorTableCellBorder), + defaultColumnWidth: const IntrinsicColumnWidth(), + children: List.unmodifiable(node.rows.map((row) => TableRow( + decoration: row.isHeader + ? BoxDecoration(color: contentTheme.colorTableHeaderBackground) + : null, + children: List.unmodifiable(row.cells.map((cell) => + MessageTableCell(node: cell, isHeader: row.isHeader))))))))); + } +} + +class MessageTableCell extends StatelessWidget { + const MessageTableCell({super.key, required this.node, required this.isHeader}); + + final TableCellNode node; + final bool isHeader; + + @override + Widget build(BuildContext context) { + return TableCell( + verticalAlignment: TableCellVerticalAlignment.middle, + child: Padding( + padding: const EdgeInsets.all(4), + child: node.nodes.isNotEmpty + ? _buildBlockInlineContainer( + node: node, + style: isHeader + ? DefaultTextStyle.of(context).style + .merge(weightVariableTextStyle(context, wght: 700)) + : DefaultTextStyle.of(context).style, + textAlign: node.textAlign) + : const SizedBox.shrink(), + )); + } +} + void _launchUrl(BuildContext context, String urlString) async { DialogStatus showError(BuildContext context, String? message) { return showErrorDialog(context: context, diff --git a/test/flutter_checks.dart b/test/flutter_checks.dart index a7c9f1c185..9d81e8ea20 100644 --- a/test/flutter_checks.dart +++ b/test/flutter_checks.dart @@ -146,3 +146,15 @@ extension MaterialChecks on Subject { extension InputDecorationChecks on Subject { Subject get hintText => has((x) => x.hintText, 'hintText'); } + +extension BoxDecorationChecks on Subject { + Subject get color => has((x) => x.color, 'color'); +} + +extension TableRowChecks on Subject { + Subject get decoration => has((x) => x.decoration, 'decoration'); +} + +extension TableChecks on Subject
{ + Subject> get children => has((x) => x.children, 'children'); +} diff --git a/test/model/content_test.dart b/test/model/content_test.dart index d06d414916..309f5b1b44 100644 --- a/test/model/content_test.dart +++ b/test/model/content_test.dart @@ -1,4 +1,5 @@ import 'dart:io'; +import 'dart:ui'; import 'package:checks/checks.dart'; import 'package:html/parser.dart'; @@ -881,6 +882,180 @@ class ContentExample { ]), InlineVideoNode(srcUrl: '/user_uploads/2/78/_KoRecCHZTFrVtyTKCkIh5Hq/Big-Buck-Bunny.webm'), ]); + + static const tableWithSingleRow = ContentExample( + 'table with single row', + // https://chat.zulip.org/#narrow/channel/7-test-here/topic/Rajesh/near/1971202 + '| a | b | c | d |\n| - | - | - | - |\n| 1 | 2 | 3 | 4 |', + '
\n\n\n\n\n\n\n\n\n' + '\n\n\n\n\n\n\n\n
abcd
1234
', [ + TableNode(rows: [ + TableRowNode(cells: [ + TableCellNode(nodes: [TextNode('a')], links: [], textAlign: TextAlign.left), + TableCellNode(nodes: [TextNode('b')], links: [], textAlign: TextAlign.left), + TableCellNode(nodes: [TextNode('c')], links: [], textAlign: TextAlign.left), + TableCellNode(nodes: [TextNode('d')], links: [], textAlign: TextAlign.left), + ], isHeader: true), + TableRowNode(cells: [ + TableCellNode(nodes: [TextNode('1')], links: [], textAlign: null), + TableCellNode(nodes: [TextNode('2')], links: [], textAlign: null), + TableCellNode(nodes: [TextNode('3')], links: [], textAlign: null), + TableCellNode(nodes: [TextNode('4')], links: [], textAlign: null), + ], isHeader: false), + ]), + ]); + + static const tableWithMultipleRows = ContentExample( + 'table with multiple rows', + // https://chat.zulip.org/#narrow/channel/7-test-here/topic/Rajesh/near/1971203 + '| heading 1 | heading 2 | heading 3 |\n| - | - | - |\n| body11 | body12 | body13 |\n| body21 | body22 | body23 |\n| body31 | body32 | body33 |', + '\n\n\n\n\n\n\n\n' + '\n\n\n\n\n\n' + '\n\n\n\n\n' + '\n\n\n\n\n\n
heading 1heading 2heading 3
body11body12body13
body21body22body23
body31body32body33
', [ + TableNode(rows: [ + TableRowNode(cells: [ + TableCellNode(nodes: [TextNode('heading 1')], links: [], textAlign: TextAlign.left), + TableCellNode(nodes: [TextNode('heading 2')], links: [], textAlign: TextAlign.left), + TableCellNode(nodes: [TextNode('heading 3')], links: [], textAlign: TextAlign.left), + ], isHeader: true), + TableRowNode(cells: [ + TableCellNode(nodes: [TextNode('body11')], links: [], textAlign: null), + TableCellNode(nodes: [TextNode('body12')], links: [], textAlign: null), + TableCellNode(nodes: [TextNode('body13')], links: [], textAlign: null), + ], isHeader: false), + TableRowNode(cells: [ + TableCellNode(nodes: [TextNode('body21')], links: [], textAlign: null), + TableCellNode(nodes: [TextNode('body22')], links: [], textAlign: null), + TableCellNode(nodes: [TextNode('body23')], links: [], textAlign: null), + ], isHeader: false), + TableRowNode(cells: [ + TableCellNode(nodes: [TextNode('body31')], links: [], textAlign: null), + TableCellNode(nodes: [TextNode('body32')], links: [], textAlign: null), + TableCellNode(nodes: [TextNode('body33')], links: [], textAlign: null), + ], isHeader: false), + ]), + ]); + + static const tableWithDifferentTextAligmentInColumns = ContentExample( + 'table with different text aligment in columns', + // https://chat.zulip.org/#narrow/channel/7-test-here/topic/Rajesh/near/1971201 + '| default-aligned | left-aligned | center-aligned | right-aligned |\n| - | :- | :-: | -: |\n| text | text | text | text |\n| long text long text long text | long text long text long text | long text long text long text | long text long text long text |', + '\n\n\n\n\n\n\n\n\n' + '\n\n\n\n\n\n\n' + '\n\n\n\n\n\n' + '\n
default-alignedleft-alignedcenter-alignedright-aligned
texttexttexttext
long text long text long textlong text long text long textlong text long text long textlong text long text long text
', [ + TableNode(rows: [ + TableRowNode(cells: [ + TableCellNode(nodes: [TextNode('default-aligned')], links: [], textAlign: TextAlign.left), + TableCellNode(nodes: [TextNode('left-aligned')], links: [], textAlign: TextAlign.left), + TableCellNode(nodes: [TextNode('center-aligned')], links: [], textAlign: TextAlign.center), + TableCellNode(nodes: [TextNode('right-aligned')], links: [], textAlign: TextAlign.right), + ], isHeader: true), + TableRowNode(cells: [ + TableCellNode(nodes: [TextNode('text')], links: [], textAlign: null), + TableCellNode(nodes: [TextNode('text')], links: [], textAlign: TextAlign.left), + TableCellNode(nodes: [TextNode('text')], links: [], textAlign: TextAlign.center), + TableCellNode(nodes: [TextNode('text')], links: [], textAlign: TextAlign.right), + ], isHeader: false), + TableRowNode(cells: [ + TableCellNode(nodes: [TextNode('long text long text long text')], links: [], textAlign: null), + TableCellNode(nodes: [TextNode('long text long text long text')], links: [], textAlign: TextAlign.left), + TableCellNode(nodes: [TextNode('long text long text long text')], links: [], textAlign: TextAlign.center), + TableCellNode(nodes: [TextNode('long text long text long text')], links: [], textAlign: TextAlign.right), + ], isHeader: false), + ]), + ]); + + static const tableWithBoldAndItalicHeaders = ContentExample( + 'table with bold and italic headers', + // https://chat.zulip.org/#narrow/channel/7-test-here/topic/Rajesh/near/1971911 + '| normal heading | *italic heading* | **bold heading** | ***italic bold heading*** |\n| - | - | - | - |\n| text | text | text | text |', + '\n\n\n\n\n\n\n\n\n' + '\n\n\n\n\n\n\n\n
normal headingitalic headingbold headingitalic bold heading
texttexttexttext
', [ + TableNode(rows: [ + TableRowNode(cells: [ + TableCellNode(nodes: [TextNode('normal heading')], links: [], textAlign: TextAlign.left), + TableCellNode(nodes: [EmphasisNode(nodes: [TextNode('italic heading')])], links: [], textAlign: TextAlign.left), + TableCellNode(nodes: [StrongNode(nodes: [TextNode('bold heading')])], links: [], textAlign: TextAlign.left), + TableCellNode(nodes: [StrongNode(nodes: [EmphasisNode(nodes: [TextNode('italic bold heading')])])], links: [], textAlign: TextAlign.left), + ], isHeader: true), + TableRowNode(cells: [ + TableCellNode(nodes: [TextNode('text')], links: [], textAlign: null), + TableCellNode(nodes: [TextNode('text')], links: [], textAlign: null), + TableCellNode(nodes: [TextNode('text')], links: [], textAlign: null), + TableCellNode(nodes: [TextNode('text')], links: [], textAlign: null), + ], isHeader: false), + ]), + ]); + + static const tableWithLinksInBodyCells = ContentExample( + 'table with links in body cells', + // https://chat.zulip.org/#narrow/channel/7-test-here/topic/Rajesh/near/1971237 + '| a | b |\n| - | - |\n| https://zulipchat.com | https://zulip.com |', + '\n\n\n\n\n\n\n' + '\n\n\n\n\n\n
ab
https://zulipchat.comhttps://zulip.com
', [ + TableNode(rows: [ + TableRowNode(cells: [ + TableCellNode(nodes: [TextNode('a')], links: [], textAlign: TextAlign.left), + TableCellNode(nodes: [TextNode('b')], links: [], textAlign: TextAlign.left), + ], isHeader: true), + TableRowNode(cells: [ + TableCellNode(nodes: [LinkNode(nodes: [TextNode('https://zulipchat.com')], url: 'https://zulipchat.com')], links: [], textAlign: null), + TableCellNode(nodes: [LinkNode(nodes: [TextNode('https://zulip.com')], url: 'https://zulip.com')], links: [], textAlign: null), + ], isHeader: false), + ]), + ]); + + static const tableWithLinksInHeaderCells = ContentExample( + 'table with links in header cells', + // https://chat.zulip.org/#narrow/channel/7-test-here/topic/Rajesh/near/1971925 + '| https://zulipchat.com | https://zulip.com |\n| - | - |\n| text | text |', + '\n\n\n\n\n\n\n' + '\n\n\n\n\n\n
https://zulipchat.comhttps://zulip.com
texttext
', [ + TableNode(rows: [ + TableRowNode(cells: [ + TableCellNode(nodes: [LinkNode(nodes: [TextNode('https://zulipchat.com')], url: 'https://zulipchat.com')], links: [], textAlign: TextAlign.left), + TableCellNode(nodes: [LinkNode(nodes: [TextNode('https://zulip.com')], url: 'https://zulip.com')], links: [], textAlign: TextAlign.left), + ], isHeader: true), + TableRowNode(cells: [ + TableCellNode(nodes: [TextNode('text')], links: [], textAlign: null), + TableCellNode(nodes: [TextNode('text')], links: [], textAlign: null), + ], isHeader: false), + ]), + ]); + + static const tableWithImagesInCells = ContentExample( + 'table with images in cells', + // https://chat.zulip.org/#narrow/channel/7-test-here/topic/Rajesh/near/1971244 + '| a | b |\n| - | - |\n| [image2.jpg](/user_uploads/2/5b/4T9c_99X3W2IYB62fKnIzyvk/image2.jpg) | [image3.jpg](/user_uploads/2/65/f1SX7wOIKQ3wdiEP7Nt4fBcQ/image3.jpg) |', + '\n\n\n\n\n\n\n' + '\n\n\n\n\n\n
ab
image2.jpgimage3.jpg
\n' + '
' + '
', [ + TableNode(rows: [ + TableRowNode(cells: [ + TableCellNode(nodes: [TextNode('a')], links: [], textAlign: TextAlign.left), + TableCellNode(nodes: [TextNode('b')], links: [], textAlign: TextAlign.left), + ], isHeader: true), + TableRowNode(cells: [ + TableCellNode(nodes: [LinkNode(nodes: [TextNode('image2.jpg')], url: '/user_uploads/2/5b/4T9c_99X3W2IYB62fKnIzyvk/image2.jpg')], links: [], textAlign: null), + TableCellNode(nodes: [LinkNode(nodes: [TextNode('image3.jpg')], url: '/user_uploads/2/65/f1SX7wOIKQ3wdiEP7Nt4fBcQ/image3.jpg')], links: [], textAlign: null), + ], isHeader: false), + ]), + ImageNodeList([ + ImageNode(srcUrl: '/user_uploads/2/5b/4T9c_99X3W2IYB62fKnIzyvk/image2.jpg', + thumbnailUrl: '/user_uploads/thumbnail/2/5b/4T9c_99X3W2IYB62fKnIzyvk/image2.jpg/840x560.webp', + loading: false, + originalWidth: 2760, + originalHeight: 4912), + ImageNode(srcUrl: '/user_uploads/2/65/f1SX7wOIKQ3wdiEP7Nt4fBcQ/image3.jpg', + thumbnailUrl: '/user_uploads/thumbnail/2/65/f1SX7wOIKQ3wdiEP7Nt4fBcQ/image3.jpg/840x560.webp', + loading: false, + originalWidth: 5564, + originalHeight: 7878), + ]) + ]); } UnimplementedBlockContentNode blockUnimplemented(String html) { @@ -1208,6 +1383,14 @@ void main() { testParseExample(ContentExample.videoInline); testParseExample(ContentExample.videoInlineClassesFlipped); + testParseExample(ContentExample.tableWithSingleRow); + testParseExample(ContentExample.tableWithMultipleRows); + testParseExample(ContentExample.tableWithDifferentTextAligmentInColumns); + testParseExample(ContentExample.tableWithBoldAndItalicHeaders); + testParseExample(ContentExample.tableWithLinksInBodyCells); + testParseExample(ContentExample.tableWithLinksInHeaderCells); + testParseExample(ContentExample.tableWithImagesInCells); + testParse('parse nested lists, quotes, headings, code blocks', // "1. > ###### two\n > * three\n\n four" '
    \n
  1. \n
    \n
    two
    \n
      \n
    • three
    • \n' diff --git a/test/widgets/content_test.dart b/test/widgets/content_test.dart index df40fa47d2..2c0c127cd0 100644 --- a/test/widgets/content_test.dart +++ b/test/widgets/content_test.dart @@ -607,6 +607,30 @@ void main() { ), styleFinder: findWordBold, ); + + testFontWeight('in table column header', + expectedWght: 900, + // | **bold** | + // | - | + // | text | + content: plainContent( + '\n' + '\n\n\n\n\n' + '\n\n\n\n\n' + '
      bold
      text
      '), + styleFinder: findWordBold); + + testFontWeight('in different kind of span in table column header', + expectedWght: 900, + // | *italic **bold*** | + // | - | + // | text | + content: plainContent( + '\n' + '\n\n\n\n\n' + '\n\n\n\n\n' + '
      italic bold
      text
      '), + styleFinder: findWordBold); }); testContentSmoke(ContentExample.emphasis); @@ -1057,4 +1081,47 @@ void main() { debugNetworkImageHttpClientProvider = null; }); }); + + group('MessageTable', () { + testFontWeight('bold column header label', + // | a | b | c | d | + // | - | - | - | - | + // | 1 | 2 | 3 | 4 | + content: plainContent(ContentExample.tableWithSingleRow.html), + expectedWght: 700, + styleFinder: (tester) { + final root = tester.renderObject(find.textContaining('a')).text; + return mergedStyleOfSubstring(root, 'a')!; + }); + + testWidgets('header row background color', (tester) async { + await prepareContent(tester, plainContent(ContentExample.tableWithSingleRow.html)); + final BuildContext context = tester.element(find.byType(Table)); + check(tester.widget(find.byType(Table))).children.first + .decoration + .isA() + .color.equals(ContentTheme.of(context).colorTableHeaderBackground); + }); + + testWidgets('different text alignment in columns', (tester) async { + await prepareContent(tester, + // | default-aligned | left-aligned | center-aligned | right-aligned | + // | - | :- | :-: | -: | + // | text | text | text | text | + // | long text long text long text | long text long text long text | long text long text long text | long text long text long text | + plainContent(ContentExample.tableWithDifferentTextAligmentInColumns.html)); + + final defaultAlignedText = tester.renderObject(find.textContaining('default-aligned')); + check(defaultAlignedText.textAlign).equals(TextAlign.left); + + final leftAlignedText = tester.renderObject(find.textContaining('left-aligned')); + check(leftAlignedText.textAlign).equals(TextAlign.left); + + final centerAlignedText = tester.renderObject(find.textContaining('center-aligned')); + check(centerAlignedText.textAlign).equals(TextAlign.center); + + final rightAlignedText = tester.renderObject(find.textContaining('right-aligned')); + check(rightAlignedText.textAlign).equals(TextAlign.right); + }); + }); }