|
| 1 | +import 'dart:ui'; |
| 2 | + |
1 | 3 | import 'package:collection/collection.dart';
|
2 | 4 | import 'package:flutter/foundation.dart';
|
3 | 5 | import 'package:html/dom.dart' as dom;
|
@@ -729,6 +731,63 @@ class GlobalTimeNode extends InlineContentNode {
|
729 | 731 | }
|
730 | 732 | }
|
731 | 733 |
|
| 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 | + |
732 | 791 | ////////////////////////////////////////////////////////////////
|
733 | 792 |
|
734 | 793 | /// What sort of nodes a [_ZulipContentParser] is currently expecting to find.
|
@@ -1220,6 +1279,118 @@ class _ZulipContentParser {
|
1220 | 1279 | return EmbedVideoNode(hrefUrl: href, previewImageSrcUrl: imgSrc, debugHtmlNode: debugHtmlNode);
|
1221 | 1280 | }
|
1222 | 1281 |
|
| 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 | + |
1223 | 1394 | BlockContentNode parseBlockContent(dom.Node node) {
|
1224 | 1395 | assert(_debugParserContext == _ParserContext.block);
|
1225 | 1396 | final debugHtmlNode = kDebugMode ? node : null;
|
@@ -1288,6 +1459,10 @@ class _ZulipContentParser {
|
1288 | 1459 | parseBlockContentList(element.nodes));
|
1289 | 1460 | }
|
1290 | 1461 |
|
| 1462 | + if (localName == 'table' && className.isEmpty) { |
| 1463 | + return parseTableContent(element); |
| 1464 | + } |
| 1465 | + |
1291 | 1466 | if (localName == 'div' && className == 'spoiler-block') {
|
1292 | 1467 | return parseSpoilerNode(element);
|
1293 | 1468 | }
|
|
0 commit comments