Skip to content

content: Handle <table> elements #1031

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 2 commits into from
Nov 25, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
190 changes: 190 additions & 0 deletions lib/model/content.dart
Original file line number Diff line number Diff line change
Expand Up @@ -504,6 +504,83 @@ class EmbedVideoNode extends BlockContentNode {
}
}

class TableNode extends BlockContentNode {
const TableNode({super.debugHtmlNode, required this.rows});

final List<TableRowNode> rows;

@override
List<DiagnosticsNode> debugDescribeChildren() {
return rows
.mapIndexed((i, row) => row.toDiagnosticsNode(name: 'row $i'))
.toList();
}
}

class TableRowNode extends BlockContentNode {
const TableRowNode({
super.debugHtmlNode,
required this.cells,
required this.isHeader,
});

final List<TableCellNode> cells;

/// Indicates whether this row is the header row.
final bool isHeader;

@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
super.debugFillProperties(properties);
properties.add(FlagProperty('isHeader', value: isHeader, ifTrue: "is header"));
}

@override
List<DiagnosticsNode> debugDescribeChildren() {
return cells
.mapIndexed((i, cell) => cell.toDiagnosticsNode(name: 'cell $i'))
.toList();
}
}

// The text-alignment setting that applies to a cell's column, from the delimiter row.
//
// See GitHub-flavored Markdown:
// https://github.github.com/gfm/#tables-extension-
enum TableColumnTextAlignment {
/// All cells' text left-aligned, represented in Markdown as `|: --- |`.
left, // TODO(i18n) RTL issues? https://github.com/zulip/zulip/issues/32265
/// All cells' text center-aligned, represented in Markdown as `|: --- :|`.
center,
/// All cells' text right-aligned, represented in Markdown as `| --- :|`.
right, // TODO(i18n) RTL issues? https://github.com/zulip/zulip/issues/32265
/// Cells' text aligned the default way, represented in Markdown as `| --- |`.
defaults
}

class TableCellNode extends BlockInlineContainerNode {
const TableCellNode({
super.debugHtmlNode,
required super.nodes,
required super.links,
required this.textAlignment,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit:

content: Handle column `text-alignment` in `<table>`

The backticks on "text-alignment" mark it as code, not prose. When a name is formatted as code, it should match exactly the name of something it's referring to in the code. But I don't think there's anything in the code called text-alignment.

Could make it not code, just normal English prose:

content: Handle column text-alignment in <table>

Or could refer to a name found in the code; for example:

content: Handle text-align in <table>

(for the CSS property; which then isn't so much about a column as a cell).

});

/// The table column text-alignment to be used for this cell.
// In Markdown, alignment is defined per column using the delimiter row.
// However, the generated HTML specifies alignment for each cell in a row
// individually, that matches the UI widget implementation which is also
// row based and needs alignment information to be per cell.
final TableColumnTextAlignment textAlignment;

@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
super.debugFillProperties(properties);
properties.add(EnumProperty('textAlignment', textAlignment,
defaultValue: TableColumnTextAlignment.defaults));
}
}

/// A content node that expects an inline layout context from its parent.
///
/// When rendered into a Flutter widget tree, an inline content node
Expand Down Expand Up @@ -1222,6 +1299,114 @@ class _ZulipContentParser {
return EmbedVideoNode(hrefUrl: href, previewImageSrcUrl: imgSrc, debugHtmlNode: debugHtmlNode);
}

BlockContentNode parseTableContent(dom.Element tableElement) {
assert(_debugParserContext == _ParserContext.block);
assert(tableElement.localName == 'table'
&& tableElement.className.isEmpty);

TableCellNode? parseTableCell(dom.Element node, bool isHeader) {
assert(node.localName == (isHeader ? 'th' : 'td'));
assert(node.className.isEmpty);

final cellStyle = node.attributes['style'];
final TableColumnTextAlignment? textAlignment;
switch (cellStyle) {
case null:
textAlignment = TableColumnTextAlignment.defaults;
case 'text-align: left;':
textAlignment = TableColumnTextAlignment.left;
case 'text-align: center;':
textAlignment = TableColumnTextAlignment.center;
case 'text-align: right;':
textAlignment = TableColumnTextAlignment.right;
default:
return null;
}
final parsed = parseBlockInline(node.nodes);
return TableCellNode(
nodes: parsed.nodes,
links: parsed.links,
textAlignment: textAlignment);
}

List<TableCellNode>? parseTableCells(dom.NodeList cellNodes, bool isHeader) {
final cells = <TableCellNode>[];
for (final node in cellNodes) {
if (node is dom.Text && node.text == '\n') continue;

if (node is! dom.Element) return null;
if (node.localName != (isHeader ? 'th' : 'td')) return null;
if (node.className.isNotEmpty) return null;

final cell = parseTableCell(node, isHeader);
if (cell == null) return null;
cells.add(cell);
}
return cells;
}

final TableNode? tableNode = (() {
if (tableElement.nodes case [
dom.Text(data: '\n'),
dom.Element(localName: 'thead') && final theadElement,
dom.Text(data: '\n'),
dom.Element(localName: 'tbody') && final tbodyElement,
dom.Text(data: '\n'),
]) {
if (theadElement.className.isNotEmpty) return null;
if (theadElement.nodes.isEmpty) return null;
if (tbodyElement.className.isNotEmpty) return null;
if (tbodyElement.nodes.isEmpty) return null;

final int headerColumnCount;
final parsedRows = <TableRowNode>[];

// Parse header row element.
if (theadElement.nodes case [
dom.Text(data: '\n'),
dom.Element(localName: 'tr') && final rowElement,
dom.Text(data: '\n'),
]) {
if (rowElement.className.isNotEmpty) return null;
if (rowElement.nodes.isEmpty) return null;

final cells = parseTableCells(rowElement.nodes, true);
if (cells == null) return null;
headerColumnCount = cells.length;
parsedRows.add(TableRowNode(cells: cells, isHeader: true));
} else {
// TODO(dart): simplify after https://github.com/dart-lang/language/issues/2537
return null;
}

// Parse body row elements.
for (final node in tbodyElement.nodes) {
if (node is dom.Text && node.text == '\n') continue;

if (node is! dom.Element) return null;
if (node.localName != 'tr') return null;
if (node.className.isNotEmpty) return null;
if (node.nodes.isEmpty) return null;

final cells = parseTableCells(node.nodes, false);
if (cells == null) return null;

// Ensure that the number of columns in this row matches
// the header row.
if (cells.length != headerColumnCount) return null;
parsedRows.add(TableRowNode(cells: cells, isHeader: false));
}

return TableNode(rows: parsedRows);
} else {
// TODO(dart): simplify after https://github.com/dart-lang/language/issues/2537
return null;
}
})();

return tableNode ?? UnimplementedBlockContentNode(htmlNode: tableElement);
}

BlockContentNode parseBlockContent(dom.Node node) {
assert(_debugParserContext == _ParserContext.block);
final debugHtmlNode = kDebugMode ? node : null;
Expand Down Expand Up @@ -1290,6 +1475,10 @@ class _ZulipContentParser {
parseBlockContentList(element.nodes));
}

if (localName == 'table' && className.isEmpty) {
return parseTableContent(element);
}

if (localName == 'div' && className == 'spoiler-block') {
return parseSpoilerNode(element);
}
Expand Down Expand Up @@ -1334,6 +1523,7 @@ class _ZulipContentParser {
case 'h5':
case 'h6':
case 'blockquote':
case 'table':
case 'div':
return false;
default:
Expand Down
Loading