Repository: superlistapp/super_editor

Issue:
[SuperEditor] - Create component for Markdown tables
A basic table node, and Markdown table parsing, have been implemented.

For this ticket, create a component that renders Markdown tables. Check existing apps and specifications for which details should be visually styleable in a table.

The component should use block selection - either fully selected, or not at all.



Fix patch:
diff --git a/super_editor/lib/src/default_editor/layout_single_column/selection_aware_viewmodel.dart b/super_editor/lib/src/default_editor/layout_single_column/selection_aware_viewmodel.dart
index ec6fb8f5b9..5746e3fb0d 100644
--- a/super_editor/lib/src/default_editor/layout_single_column/selection_aware_viewmodel.dart
+++ b/super_editor/lib/src/default_editor/layout_single_column/selection_aware_viewmodel.dart
@@ -1,4 +1,5 @@
 import 'package:flutter/material.dart';
+import 'package:super_editor/src/core/document.dart' show NodeSelection;
 import 'package:super_editor/src/core/styles.dart';
 import 'package:super_editor/src/default_editor/layout_single_column/layout_single_column.dart';
 
diff --git a/super_editor/lib/src/default_editor/table.dart b/super_editor/lib/src/default_editor/tables/table_block.dart
similarity index 100%
rename from super_editor/lib/src/default_editor/table.dart
rename to super_editor/lib/src/default_editor/tables/table_block.dart
diff --git a/super_editor/lib/src/default_editor/tables/table_markdown.dart b/super_editor/lib/src/default_editor/tables/table_markdown.dart
new file mode 100644
index 0000000000..34a8d5123c
--- /dev/null
+++ b/super_editor/lib/src/default_editor/tables/table_markdown.dart
@@ -0,0 +1,445 @@
+import 'package:attributed_text/attributed_text.dart';
+import 'package:collection/collection.dart';
+import 'package:flutter/material.dart';
+import 'package:super_editor/src/core/document.dart';
+import 'package:super_editor/src/core/styles.dart';
+import 'package:super_editor/src/default_editor/box_component.dart';
+import 'package:super_editor/src/default_editor/layout_single_column/layout_single_column.dart';
+import 'package:super_editor/src/default_editor/layout_single_column/selection_aware_viewmodel.dart';
+import 'package:super_editor/src/default_editor/selection_upstream_downstream.dart';
+import 'package:super_editor/src/default_editor/tables/table_block.dart';
+import 'package:super_editor/src/default_editor/text.dart';
+import 'package:super_editor/src/infrastructure/attributed_text_styles.dart';
+import 'package:super_text_layout/super_text_layout.dart';
+
+/// Builds [MarkdownTableViewModel]s and [MarkdownTableComponent]s for every [TableBlockNode]
+/// in a document.
+///
+/// The [MarkdownTableComponent] uses block level selection, which means that the table is either
+/// fully selected or not selected at all, i.e., there is no selection of individual cells.
+///
+/// See [TableStyles] for the styles that can be applied to the table through a [Stylesheet].
+class MarkdownTableComponentBuilder implements ComponentBuilder {
+  const MarkdownTableComponentBuilder();
+
+  @override
+  SingleColumnLayoutComponentViewModel? createViewModel(Document document, DocumentNode node) {
+    if (node is! TableBlockNode) {
+      return null;
+    }
+
+    return MarkdownTableViewModel(
+      nodeId: node.id,
+      createdAt: node.metadata[NodeMetadata.createdAt],
+      padding: EdgeInsets.zero,
+      cells: [
+        for (int i = 0; i < node.rowCount; i += 1) //
+          [
+            for (final cell in node.getRow(i))
+              MarkdownTableCellViewModel(
+                nodeId: cell.id,
+                createdAt: cell.metadata[NodeMetadata.createdAt],
+                text: cell.text,
+                textAlign: cell.getMetadataValue(TextNodeMetadata.textAlign) ?? TextAlign.left,
+                textStyleBuilder: noStyleBuilder,
+                padding: const EdgeInsets.all(8.0),
+                //       ^ Default padding, can be overridden through the stylesheet.
+                metadata: cell.metadata,
+              )
+          ],
+      ],
+      selectionColor: const Color(0x00000000),
+      caretColor: const Color(0x00000000),
+    );
+  }
+
+  @override
+  Widget? createComponent(
+      SingleColumnDocumentComponentContext componentContext, SingleColumnLayoutComponentViewModel componentViewModel) {
+    if (componentViewModel is! MarkdownTableViewModel) {
+      return null;
+    }
+
+    return MarkdownTableComponent(
+      componentKey: componentContext.componentKey,
+      viewModel: componentViewModel,
+      // selection: componentViewModel.selection?.nodeSelection as UpstreamDownstreamNodeSelection?,
+      // selectionColor: componentViewModel.selectionColor,
+      // showCaret: componentViewModel.selection != null,
+      // caretColor: componentViewModel.caretColor,
+      // opacity: componentViewModel.opacity,
+    );
+  }
+}
+
+/// View model that configures the appearance of a [MarkdownTableComponent].
+///
+/// View models move through various style phases, which fill out
+/// various properties in the view model. For example, one phase applies
+/// all [StyleRule]s, and another phase configures content selection
+/// and caret appearance.
+class MarkdownTableViewModel extends SingleColumnLayoutComponentViewModel with SelectionAwareViewModelMixin {
+  MarkdownTableViewModel({
+    required super.nodeId,
+    required super.createdAt,
+    super.maxWidth,
+    required super.padding,
+    super.opacity,
+    required this.cells,
+    this.border,
+    this.inlineWidgetBuilders = const [],
+    required this.caretColor,
+    DocumentNodeSelection? selection,
+    Color selectionColor = Colors.transparent,
+  }) {
+    super.selection = selection;
+    super.selectionColor = selectionColor;
+  }
+
+  /// The cells of the table, indexed as `[rowIndex][columnIndex]`.
+  ///
+  /// The first row is considered the header row.
+  ///
+  /// The remaining rows are considered to be data rows.
+  final List<List<MarkdownTableCellViewModel>> cells;
+
+  /// The border to draw around the table and its cells.
+  ///
+  /// Configurable through [TableStyles.border].
+  TableBorder? border;
+
+  /// A chain of builders that create inline widgets that can be embedded
+  /// inside the table's cells.
+  InlineWidgetBuilderChain inlineWidgetBuilders;
+
+  /// The color to use when painting the caret.
+  Color caretColor;
+
+  @override
+  SingleColumnLayoutComponentViewModel copy() {
+    return MarkdownTableViewModel(
+      nodeId: nodeId,
+      createdAt: createdAt,
+      maxWidth: maxWidth,
+      padding: padding,
+      opacity: opacity,
+      cells: [
+        for (final row in cells) //
+          row.map((e) => e.copy()).toList(),
+      ],
+      border: border,
+      inlineWidgetBuilders: inlineWidgetBuilders,
+      caretColor: caretColor,
+      selection: selection,
+      selectionColor: selectionColor,
+    );
+  }
+
+  @override
+  void applyStyles(Map<String, dynamic> styles) {
+    super.applyStyles(styles);
+
+    if (cells.isEmpty) {
+      // There is no cell, so we're not rendering anything. Fizzle.
+      return;
+    }
+
+    border = styles[TableStyles.border] as TableBorder? ?? border;
+    inlineWidgetBuilders = styles[Styles.inlineWidgetBuilders] ?? inlineWidgetBuilders;
+    final inlineTextStyler = styles[Styles.inlineTextStyler] as AttributionStyleAdjuster;
+
+    final baseTextStyle = (styles[Styles.textStyle] ?? noStyleBuilder({})) as TextStyle;
+    final headerTextStyles = styles[TableStyles.headerTextStyle] as TextStyle?;
+    final cellDecorator = styles[TableStyles.cellDecorator] as TableCellDecorator?;
+
+    EdgeInsets cellPadding = const EdgeInsets.all(0);
+    final cascadingPadding = styles[TableStyles.cellPadding] as CascadingPadding?;
+    if (cascadingPadding != null) {
+      cellPadding = cascadingPadding.toEdgeInsets();
+    }
+
+    // Apply the styles to the header.
+    final headerRow = cells[0];
+    for (int i = 0; i < headerRow.length; i += 1) {
+      final headerCell = headerRow[i];
+      // Applies the header text style on top of the base style.
+      headerCell.textStyleBuilder = (attributions) {
+        return inlineTextStyler(
+          attributions,
+          headerTextStyles != null //
+              ? baseTextStyle.merge(headerTextStyles)
+              : baseTextStyle,
+        );
+      };
+      headerCell.padding = cellPadding;
+      headerCell.decoration = cellDecorator?.call(
+            rowIndex: 0,
+            columnIndex: i,
+            cellText: headerCell.text,
+            cellMetadata: headerCell.metadata,
+          ) ??
+          const BoxDecoration();
+    }
+
+    // Apply the styles to the data rows.
+    for (int i = 1; i < cells.length; i += 1) {
+      final dataRow = cells[i];
+      for (int j = 0; j < dataRow.length; j += 1) {
+        final dataCell = dataRow[j];
+        dataCell.textStyleBuilder = (attributions) {
+          return inlineTextStyler(attributions, baseTextStyle);
+        };
+        dataCell.padding = cellPadding;
+        dataCell.decoration = cellDecorator?.call(
+              rowIndex: i,
+              columnIndex: j,
+              cellText: dataCell.text,
+              cellMetadata: dataCell.metadata,
+            ) ??
+            const BoxDecoration();
+      }
+    }
+  }
+
+  @override
+  bool operator ==(Object other) =>
+      identical(this, other) ||
+      other is MarkdownTableViewModel &&
+          runtimeType == other.runtimeType &&
+          nodeId == other.nodeId &&
+          createdAt == other.createdAt &&
+          maxWidth == other.maxWidth &&
+          padding == other.padding &&
+          opacity == other.opacity &&
+          caretColor == other.caretColor &&
+          selection == other.selection &&
+          selectionColor == other.selectionColor &&
+          border == other.border &&
+          const DeepCollectionEquality().equals(cells, other.cells);
+
+  @override
+  int get hashCode =>
+      nodeId.hashCode ^
+      createdAt.hashCode ^
+      maxWidth.hashCode ^
+      padding.hashCode ^
+      opacity.hashCode ^
+      caretColor.hashCode ^
+      selection.hashCode ^
+      selectionColor.hashCode ^
+      border.hashCode ^
+      cells.hashCode;
+}
+
+/// View model that configures the appearance of a [MarkdownTableComponent]'s cell.
+class MarkdownTableCellViewModel extends SingleColumnLayoutComponentViewModel {
+  MarkdownTableCellViewModel({
+    required super.nodeId,
+    required this.text,
+    this.textAlign = TextAlign.left,
+    this.textStyleBuilder = noStyleBuilder,
+    required super.padding,
+    this.decoration,
+    required this.metadata,
+    required super.createdAt,
+  });
+
+  final AttributedText text;
+  TextAlign textAlign;
+  AttributionStyleBuilder textStyleBuilder;
+  BoxDecoration? decoration;
+  Map<String, dynamic> metadata;
+
+  @override
+  MarkdownTableCellViewModel copy() {
+    return MarkdownTableCellViewModel(
+      nodeId: nodeId,
+      createdAt: createdAt,
+      text: text,
+      textAlign: textAlign,
+      textStyleBuilder: textStyleBuilder,
+      padding: padding,
+      decoration: decoration,
+      metadata: Map<String, dynamic>.from(metadata),
+    );
+  }
+
+  @override
+  bool operator ==(Object other) =>
+      identical(this, other) ||
+      other is MarkdownTableCellViewModel &&
+          runtimeType == other.runtimeType &&
+          super == other &&
+          text == other.text &&
+          textAlign == other.textAlign &&
+          padding == other.padding &&
+          decoration == other.decoration &&
+          const DeepCollectionEquality().equals(metadata, other.metadata);
+
+  @override
+  int get hashCode =>
+      super.hashCode ^ //
+      text.hashCode ^
+      textAlign.hashCode ^
+      padding.hashCode ^
+      decoration.hashCode ^
+      metadata.hashCode;
+}
+
+/// A component that displays a read-only table with block level selection.
+///
+/// A block level selection means that the table is either fully selected or not selected at all,
+/// i.e., there is no selection of individual cells.
+///
+/// The table automatically expands to fill the available width, and shrinks to fit when it is wider
+/// than the available width.
+class MarkdownTableComponent extends StatelessWidget {
+  const MarkdownTableComponent({
+    super.key,
+    required this.componentKey,
+    required this.viewModel,
+  });
+
+  final GlobalKey componentKey;
+  final MarkdownTableViewModel viewModel;
+
+  @override
+  Widget build(BuildContext context) {
+    return MouseRegion(
+      cursor: SystemMouseCursors.basic,
+      hitTestBehavior: HitTestBehavior.translucent,
+      //               ^ Without `HitTestBehavior.translucent` the `MouseRegion` seems to be stealing
+      //                 the pointer events, making it impossible to place the caret.
+      child: IgnorePointer(
+        //   ^ Without `IgnorePointer` gestures like taping to place the caret or double tapping
+        //     to select the whole table don't work. The `SelectableBox` seems to be stealing
+        //     the pointer events.
+        child: SelectableBox(
+          selection: viewModel.selection?.nodeSelection is UpstreamDownstreamNodeSelection
+              ? viewModel.selection?.nodeSelection as UpstreamDownstreamNodeSelection
+              : null,
+          selectionColor: viewModel.selectionColor,
+          child: BoxComponent(
+            key: componentKey,
+            opacity: viewModel.opacity,
+            child: LayoutBuilder(builder: (context, constraints) {
+              return FittedBox(
+                fit: BoxFit.scaleDown,
+                //  ^ Shrink to fit when the table is wider than the viewport.
+                child: ConstrainedBox(
+                  constraints: BoxConstraints(
+                    minWidth: constraints.maxWidth,
+                    // ^ Expand to fill when the table is narrower than the viewport.
+                  ),
+                  child: Table(
+                    border: viewModel.border ?? TableBorder.all(),
+                    defaultColumnWidth: const IntrinsicColumnWidth(),
+                    children: [
+                      for (int i = 0; i < viewModel.cells.length; i += 1) //
+                        _buildRow(context, viewModel.cells[i], i),
+                    ],
+                  ),
+                ),
+              );
+            }),
+          ),
+        ),
+      ),
+    );
+  }
+
+  TableRow _buildRow(BuildContext context, List<MarkdownTableCellViewModel> row, int rowIndex) {
+    return TableRow(
+      children: [
+        for (final cell in row) //
+          _buildCell(context, cell),
+      ],
+    );
+  }
+
+  Widget _buildCell(
+    BuildContext context,
+    MarkdownTableCellViewModel cell,
+  ) {
+    return DecoratedBox(
+      decoration: cell.decoration ?? const BoxDecoration(),
+      child: Padding(
+        padding: cell.padding,
+        child: SuperText(
+          richText: cell.text.computeInlineSpan(
+            context,
+            cell.textStyleBuilder,
+            viewModel.inlineWidgetBuilders,
+          ),
+          textAlign: cell.textAlign,
+        ),
+      ),
+    );
+  }
+}
+
+/// A function that decorates a table row.
+///
+/// Can be used, for example, to apply alternating background colors to rows.
+///
+/// The header row has [rowIndex] 0, the first data row has [rowIndex] 1, and so on.
+///
+/// Returning `null` means that no decoration is applied to the row.
+typedef TableRowDecorator = BoxDecoration? Function({
+  required int rowIndex,
+});
+
+/// A function that decorates a table cell.
+///
+/// The header row has [rowIndex] 0, the first data row has [rowIndex] 1, and so on.
+///
+/// Returning `null` means that no decoration is applied to the cell, which means
+/// the decoration of the row is applied, if any.
+typedef TableCellDecorator = BoxDecoration? Function({
+  required int rowIndex,
+  required int columnIndex,
+  required AttributedText cellText,
+  required Map<String, dynamic> cellMetadata,
+});
+
+/// The default styles that are applied to a table through a [Stylesheet].
+///
+/// Applies a border around the entire table and each cell, a bold text style to the header row,
+/// and padding to each cell.
+final markdownTableStyles = StyleRule(
+  BlockSelector(tableBlockAttribution.name),
+  (document, node) {
+    if (node is! TableBlockNode) {
+      return {};
+    }
+
+    return {
+      Styles.padding: const CascadingPadding.only(top: 24),
+      TableStyles.headerTextStyle: const TextStyle(
+        fontWeight: FontWeight.w600,
+        color: Colors.black,
+      ),
+      TableStyles.cellPadding: const CascadingPadding.all(4.0),
+      TableStyles.border: TableBorder.all(color: Colors.grey, width: 1),
+    };
+  },
+);
+
+/// The keys to the style metadata used to style a table.
+class TableStyles {
+  /// Applies a [TextStyle] to the cells of the header row.
+  static const String headerTextStyle = 'tableHeaderTextStyle';
+
+  /// Applies a [TableBorder] to the table.
+  static const String border = 'tableBorder';
+
+  /// Applies a [TableCellDecorator] to each cell in the table.
+  ///
+  /// A [TableCellDecorator] is applied after the [TableStyles.rowDecorator],
+  /// which means that the cell decorator can paint the cell with a different
+  /// background color than its parent row.
+  static const String cellDecorator = 'tableCellDecorator';
+
+  /// Applies padding to each cell in the table.
+  static const String cellPadding = 'tableCellPadding';
+}
diff --git a/super_editor/lib/src/infrastructure/serialization/html/html_table.dart b/super_editor/lib/src/infrastructure/serialization/html/html_table.dart
index 8fc47e8d48..3521e6f66b 100644
--- a/super_editor/lib/src/infrastructure/serialization/html/html_table.dart
+++ b/super_editor/lib/src/infrastructure/serialization/html/html_table.dart
@@ -1,7 +1,7 @@
 import 'package:flutter/material.dart';
 import 'package:super_editor/src/core/document.dart';
 import 'package:super_editor/src/default_editor/selection_upstream_downstream.dart';
