@@ -504,6 +504,88 @@ class EmbedVideoNode extends BlockContentNode {
504
504
}
505
505
}
506
506
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 (DiagnosticsProperty <bool >('isHeader' , isHeader));
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
+ /// The text-alignment that applies to text inside the table cell.
547
+ ///
548
+ /// See GitHub-flavored Markdown:
549
+ /// https://github.github.com/gfm/#tables-extension-
550
+ // In Markdown, alignment is defined per column using the delimiter row.
551
+ // However, the generated HTML specifies alignment for each cell in a row
552
+ // individually, that matches the UI widget implementation which is also
553
+ // row based and needs alignment information to be per cell.
554
+ enum TableCellTextAlignment {
555
+ /// Text inside the table cell is left-aligned.
556
+ // Represented in markdown as `|: --- |` for the specific column in the delimeter row.
557
+ // TODO(i18n) RTL issues? https://github.com/zulip/zulip/issues/32265
558
+ left,
559
+ /// Text inside the table cell is center-aligned.
560
+ // Represented in markdown as `|: --- :|` for the specific column in the delimeter row.
561
+ center,
562
+ /// Text inside the table cell is right-aligned.
563
+ // Represented in markdown as `| --- :|` for the specific column in the delimeter row.
564
+ // TODO(i18n) RTL issues? https://github.com/zulip/zulip/issues/32265
565
+ right,
566
+ /// Text inside the table cell is aligned the default way.
567
+ // Represented in markdown as `| --- |` for the specific column in the delimeter row.
568
+ defaults
569
+ }
570
+
571
+ class TableCellNode extends BlockInlineContainerNode {
572
+ const TableCellNode ({
573
+ super .debugHtmlNode,
574
+ required super .nodes,
575
+ required super .links,
576
+ required this .textAlignment,
577
+ });
578
+
579
+ /// The text-alignment for text inside the table cell.
580
+ final TableCellTextAlignment textAlignment;
581
+
582
+ @override
583
+ void debugFillProperties (DiagnosticPropertiesBuilder properties) {
584
+ super .debugFillProperties (properties);
585
+ properties.add (DiagnosticsProperty <TableCellTextAlignment >('textAlign' , textAlignment));
586
+ }
587
+ }
588
+
507
589
/// A content node that expects an inline layout context from its parent.
508
590
///
509
591
/// When rendered into a Flutter widget tree, an inline content node
@@ -1222,6 +1304,136 @@ class _ZulipContentParser {
1222
1304
return EmbedVideoNode (hrefUrl: href, previewImageSrcUrl: imgSrc, debugHtmlNode: debugHtmlNode);
1223
1305
}
1224
1306
1307
+ BlockContentNode parseTableContent (dom.Element tableElement) {
1308
+ assert (_debugParserContext == _ParserContext .block);
1309
+ assert (tableElement.localName == 'table'
1310
+ && tableElement.className.isEmpty);
1311
+
1312
+ TableCellNode ? parseTableCell (dom.Element node, bool isHeader) {
1313
+ assert (node.localName == (isHeader ? 'th' : 'td' ));
1314
+ assert (node.className.isEmpty);
1315
+ assert (node.attributes.length <= 1 );
1316
+
1317
+ final cellStyle = node.attributes['style' ];
1318
+ final TableCellTextAlignment ? textAlignment;
1319
+ switch (cellStyle) {
1320
+ case null :
1321
+ textAlignment = TableCellTextAlignment .defaults;
1322
+ case 'text-align: left;' :
1323
+ textAlignment = TableCellTextAlignment .left;
1324
+ case 'text-align: center;' :
1325
+ textAlignment = TableCellTextAlignment .center;
1326
+ case 'text-align: right;' :
1327
+ textAlignment = TableCellTextAlignment .right;
1328
+ default :
1329
+ return null ;
1330
+ }
1331
+ final parsed = parseBlockInline (node.nodes);
1332
+ return TableCellNode (
1333
+ nodes: parsed.nodes,
1334
+ links: parsed.links,
1335
+ textAlignment: textAlignment);
1336
+ }
1337
+
1338
+ List <TableCellNode >? parseTableCells (dom.NodeList cellNodes, bool isHeader) {
1339
+ final cells = < TableCellNode > [];
1340
+ for (final node in cellNodes) {
1341
+ if (node is dom.Text && node.text == '\n ' ) continue ;
1342
+
1343
+ if (node is ! dom.Element ) return null ;
1344
+ if (node.localName != (isHeader ? 'th' : 'td' )) return null ;
1345
+ if (node.className.isNotEmpty) return null ;
1346
+ if (node.attributes.length > 1 ) return null ;
1347
+
1348
+ final cell = parseTableCell (node, isHeader);
1349
+ if (cell == null ) return null ;
1350
+ cells.add (cell);
1351
+ }
1352
+ return cells;
1353
+ }
1354
+
1355
+ TableRowNode ? parseTableHeaderRow (dom.NodeList theadNodes) {
1356
+ if (theadNodes case [
1357
+ dom.Text (data: '\n ' ),
1358
+ dom.Element (localName: 'tr' ) && final rowElement,
1359
+ dom.Text (data: '\n ' ),
1360
+ ]) {
1361
+ if (rowElement.className.isNotEmpty) return null ;
1362
+ if (rowElement.attributes.isNotEmpty) return null ;
1363
+ if (rowElement.nodes.isEmpty) return null ;
1364
+
1365
+ final cells = parseTableCells (rowElement.nodes, true );
1366
+ if (cells == null ) return null ;
1367
+ return TableRowNode (cells: cells, isHeader: true );
1368
+ } else {
1369
+ return null ;
1370
+ }
1371
+ }
1372
+
1373
+ bool parseTableBodyRows (dom.NodeList tbodyNodes, int headerColumnCount, List <TableRowNode > parsedRows) {
1374
+ for (final node in tbodyNodes) {
1375
+ if (node is dom.Text && node.text == '\n ' ) continue ;
1376
+
1377
+ if (node is ! dom.Element ) return false ;
1378
+ if (node.localName != 'tr' ) return false ;
1379
+ if (node.className.isNotEmpty) return false ;
1380
+ if (node.attributes.isNotEmpty) return false ;
1381
+ if (node.nodes.isEmpty) return false ;
1382
+
1383
+ final cells = parseTableCells (node.nodes, false );
1384
+ if (cells == null ) return false ;
1385
+
1386
+ // Ensure that the number of columns in this row matches
1387
+ // the header row.
1388
+ if (cells.length != headerColumnCount) return false ;
1389
+ parsedRows.add (TableRowNode (cells: cells, isHeader: false ));
1390
+ }
1391
+ return true ;
1392
+ }
1393
+
1394
+ final TableNode ? tableNode = (() {
1395
+ late final int headerColumnCount;
1396
+ final parsedRows = < TableRowNode > [];
1397
+ for (final node in tableElement.nodes) {
1398
+ if (node is dom.Text && node.text == '\n ' ) continue ;
1399
+
1400
+ switch (node) {
1401
+ case dom.Element (localName: 'thead' ) && final theadElement:
1402
+ if (theadElement.className.isNotEmpty) return null ;
1403
+ if (theadElement.attributes.isNotEmpty) return null ;
1404
+ if (theadElement.nodes.isEmpty) return null ;
1405
+
1406
+ // The first row to be parsed should be the header row.
1407
+ if (parsedRows.isNotEmpty) return null ;
1408
+
1409
+ final headerRow = parseTableHeaderRow (theadElement.nodes);
1410
+ if (headerRow == null ) return null ;
1411
+ headerColumnCount = headerRow.cells.length;
1412
+ parsedRows.add (headerRow);
1413
+
1414
+ case dom.Element (localName: 'tbody' ) && final tbodyElement:
1415
+ if (tbodyElement.className.isNotEmpty) return null ;
1416
+ if (tbodyElement.attributes.isNotEmpty) return null ;
1417
+ if (tbodyElement.nodes.isEmpty) return null ;
1418
+
1419
+ // The header row should have been parsed already.
1420
+ if (parsedRows.isEmpty) return null ;
1421
+
1422
+ final nodes = tbodyElement.nodes;
1423
+ if (! parseTableBodyRows (nodes, headerColumnCount, parsedRows)) {
1424
+ return null ;
1425
+ }
1426
+
1427
+ default :
1428
+ return null ;
1429
+ }
1430
+ }
1431
+ return TableNode (rows: parsedRows);
1432
+ })();
1433
+
1434
+ return tableNode ?? UnimplementedBlockContentNode (htmlNode: tableElement);
1435
+ }
1436
+
1225
1437
BlockContentNode parseBlockContent (dom.Node node) {
1226
1438
assert (_debugParserContext == _ParserContext .block);
1227
1439
final debugHtmlNode = kDebugMode ? node : null ;
@@ -1290,6 +1502,10 @@ class _ZulipContentParser {
1290
1502
parseBlockContentList (element.nodes));
1291
1503
}
1292
1504
1505
+ if (localName == 'table' && className.isEmpty) {
1506
+ return parseTableContent (element);
1507
+ }
1508
+
1293
1509
if (localName == 'div' && className == 'spoiler-block' ) {
1294
1510
return parseSpoilerNode (element);
1295
1511
}
@@ -1335,6 +1551,7 @@ class _ZulipContentParser {
1335
1551
case 'h6' :
1336
1552
case 'blockquote' :
1337
1553
case 'div' :
1554
+ case 'table' :
1338
1555
return false ;
1339
1556
default :
1340
1557
return true ;
0 commit comments