Skip to content

Commit 9d02ab8

Browse files
rajveermalviyagnprice
authored andcommitted
content: Handle <table> elements
Fixes: #360
1 parent 0fd1d64 commit 9d02ab8

File tree

5 files changed

+450
-0
lines changed

5 files changed

+450
-0
lines changed

lib/model/content.dart

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -504,6 +504,53 @@ class EmbedVideoNode extends BlockContentNode {
504504
}
505505
}
506506

507+
class TableNode extends BlockContentNode {
508+
const TableNode({super.debugHtmlNode, required this.rows});
509+
510+
final List<TableRowNode> rows;
511+
512+
@override
513+
List<DiagnosticsNode> debugDescribeChildren() {
514+
return rows
515+
.mapIndexed((i, row) => row.toDiagnosticsNode(name: 'row $i'))
516+
.toList();
517+
}
518+
}
519+
520+
class TableRowNode extends BlockContentNode {
521+
const TableRowNode({
522+
super.debugHtmlNode,
523+
required this.cells,
524+
required this.isHeader,
525+
});
526+
527+
final List<TableCellNode> cells;
528+
529+
/// Indicates whether this row is the header row.
530+
final bool isHeader;
531+
532+
@override
533+
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
534+
super.debugFillProperties(properties);
535+
properties.add(FlagProperty('isHeader', value: isHeader, ifTrue: "is header"));
536+
}
537+
538+
@override
539+
List<DiagnosticsNode> debugDescribeChildren() {
540+
return cells
541+
.mapIndexed((i, cell) => cell.toDiagnosticsNode(name: 'cell $i'))
542+
.toList();
543+
}
544+
}
545+
546+
class TableCellNode extends BlockInlineContainerNode {
547+
const TableCellNode({
548+
super.debugHtmlNode,
549+
required super.nodes,
550+
required super.links,
551+
});
552+
}
553+
507554
/// A content node that expects an inline layout context from its parent.
508555
///
509556
/// When rendered into a Flutter widget tree, an inline content node
@@ -1222,6 +1269,99 @@ class _ZulipContentParser {
12221269
return EmbedVideoNode(hrefUrl: href, previewImageSrcUrl: imgSrc, debugHtmlNode: debugHtmlNode);
12231270
}
12241271