-import 'package:super_editor/src/default_editor/table.dart';
+import 'package:super_editor/src/default_editor/tables/table_block.dart';
 import 'package:super_editor/src/default_editor/text.dart';
 import 'package:super_editor/src/infrastructure/serialization/html/html_inline_text_styles.dart';
 
diff --git a/super_editor/lib/super_editor.dart b/super_editor/lib/super_editor.dart
index d51aabb8bd..1d16ec4efc 100644
--- a/super_editor/lib/super_editor.dart
+++ b/super_editor/lib/super_editor.dart
@@ -49,9 +49,10 @@ export 'src/default_editor/layout_single_column/selection_aware_viewmodel.dart';
 export 'src/default_editor/selection_binary.dart';
 export 'src/default_editor/selection_upstream_downstream.dart';
 export 'src/default_editor/super_editor.dart';
-export 'src/default_editor/table.dart';
+export 'src/default_editor/tables/table_block.dart';
 export 'src/default_editor/tasks.dart';
 export 'src/default_editor/text.dart';
+export 'src/default_editor/tables/table_markdown.dart';
 export 'src/default_editor/text_ai.dart';
 export 'src/default_editor/text_tools.dart';
 export 'src/default_editor/text/custom_underlines.dart';


Test patch:
diff --git a/super_editor/test/super_editor/components/markdown_table_test.dart b/super_editor/test/super_editor/components/markdown_table_test.dart
new file mode 100644
index 0000000000..9668553c84
--- /dev/null
+++ b/super_editor/test/super_editor/components/markdown_table_test.dart
@@ -0,0 +1,103 @@
+import 'package:flutter_test/flutter_test.dart';
+import 'package:flutter_test_runners/flutter_test_runners.dart';
+import 'package:super_editor/super_editor.dart';
+import 'package:super_editor/super_editor_test.dart';
+
+import '../supereditor_test_tools.dart';
+
+void main() {
+  group('SuperEditor > Markdown Table >', () {
+    group('gestures >', () {
+      testWidgetsOnAllPlatforms('places caret at left edge when tapping at the left side', (tester) async {
+        await _pumpTestApp(tester);
+
+        // Tap close to the left edge of the table to place the caret
+        // upstream on the table.
+        await tester.tapAt(
+          tester.getTopLeft(find.byType(MarkdownTableComponent)) + const Offset(20, 20),
+        );
+        await tester.pump();
+
+        // Ensure the caret is placed at the upstream side of the table.
+        expect(
+          SuperEditorInspector.findDocumentSelection(),
+          DocumentSelection.collapsed(
+            position: DocumentPosition(
+              nodeId: SuperEditorInspector.findDocument()!.first.id,
+              nodePosition: const UpstreamDownstreamNodePosition.upstream(),
+            ),
+          ),
+        );
+
+        // Allow the long press timer to resolve.
+        await tester.pumpAndSettle();
+      });
+
+      testWidgetsOnAllPlatforms('places caret at right edge when tapping at the right side', (tester) async {
+        await _pumpTestApp(tester);
+
+        // Tap close to the right edge of the table to place the caret
+        // downstream on the table.
+        await tester.tapAt(
+          tester.getTopRight(find.byType(MarkdownTableComponent)) + const Offset(-20, 20),
+        );
+        await tester.pump();
+
+        // Ensure the caret is placed at the downstream side of the table.
+        expect(
+          SuperEditorInspector.findDocumentSelection(),
+          DocumentSelection.collapsed(
+            position: DocumentPosition(
+              nodeId: SuperEditorInspector.findDocument()!.first.id,
+              nodePosition: const UpstreamDownstreamNodePosition.downstream(),
+            ),
+          ),
+        );
+
+        // Allow the long press timer to resolve.
+        await tester.pumpAndSettle();
+      });
+
+      testWidgetsOnAllPlatforms('places an expanded selection when double tapping', (tester) async {
+        await _pumpTestApp(tester);
+
+        // Double tap in the middle of the table to select the entire table.
+        await tester.tapAt(
+          tester.getCenter(find.byType(MarkdownTableComponent)),
+        );
+        await tester.pump(kTapMinTime);
+        await tester.tapAt(
+          tester.getCenter(find.byType(MarkdownTableComponent)),
+        );
+        await tester.pump(kTapMinTime);
+
+        // The entire table should be selected.
+        expect(
+          SuperEditorInspector.findDocumentSelection(),
+          DocumentSelection(
+            base: DocumentPosition(
+              nodeId: SuperEditorInspector.findDocument()!.first.id,
+              nodePosition: const UpstreamDownstreamNodePosition.upstream(),
+            ),
+            extent: DocumentPosition(
+              nodeId: SuperEditorInspector.findDocument()!.first.id,
+              nodePosition: const UpstreamDownstreamNodePosition.downstream(),
+            ),
+          ),
+        );
+      });
+    });
+  });
+}
+
+Future<void> _pumpTestApp(WidgetTester tester) async {
+  await tester //
+      .createDocument()
+      .fromMarkdown('''
+| Header 1 | Header 2 |
+|---|---|
+| Cell 1 | Cell 2 |
+| Cell 3 | Cell 4 |''') //
+      .withAddedComponents([const MarkdownTableComponentBuilder()]) //
+      .pump();
+}
diff --git a/super_editor/test_goldens/editor/components/goldens/super_editor_markdown_table_customization_border.png b/super_editor/test_goldens/editor/components/goldens/super_editor_markdown_table_customization_border.png
new file mode 100644
index 0000000000..9860c4634f
Binary files /dev/null and b/super_editor/test_goldens/editor/components/goldens/super_editor_markdown_table_customization_border.png differ
diff --git a/super_editor/test_goldens/editor/components/goldens/super_editor_markdown_table_customization_cell_decoration.png b/super_editor/test_goldens/editor/components/goldens/super_editor_markdown_table_customization_cell_decoration.png
new file mode 100644
index 0000000000..fd0b2f5dbe
Binary files /dev/null and b/super_editor/test_goldens/editor/components/goldens/super_editor_markdown_table_customization_cell_decoration.png differ
diff --git a/super_editor/test_goldens/editor/components/goldens/super_editor_markdown_table_customization_cell_padding.png b/super_editor/test_goldens/editor/components/goldens/super_editor_markdown_table_customization_cell_padding.png
new file mode 100644
index 0000000000..22c552b3ab
Binary files /dev/null and b/super_editor/test_goldens/editor/components/goldens/super_editor_markdown_table_customization_cell_padding.png differ
diff --git a/super_editor/test_goldens/editor/components/goldens/super_editor_markdown_table_customization_header_decoration.png b/super_editor/test_goldens/editor/components/goldens/super_editor_markdown_table_customization_header_decoration.png
new file mode 100644
index 0000000000..508fb2771c
Binary files /dev/null and b/super_editor/test_goldens/editor/components/goldens/super_editor_markdown_table_customization_header_decoration.png differ
diff --git a/super_editor/test_goldens/editor/components/goldens/super_editor_markdown_table_customization_header_text_style.png b/super_editor/test_goldens/editor/components/goldens/super_editor_markdown_table_customization_header_text_style.png
new file mode 100644
index 0000000000..4e28371648
Binary files /dev/null and b/super_editor/test_goldens/editor/components/goldens/super_editor_markdown_table_customization_header_text_style.png differ
diff --git a/super_editor/test_goldens/editor/components/goldens/super_editor_markdown_table_customization_row_decoration.png b/super_editor/test_goldens/editor/components/goldens/super_editor_markdown_table_customization_row_decoration.png
new file mode 100644
index 0000000000..0f77f80a9d
Binary files /dev/null and b/super_editor/test_goldens/editor/components/goldens/super_editor_markdown_table_customization_row_decoration.png differ
diff --git a/super_editor/test_goldens/editor/components/goldens/super_editor_markdown_table_customization_text_style.png b/super_editor/test_goldens/editor/components/goldens/super_editor_markdown_table_customization_text_style.png
new file mode 100644
index 0000000000..35f7166d04
Binary files /dev/null and b/super_editor/test_goldens/editor/components/goldens/super_editor_markdown_table_customization_text_style.png differ
diff --git a/super_editor/test_goldens/editor/components/goldens/super_editor_markdown_table_different_alignments.png b/super_editor/test_goldens/editor/components/goldens/super_editor_markdown_table_different_alignments.png
new file mode 100644
index 0000000000..f4bafb909a
Binary files /dev/null and b/super_editor/test_goldens/editor/components/goldens/super_editor_markdown_table_different_alignments.png differ
diff --git a/super_editor/test_goldens/editor/components/goldens/super_editor_markdown_table_fills_width.png b/super_editor/test_goldens/editor/components/goldens/super_editor_markdown_table_fills_width.png
new file mode 100644
index 0000000000..be598f99f5
Binary files /dev/null and b/super_editor/test_goldens/editor/components/goldens/super_editor_markdown_table_fills_width.png differ
diff --git a/super_editor/test_goldens/editor/components/goldens/super_editor_markdown_table_inline_styles.png b/super_editor/test_goldens/editor/components/goldens/super_editor_markdown_table_inline_styles.png
new file mode 100644
index 0000000000..5462ecb3c0
Binary files /dev/null and b/super_editor/test_goldens/editor/components/goldens/super_editor_markdown_table_inline_styles.png differ
diff --git a/super_editor/test_goldens/editor/components/goldens/super_editor_markdown_table_missing_columns.png b/super_editor/test_goldens/editor/components/goldens/super_editor_markdown_table_missing_columns.png
new file mode 100644
index 0000000000..996a650666
Binary files /dev/null and b/super_editor/test_goldens/editor/components/goldens/super_editor_markdown_table_missing_columns.png differ
diff --git a/super_editor/test_goldens/editor/components/goldens/super_editor_markdown_table_shrinks_to_fit_width.png b/super_editor/test_goldens/editor/components/goldens/super_editor_markdown_table_shrinks_to_fit_width.png
new file mode 100644
index 0000000000..8a0b08481f
Binary files /dev/null and b/super_editor/test_goldens/editor/components/goldens/super_editor_markdown_table_shrinks_to_fit_width.png differ
diff --git a/super_editor/test_goldens/editor/components/goldens/super_editor_markdown_table_single_header_cell.png b/super_editor/test_goldens/editor/components/goldens/super_editor_markdown_table_single_header_cell.png
new file mode 100644
index 0000000000..9037eeca2b
Binary files /dev/null and b/super_editor/test_goldens/editor/components/goldens/super_editor_markdown_table_single_header_cell.png differ
diff --git a/super_editor/test_goldens/editor/components/goldens/super_editor_markdown_table_without_data_rows.png b/super_editor/test_goldens/editor/components/goldens/super_editor_markdown_table_without_data_rows.png
new file mode 100644
index 0000000000..f803c4e7bd
Binary files /dev/null and b/super_editor/test_goldens/editor/components/goldens/super_editor_markdown_table_without_data_rows.png differ
diff --git a/super_editor/test_goldens/editor/components/markdown_table_test.dart b/super_editor/test_goldens/editor/components/markdown_table_test.dart
new file mode 100644
index 0000000000..29cfe097fb
--- /dev/null
+++ b/super_editor/test_goldens/editor/components/markdown_table_test.dart
@@ -0,0 +1,362 @@
+import 'package:flutter/material.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:golden_toolkit/golden_toolkit.dart';
+import 'package:super_editor/super_editor.dart';
+
+import '../../../test/super_editor/supereditor_test_tools.dart';
+import '../../test_tools_goldens.dart';
+
+void main() {
+  group('SuperEditor > Markdown Table >', () {
+    group('layout >', () {
+      testGoldensOnMac('expands to fill width', (tester) async {
+        await tester //
+            .createDocument()
+            .fromMarkdown('''
+| Header 1 |
+|---|
+| Cell 1 |
+| Cell 2 |
+| Cell 3 |''')
+            .withAddedComponents([const MarkdownTableComponentBuilder()])
+            .useStylesheet(markdownTableStylesheet)
+            .pump();
+
+        await screenMatchesGolden(tester, 'super_editor_markdown_table_fills_width');
+      }, windowSize: goldenSizeMedium);
+
+      testGoldensOnMac('shrinks to fit width', (tester) async {
+        await tester //
+            .createDocument()
+            .fromMarkdown('''
+| Header 1 | Header 2 | Header 3 | Header 4 | Header 5 | 
+|---|---|---|---|---|
+| Cell 1 | Cell 2 | Cell 3 | Cell 4 | Cell 5 |
+| Cell 1 | Cell 2 | Cell 3 | Cell 4 | Cell 5 |''')
+            .withAddedComponents([const MarkdownTableComponentBuilder()])
+            .useStylesheet(markdownTableStylesheet)
+            .pump();
+
+        await screenMatchesGolden(tester, 'super_editor_markdown_table_shrinks_to_fit_width');
+      }, windowSize: goldenSizeMedium);
+
+      testGoldensOnMac('without data rows', (tester) async {
+        await tester //
+            .createDocument()
+            .fromMarkdown('''
+| Header 1 | Header 2 |
+|---|---|''')
+            .withAddedComponents([const MarkdownTableComponentBuilder()])
+            .useStylesheet(markdownTableStylesheet)
+            .pump();
+
+        await screenMatchesGolden(tester, 'super_editor_markdown_table_without_data_rows');
+      }, windowSize: goldenSizeMedium);
+
+      testGoldensOnMac('missing columns', (tester) async {
+        await tester //
+            .createDocument()
+            .fromMarkdown('''
+| Header 1 | Header 2 | Header 3 |
+|---|---|---|
+| Cell 1 | Cell 2 |
+| Cell 3 |
+''')
+            .withAddedComponents([const MarkdownTableComponentBuilder()])
+            .useStylesheet(markdownTableStylesheet)
+            .pump();
+
+        await screenMatchesGolden(tester, 'super_editor_markdown_table_missing_columns');
+      }, windowSize: goldenSizeMedium);
+
+      testGoldensOnMac('single header cell', (tester) async {
+        await tester //
+            .createDocument()
+            .fromMarkdown('''
+| Header 1 |
+|---|''')
+            .withAddedComponents([const MarkdownTableComponentBuilder()])
+            .useStylesheet(markdownTableStylesheet)
+            .pump();
+
+        await screenMatchesGolden(tester, 'super_editor_markdown_table_single_header_cell');
+      }, windowSize: goldenSizeMedium);
+
+      testGoldensOnMac('different alignments', (tester) async {
+        await tester //
+            .createDocument()
+            .fromMarkdown('''
+| Column 1 | Column 2 | Column 3 |
+|---|:---:|---:|
+| Cell 1 | Cell 2 | Cell 3 |
+| Cell 1 | Cell 2 | Cell 3 |
+| Cell 1 | Cell 2 | Cell 3 |
+| Cell 1 | Cell 2 | Cell 3 |
+''')
+            .withAddedComponents([const MarkdownTableComponentBuilder()])
+            .useStylesheet(markdownTableStylesheet)
+            .pump();
+
+        await screenMatchesGolden(tester, 'super_editor_markdown_table_different_alignments');
+      }, windowSize: goldenSizeMedium);
+
+      testGoldensOnMac('different alignments', (tester) async {
+        await tester //
+            .createDocument()
+            .fromMarkdown('''
+| Column 1 | Column 2 | Column 3 |
+|---|:---:|---:|
+| Cell 1 | Cell 2 | Cell 3 |
+| Cell 1 | Cell 2 | Cell 3 |
+| Cell 1 | Cell 2 | Cell 3 |
+| Cell 1 | Cell 2 | Cell 3 |
+''')
+            .withAddedComponents([const MarkdownTableComponentBuilder()])
+            .useStylesheet(markdownTableStylesheet)
+            .pump();
+
+        await screenMatchesGolden(tester, 'super_editor_markdown_table_different_alignments');
+      }, windowSize: goldenSizeMedium);
+
+      testGoldensOnMac('inline styles', (tester) async {
+        await tester //
+            .createDocument()
+            .fromMarkdown('''
+| Column 1 | ~Column 2~ | ¬Column 3¬ |
+|---|---|---|
+| **Cell 1** | Cell 2 | Cell 3 |
+| Cell 1 | *Cell 2* | Cell 3 |
+| Cell 1 | Cell 2 | *Cell 3* |
+''')
+            .withAddedComponents([const MarkdownTableComponentBuilder()])
+            .useStylesheet(markdownTableStylesheet.copyWith(
+              addRulesAfter: [
+                StyleRule(
+                  BlockSelector.all,
+                  (document, node) {
+                    return {
+                      Styles.textStyle: const TextStyle(
+                        fontFamily: 'Roboto',
+                      ),
+                    };
+                  },
+                ),
+              ],
+            ))
+            .pump();
+
+        await screenMatchesGolden(tester, 'super_editor_markdown_table_inline_styles');
+      }, windowSize: goldenSizeMedium);
+    });
+
+    group('customization >', () {
+      testGoldensOnMac('text style', (tester) async {
+        await _pumpCustomizationTestApp(
+          tester,
+          stylesheet: markdownTableStylesheet.copyWith(
+            addRulesAfter: [
+              StyleRule(
+                BlockSelector(tableBlockAttribution.name),
+                (document, node) {
+                  return {
+                    Styles.textStyle: const TextStyle(
+                      color: Colors.red,
+                    ),
+                  };
+                },
+              ),
+            ],
+          ),
+        );
+
+        await screenMatchesGolden(tester, 'super_editor_markdown_table_customization_text_style');
+      }, windowSize: goldenSizeMedium);
+
+      testGoldensOnMac('header text style', (tester) async {
+        await _pumpCustomizationTestApp(
+          tester,
+          stylesheet: markdownTableStylesheet.copyWith(
+            addRulesAfter: [
+              StyleRule(
+                BlockSelector(tableBlockAttribution.name),
+                (document, node) {
+                  return {
+                    TableStyles.headerTextStyle: const TextStyle(
+                      color: Colors.red,
+                    ),
+                  };
+                },
+              ),
+            ],
+          ),
+        );
+
+        await screenMatchesGolden(tester, 'super_editor_markdown_table_customization_header_text_style');
+      }, windowSize: goldenSizeMedium);
+
+      testGoldensOnMac('border', (tester) async {
+        await _pumpCustomizationTestApp(
+          tester,
+          stylesheet: markdownTableStylesheet.copyWith(
+            addRulesAfter: [
+              StyleRule(
+                BlockSelector(tableBlockAttribution.name),
+                (document, node) {
+                  return {
+                    TableStyles.border: const TableBorder(),
+                  };
+                },
+              ),
+            ],
+          ),
+        );
+
+        await screenMatchesGolden(tester, 'super_editor_markdown_table_customization_border');
+      }, windowSize: goldenSizeMedium);
+
+      testGoldensOnMac('cell padding', (tester) async {
+        await _pumpCustomizationTestApp(
+          tester,
+          stylesheet: markdownTableStylesheet.copyWith(
+            addRulesAfter: [
+              StyleRule(
+                BlockSelector(tableBlockAttribution.name),
+                (document, node) {
+                  return {
+                    TableStyles.cellPadding: const CascadingPadding.all(20.0),
+                  };
+                },
+              ),
+            ],
+          ),
+        );
+
+        await screenMatchesGolden(tester, 'super_editor_markdown_table_customization_cell_padding');
+      }, windowSize: goldenSizeMedium);
+
+      testGoldensOnMac('header row decoration', (tester) async {
+        await _pumpCustomizationTestApp(
+          tester,
+          stylesheet: markdownTableStylesheet.copyWith(
+            addRulesAfter: [
+              StyleRule(
+                BlockSelector(tableBlockAttribution.name),
+                (document, node) {
+                  return {
+                    TableStyles.cellDecorator: ({
+                      required int rowIndex,
+                      required int columnIndex,
+                      required AttributedText cellText,
+                      required Map<String, dynamic> cellMetadata,
+                    }) {
+                      if (rowIndex == 0) {
+                        // Header row.
+                        return const BoxDecoration(color: Colors.blue);
+                      }
+
+                      return null;
+                    },
+                  };
+                },
+              ),
+            ],
+          ),
+        );
+
+        await screenMatchesGolden(tester, 'super_editor_markdown_table_customization_header_decoration');
+      }, windowSize: goldenSizeMedium);
+
+      testGoldensOnMac('data row decoration', (tester) async {
+        await _pumpCustomizationTestApp(
+          tester,
+          stylesheet: markdownTableStylesheet.copyWith(
+            addRulesAfter: [
+              StyleRule(
+                BlockSelector(tableBlockAttribution.name),
+                (document, node) {
+                  return {
+                    TableStyles.cellDecorator: ({
+                      required int rowIndex,
+                      required int columnIndex,
+                      required AttributedText cellText,
+                      required Map<String, dynamic> cellMetadata,
+                    }) {
+                      if (rowIndex > 0 && rowIndex % 2 == 0) {
+                        // Even data row.
+                        return const BoxDecoration(
+                          color: Colors.green,
+                        );
+                      }
+
+                      return null;
+                    },
+                  };
+                },
+              ),
+            ],
+          ),
+        );
+
+        await screenMatchesGolden(tester, 'super_editor_markdown_table_customization_row_decoration');
+      }, windowSize: goldenSizeMedium);
+
+      testGoldensOnMac('cell decoration', (tester) async {
+        await _pumpCustomizationTestApp(
+          tester,
+          stylesheet: markdownTableStylesheet.copyWith(
+            addRulesAfter: [
+              StyleRule(
+                BlockSelector(tableBlockAttribution.name),
+                (document, node) {
+                  return {
+                    TableStyles.cellDecorator: ({
+                      required int rowIndex,
+                      required int columnIndex,
+                      required AttributedText cellText,
+                      required Map<String, dynamic> cellMetadata,
+                    }) {
+                      if (columnIndex == 1) {
+                        return const BoxDecoration(color: Colors.red);
+                      }
+
+                      return null;
+                    },
+                  };
+                },
+              ),
+            ],
+          ),
+        );
+
+        await screenMatchesGolden(tester, 'super_editor_markdown_table_customization_cell_decoration');
+      }, windowSize: goldenSizeMedium);
+    });
+  });
+}
+
+Future<void> _pumpCustomizationTestApp(
+  WidgetTester tester, {
+  required Stylesheet stylesheet,
+}) async {
+  await tester //
+      .createDocument()
+      .fromMarkdown('''
+| Header 1 | Header 2 | Header 3 |
+|---|---|---|
+| Cell 1 | Cell 2 | Cell 3 |
+| Cell 1 | Cell 2 |
+| Cell 1 | Cell 2 |
+| Cell 1 | Cell 2 |
+| Cell 1 | Cell 2 |
+''')
+      .withAddedComponents([const MarkdownTableComponentBuilder()])
+      .useStylesheet(stylesheet)
+      .pump();
+}
+
+/// Applies the markdown table styles to the default stylesheet.
+final markdownTableStylesheet = defaultStylesheet.copyWith(
+  addRulesAfter: [
+    markdownTableStyles,
+  ],
+);