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