1272+
BlockContentNode parseTableContent(dom.Element tableElement) {
1273+
assert(_debugParserContext == _ParserContext.block);
1274+
assert(tableElement.localName == 'table'
1275+
&& tableElement.className.isEmpty);
1276+
1277+
TableCellNode? parseTableCell(dom.Element node, bool isHeader) {
1278+
assert(node.localName == (isHeader ? 'th' : 'td'));
1279+
assert(node.className.isEmpty);
1280+
1281+
final parsed = parseBlockInline(node.nodes);
1282+
return TableCellNode(
1283+
nodes: parsed.nodes,
1284+
links: parsed.links);
1285+
}
1286+
1287+
List<TableCellNode>? parseTableCells(dom.NodeList cellNodes, bool isHeader) {
1288+
final cells = <TableCellNode>[];
1289+
for (final node in cellNodes) {
1290+
if (node is dom.Text && node.text == '\n') continue;
1291+
1292+
if (node is! dom.Element) return null;
1293+
if (node.localName != (isHeader ? 'th' : 'td')) return null;
1294+
if (node.className.isNotEmpty) return null;
1295+
1296+
final cell = parseTableCell(node, isHeader);
1297+
if (cell == null) return null;
1298+
cells.add(cell);
1299+
}
1300+
return cells;
1301+
}
1302+
1303+
final TableNode? tableNode = (() {
1304+
if (tableElement.nodes case [
1305+
dom.Text(data: '\n'),
1306+
dom.Element(localName: 'thead') && final theadElement,
1307+
dom.Text(data: '\n'),
1308+
dom.Element(localName: 'tbody') && final tbodyElement,
1309+
dom.Text(data: '\n'),
1310+
]) {
1311+
if (theadElement.className.isNotEmpty) return null;
1312+
if (theadElement.nodes.isEmpty) return null;
1313+
if (tbodyElement.className.isNotEmpty) return null;
1314+
if (tbodyElement.nodes.isEmpty) return null;
1315+
1316+
final int headerColumnCount;
1317+
final parsedRows = <TableRowNode>[];
1318+
1319+
// Parse header row element.
1320+
if (theadElement.nodes case [
1321+
dom.Text(data: '\n'),
1322+
dom.Element(localName: 'tr') && final rowElement,
1323+
dom.Text(data: '\n'),
1324+
]) {
1325+
if (rowElement.className.isNotEmpty) return null;
1326+
if (rowElement.nodes.isEmpty) return null;
1327+
1328+
final cells = parseTableCells(rowElement.nodes, true);
1329+
if (cells == null) return null;
1330+
headerColumnCount = cells.length;
1331+
parsedRows.add(TableRowNode(cells: cells, isHeader: true));
1332+
} else {
1333+
// TODO(dart): simplify after https://github.com/dart-lang/language/issues/2537
1334+
return null;
1335+
}
1336+
1337+
// Parse body row elements.
1338+
for (final node in tbodyElement.nodes) {
1339+
if (node is dom.Text && node.text == '\n') continue;
1340+
1341+
if (node is! dom.Element) return null;
1342+
if (node.localName != 'tr') return null;
1343+
if (node.className.isNotEmpty) return null;
1344+
if (node.nodes.isEmpty) return null;
1345+
1346+
final cells = parseTableCells(node.nodes, false);
1347+
if (cells == null) return null;
1348+
1349+
// Ensure that the number of columns in this row matches
1350+
// the header row.
1351+
if (cells.length != headerColumnCount) return null;
1352+
parsedRows.add(TableRowNode(cells: cells, isHeader: false));
1353+
}
1354+
1355+
return TableNode(rows: parsedRows);
1356+
} else {
1357+
// TODO(dart): simplify after https://github.com/dart-lang/language/issues/2537
1358+
return null;
1359+
}
1360+
})();
1361+
1362+
return tableNode ?? UnimplementedBlockContentNode(htmlNode: tableElement);
1363+
}
1364+
12251365
BlockContentNode parseBlockContent(dom.Node node) {
12261366
assert(_debugParserContext == _ParserContext.block);
12271367
final debugHtmlNode = kDebugMode ? node : null;
@@ -1290,6 +1430,10 @@ class _ZulipContentParser {
12901430
parseBlockContentList(element.nodes));
12911431
}
12921432

