Skip to content

Commit 8bbca00

Browse files
content: Handle <table> elements
Fixes: #360
1 parent ffa41be commit 8bbca00

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.
@@ -1220,6 +1279,118 @@ class _ZulipContentParser {
12201279
return EmbedVideoNode(hrefUrl: href, previewImageSrcUrl: imgSrc, debugHtmlNode: debugHtmlNode);
12211280
}
12221281

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

1462+
if (localName == 'table' && className.isEmpty) {
1463+
return parseTableContent(element);
1464+
}
1465+
12911466
if (localName == 'div' && className == 'spoiler-block') {
12921467
return parseSpoilerNode(element);
12931468
}

0 commit comments

Comments
 (0)