Skip to content

Commit 9450a99

Browse files
content: Handle <table> elements
Fixes: #360
1 parent fe16ad1 commit 9450a99

File tree

5 files changed

+533
-5
lines changed

5 files changed

+533
-5
lines changed

lib/model/content.dart

Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import 'dart:ui';
2+
13
import 'package:collection/collection.dart';
24
import 'package:flutter/foundation.dart';
35
import 'package:html/dom.dart' as dom;
@@ -729,6 +731,63 @@ class GlobalTimeNode extends InlineContentNode {
729731
}
730732
}
731733

734+
class TableNode extends BlockContentNode {
735+
const TableNode({super.debugHtmlNode, required this.rows});
736+
737+
final List<TableRowNode> rows;
738+
739+
@override
740+
List<DiagnosticsNode> debugDescribeChildren() {
741+
return rows
742+
.mapIndexed((i, row) => row.toDiagnosticsNode(name: 'row $i'))
743+
.toList();
744+
}
745+
}
746+
747+
class TableRowNode extends BlockContentNode {
748+
const TableRowNode({
749+
super.debugHtmlNode,
750+
required this.cells,
751+
required this.isHeader,
752+
});
753+
754+
final List<TableCellNode> cells;
755+
756+
/// Indicates whether this row is the header row.
757+
final bool isHeader;
758+
759+
@override
760+
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
761+
super.debugFillProperties(properties);
762+
properties.add(DiagnosticsProperty<bool>('isHeader', isHeader));
763+
}
764+
765+
@override
766+
List<DiagnosticsNode> debugDescribeChildren() {
767+
return cells
768+
.mapIndexed((i, cell) => cell.toDiagnosticsNode(name: 'cell $i'))
769+
.toList();
770+
}
771+
}
772+
773+
class TableCellNode extends BlockInlineContainerNode {
774+
const TableCellNode({
775+
super.debugHtmlNode,
776+
required super.nodes,
777+
required super.links,
778+
required this.textAlign,
779+
});
780+
781+
/// The [TextAlign] alignment for the content within this cell.
782+
final TextAlign? textAlign;
783+
784+
@override
785+
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
786+
super.debugFillProperties(properties);
787+
properties.add(DiagnosticsProperty<TextAlign>('textAlign', textAlign));
788+
}
789+
}
790+
732791
////////////////////////////////////////////////////////////////
733792
734793
/// What sort of nodes a [_ZulipContentParser] is currently expecting to find.
@@ -1222,6 +1281,118 @@ class _ZulipContentParser {
12221281
return EmbedVideoNode(hrefUrl: href, previewImageSrcUrl: imgSrc, debugHtmlNode: debugHtmlNode);
12231282
}
12241283