1433+
if (localName == 'table' && className.isEmpty) {
1434+
return parseTableContent(element);
1435+
}
1436+
12931437
if (localName == 'div' && className == 'spoiler-block') {
12941438
return parseSpoilerNode(element);
12951439
}
@@ -1334,6 +1478,7 @@ class _ZulipContentParser {
13341478
case 'h5':
13351479
case 'h6':
13361480
case 'blockquote':
1481+
case 'table':
13371482
case 'div':
13381483
return false;
13391484
default:

lib/widgets/content.dart

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,8 @@ class ContentTheme extends ThemeExtension<ContentTheme> {
4848
colorPollVoteCountBackground: const HSLColor.fromAHSL(1, 0, 0, 1).toColor(),
4949
colorPollVoteCountBorder: const HSLColor.fromAHSL(1, 156, 0.28, 0.7).toColor(),
5050
colorPollVoteCountText: const HSLColor.fromAHSL(1, 156, 0.41, 0.4).toColor(),
51+
colorTableCellBorder: const HSLColor.fromAHSL(1, 0, 0, 0.80).toColor(),
52+
colorTableHeaderBackground: const HSLColor.fromAHSL(1, 0, 0, 0.93).toColor(),
5153
colorThematicBreak: const HSLColor.fromAHSL(1, 0, 0, .87).toColor(),
5254
textStylePlainParagraph: _plainParagraphCommon(context).copyWith(
5355
color: const HSLColor.fromAHSL(1, 0, 0, 0.15).toColor(),
@@ -77,6 +79,8 @@ class ContentTheme extends ThemeExtension<ContentTheme> {
7779
colorPollVoteCountBackground: const HSLColor.fromAHSL(0.2, 0, 0, 0).toColor(),
7880
colorPollVoteCountBorder: const HSLColor.fromAHSL(1, 185, 0.35, 0.35).toColor(),
7981
colorPollVoteCountText: const HSLColor.fromAHSL(1, 185, 0.35, 0.65).toColor(),
82+
colorTableCellBorder: const HSLColor.fromAHSL(1, 0, 0, 0.33).toColor(),
83+
colorTableHeaderBackground: const HSLColor.fromAHSL(0.5, 0, 0, 0).toColor(),
8084
colorThematicBreak: const HSLColor.fromAHSL(1, 0, 0, .87).toColor().withValues(alpha: 0.2),
8185
textStylePlainParagraph: _plainParagraphCommon(context).copyWith(
8286
color: const HSLColor.fromAHSL(1, 0, 0, 0.85).toColor(),
@@ -105,6 +109,8 @@ class ContentTheme extends ThemeExtension<ContentTheme> {
105109
required this.colorPollVoteCountBackground,
106110
required this.colorPollVoteCountBorder,
107111
required this.colorPollVoteCountText,
112+
required this.colorTableCellBorder,
113+
required this.colorTableHeaderBackground,
108114
required this.colorThematicBreak,
109115
required this.textStylePlainParagraph,
110116
required this.codeBlockTextStyles,
@@ -134,6 +140,8 @@ class ContentTheme extends ThemeExtension<ContentTheme> {
134140
final Color colorPollVoteCountBackground;
135141
final Color colorPollVoteCountBorder;
136142
final Color colorPollVoteCountText;
143+
final Color colorTableCellBorder;
144+
final Color colorTableHeaderBackground;
137145
final Color colorThematicBreak;
138146

139147
/// The complete [TextStyle] we use for plain, unstyled paragraphs.
@@ -189,6 +197,8 @@ class ContentTheme extends ThemeExtension<ContentTheme> {
189197
Color? colorPollVoteCountBackground,
190198
Color? colorPollVoteCountBorder,
191199
Color? colorPollVoteCountText,
200+
Color? colorTableCellBorder,
201+
Color? colorTableHeaderBackground,
192202
Color? colorThematicBreak,
193203
TextStyle? textStylePlainParagraph,
194204
CodeBlockTextStyles? codeBlockTextStyles,
@@ -208,6 +218,8 @@ class ContentTheme extends ThemeExtension<ContentTheme> {
208218
colorPollVoteCountBackground: colorPollVoteCountBackground ?? this.colorPollVoteCountBackground,
209219
colorPollVoteCountBorder: colorPollVoteCountBorder ?? this.colorPollVoteCountBorder,
210220
colorPollVoteCountText: colorPollVoteCountText ?? this.colorPollVoteCountText,
221+
colorTableCellBorder: colorTableCellBorder ?? this.colorTableCellBorder,
222+
colorTableHeaderBackground: colorTableHeaderBackground ?? this.colorTableHeaderBackground,
211223
colorThematicBreak: colorThematicBreak ?? this.colorThematicBreak,
212224
textStylePlainParagraph: textStylePlainParagraph ?? this.textStylePlainParagraph,
213225
codeBlockTextStyles: codeBlockTextStyles ?? this.codeBlockTextStyles,
@@ -234,6 +246,8 @@ class ContentTheme extends ThemeExtension<ContentTheme> {
234246
colorPollVoteCountBackground: Color.lerp(colorPollVoteCountBackground, other.colorPollVoteCountBackground, t)!,
235247
colorPollVoteCountBorder: Color.lerp(colorPollVoteCountBorder, other.colorPollVoteCountBorder, t)!,
236248
colorPollVoteCountText: Color.lerp(colorPollVoteCountText, other.colorPollVoteCountText, t)!,
249+
colorTableCellBorder: Color.lerp(colorTableCellBorder, other.colorTableCellBorder, t)!,
250+
colorTableHeaderBackground: Color.lerp(colorTableHeaderBackground, other.colorTableHeaderBackground, t)!,
237251
colorThematicBreak: Color.lerp(colorThematicBreak, other.colorThematicBreak, t)!,
238252
textStylePlainParagraph: TextStyle.lerp(textStylePlainParagraph, other.textStylePlainParagraph, t)!,
239253
codeBlockTextStyles: CodeBlockTextStyles.lerp(codeBlockTextStyles, other.codeBlockTextStyles, t),
@@ -324,6 +338,21 @@ class BlockContentList extends StatelessWidget {
324338
}(),
325339
InlineVideoNode() => MessageInlineVideo(node: node),
326340
EmbedVideoNode() => MessageEmbedVideo(node: node),
341+
TableNode() => MessageTable(node: node),
342+
TableRowNode() => () {
343+
assert(false,
344+
"[TableRowNode] not allowed in [BlockContentList]. "
345+
"It should be wrapped in [TableNode]."
346+
);
347+
return const SizedBox.shrink();
348+
}(),
349+
TableCellNode() => () {
350+
assert(false,
351+
"[TableCellNode] not allowed in [BlockContentList]. "
352+
"It should be wrapped in [TableRowNode]."
353+
);
354+
return const SizedBox.shrink();
355+
}(),
327356
UnimplementedBlockContentNode() =>
328357
Text.rich(_errorUnimplemented(node, context: context)),
329358
};
@@ -1196,6 +1225,62 @@ class GlobalTime extends StatelessWidget {
11961225
}
11971226
}
11981227

1228+
class MessageTable extends StatelessWidget {
1229+
const MessageTable({super.key, required this.node});
1230+
1231+
final TableNode node;
1232+
1233+
@override
1234+
Widget build(BuildContext context) {
1235+
final contentTheme = ContentTheme.of(context);
1236+
return SingleChildScrollViewWithScrollbar(
1237+
scrollDirection: Axis.horizontal,
1238+
child: Padding(
1239+
padding: const EdgeInsets.symmetric(horizontal: 5),
1240+
child: Table(
1241+
border: TableBorder.all(
1242+
width: 1,
1243+
style: BorderStyle.solid,
1244+
color: contentTheme.colorTableCellBorder),
1245+
defaultColumnWidth: const IntrinsicColumnWidth(),
1246+
children: List.unmodifiable(node.rows.map((row) => TableRow(
1247+
decoration: row.isHeader
1248+
? BoxDecoration(color: contentTheme.colorTableHeaderBackground)
1249+
: null,
1250+
children: List.unmodifiable(row.cells.map((cell) =>
1251+
MessageTableCell(node: cell, isHeader: row.isHeader)))))))));
1252+
}
1253+
}
1254+
1255+
class MessageTableCell extends StatelessWidget {
1256+
const MessageTableCell({super.key, required this.node, required this.isHeader});
1257+
1258+
final TableCellNode node;
1259+
final bool isHeader;
1260+
1261+
@override
1262+
Widget build(BuildContext context) {
1263+
return TableCell(
1264+
verticalAlignment: TableCellVerticalAlignment.middle,
1265+
child: Padding(
1266+
// Web has 4px padding and 1px border on all sides.
1267+
// In web, the 1px border grows each cell by 0.5px in all directions.
1268+
// Our border doesn't affect the layout, it's just painted on,
1269+
// so we add 0.5px on all sides to match web.
1270+
// Ref: https://github.com/flutter/flutter/issues/78691
1271+
padding: const EdgeInsets.all(4 + 0.5),
1272+
child: node.nodes.isEmpty
1273+
? const SizedBox.shrink()
1274+
: _buildBlockInlineContainer(
1275+
node: node,
1276+
style: !isHeader
1277+
? DefaultTextStyle.of(context).style
1278+
: DefaultTextStyle.of(context).style
1279+
.merge(weightVariableTextStyle(context, wght: 700))),
1280+
));
1281+
}
1282+
}
1283+
11991284
void _launchUrl(BuildContext context, String urlString) async {
12001285
DialogStatus showError(BuildContext context, String? message) {
12011286
return showErrorDialog(context: context,

test/flutter_checks.dart

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,3 +146,15 @@ extension MaterialChecks on Subject<Material> {
146146
extension InputDecorationChecks on Subject<InputDecoration> {
147147
Subject<String?> get hintText => has((x) => x.hintText, 'hintText');
148148
}
149+
150+
extension BoxDecorationChecks on Subject<BoxDecoration> {
151+
Subject<Color?> get color => has((x) => x.color, 'color');
152+
}
153+
154+
extension TableRowChecks on Subject<TableRow> {
155+
Subject<Decoration?> get decoration => has((x) => x.decoration, 'decoration');
156+
}
157+
158+
extension TableChecks on Subject<Table> {
159+
Subject<List<TableRow>> get children => has((x) => x.children, 'children');
160+
}

0 commit comments

Comments
 (0)