1284+
BlockContentNode parseTableContent(dom.Element tableElement) {
1285+
assert(_debugParserContext == _ParserContext.block);
1286+
assert(tableElement.localName == 'table'
1287+
&& tableElement.className.isEmpty);
1288+
1289+
TableCellNode? parseTableCell(dom.Element node, bool isHeader) {
1290+
assert(node.localName == (isHeader ? 'th' : 'td'));
1291+
assert(node.className.isEmpty);
1292+
1293+
final cellStyle = node.attributes['style'];
1294+
final TextAlign? textAlign;
1295+
switch (cellStyle) {
1296+
case null:
1297+
// Default text alignment specified in for `tr > th` element
1298+
// in `web/styles/rendered_markdown.css`:
1299+
// https://github.com/zulip/zulip/blob/d556c0e0a5e9e8ba0ff548354bf2ba6ef2c97d4b/web/styles/rendered_markdown.css#L140
1300+
textAlign = isHeader ? TextAlign.left : null;
1301+
case 'text-align: left;':
1302+
textAlign = TextAlign.left;
1303+
case 'text-align: center;':
1304+
textAlign = TextAlign.center;
1305+
case 'text-align: right;':
1306+
textAlign = TextAlign.right;
1307+
default:
1308+
return null;
1309+
}
1310+
final parsed = parseBlockInline(node.nodes);
1311+
return TableCellNode(
1312+
nodes: parsed.nodes,
1313+
links: parsed.links,
1314+
textAlign: textAlign);
1315+
}
1316+
1317+
List<TableCellNode>? parseTableCells(dom.NodeList cellNodes, bool isHeader) {
1318+
final cells = <TableCellNode>[];
1319+
for (final node in cellNodes) {
1320+
if (node is dom.Text && (node.text == '\n')) continue;
1321+
1322+
if (node is! dom.Element) return null;
1323+
if (node.localName != (isHeader ? 'th' : 'td')) return null;
1324+
if (node.className.isNotEmpty) return null;
1325+
1326+
final cell = parseTableCell(node, isHeader);
1327+
if (cell == null) return null;
1328+
cells.add(cell);
1329+
}
1330+
return cells;
1331+
}
1332+
1333+
bool parseTableBodyRows(dom.NodeList tbodyNodes, int headerColumnCount, List<TableRowNode> rows) {
1334+
for (final node in tbodyNodes) {
1335+
if (node is dom.Text && (node.text == '\n')) continue;
1336+
1337+
if (node is! dom.Element) return false;
1338+
if (node.localName != 'tr') return false;
1339+
if (node.className.isNotEmpty) return false;
1340+
if (node.nodes.isEmpty) return false;
1341+
1342+
final cells = parseTableCells(node.nodes, false);
1343+
if (cells == null) return false;
1344+
1345+
// Ensure that the number of columns in this row matches
1346+
// the header row.
1347+
if (cells.length != headerColumnCount) return false;
1348+
rows.add(TableRowNode(cells: cells, isHeader: false));
1349+
}
1350+
return true;
1351+
}
1352+
1353+
TableRowNode? parseTableHeaderRow(dom.NodeList theadNodes) {
1354+
if (theadNodes case [
1355+
dom.Text(data: '\n'),
1356+
dom.Element(localName: 'tr', className: '', nodes: [...] && var nodes),
1357+
dom.Text(data: '\n'),
1358+
]) {
1359+
final cells = parseTableCells(nodes, true);
1360+
if (cells == null) return null;
1361+
return TableRowNode(cells: cells, isHeader: true);
1362+
} else {
1363+
return null;
1364+
}
1365+
}
1366+
1367+
late final int headerColumnCount;
1368+
final rows = <TableRowNode>[];
1369+
for (final node in tableElement.nodes) {
1370+
if (node is dom.Text && (node.text == '\n')) continue;
1371+
if (node is! dom.Element) {
1372+
return UnimplementedBlockContentNode(htmlNode: tableElement);
1373+
}
1374+
1375+
switch (node) {
1376+
case dom.Element(localName: 'thead', className: '', nodes: [...] && var nodes):
1377+
final headerRow = parseTableHeaderRow(nodes);
1378+
if (headerRow == null) {
1379+
return UnimplementedBlockContentNode(htmlNode: tableElement);
1380+
}
1381+
headerColumnCount = headerRow.cells.length;
1382+
rows.add(headerRow);
1383+
1384+
case dom.Element(localName: 'tbody', className: '', nodes: [...] && var nodes):
1385+
if (!parseTableBodyRows(nodes, headerColumnCount, rows)) {
1386+
return UnimplementedBlockContentNode(htmlNode: tableElement);
1387+
}
1388+
1389+
default:
1390+
return UnimplementedBlockContentNode(htmlNode: tableElement);
1391+
}
1392+
}
1393+
return TableNode(rows: rows);
1394+
}
1395+
12251396
BlockContentNode parseBlockContent(dom.Node node) {
12261397
assert(_debugParserContext == _ParserContext.block);
12271398
final debugHtmlNode = kDebugMode ? node : null;
@@ -1290,6 +1461,10 @@ class _ZulipContentParser {
12901461
parseBlockContentList(element.nodes));
12911462
}
12921463

1464+
if (localName == 'table' && className.isEmpty) {
1465+
return parseTableContent(element);
1466+
}
1467+
12931468
if (localName == 'div' && className == 'spoiler-block') {
12941469
return parseSpoilerNode(element);
12951470
}

0 commit comments

Comments
 (0)