Repository: superlistapp/super_editor

Issue:
[Markdown][Super Editor][Super Reader] - Add block node for tables
After #2758 we should another small step towards table support. This ticket is essentially for a "read only" version of a table node, which really amounts to the table node being a block node, which behaves like an image, or a horizontal rule.

We can call this node `TableBlockNode` and it should extend `BlockNode`. This node should support a finite number of rows and columns. 

Open question: We know that one of the places we're going to pull data from is Markdown. We know Markdown explicitly has the concept of column headers. Should we include the explicit concept of headers in this node? Or should we leave that up to the app that uses it?

In this PR, in addition to defining the node, update the existing Markdown parser to use this node.



Fix patch:
diff --git a/super_editor/lib/src/default_editor/table.dart b/super_editor/lib/src/default_editor/table.dart
new file mode 100644
index 0000000000..d07a7762bc
--- /dev/null
+++ b/super_editor/lib/src/default_editor/table.dart
@@ -0,0 +1,130 @@
+import 'package:attributed_text/attributed_text.dart';
+import 'package:collection/collection.dart';
+import 'package:flutter/foundation.dart';
+import 'package:super_editor/src/core/document.dart';
+import 'package:super_editor/src/default_editor/box_component.dart';
+import 'package:super_editor/src/default_editor/selection_upstream_downstream.dart';
+import 'package:super_editor/src/default_editor/text.dart';
+
+/// A [DocumentNode] that represents a read-only block table.
+///
+/// Being a block node means that the table is either fully selected or not selected at all,
+/// i.e., there is no selection of individual cells.
+@immutable
+class TableBlockNode extends BlockNode {
+  /// Creates a [TableBlockNode] with the given [cells].
+  ///
+  /// The [cells] grid is indexed as `cells[row][column]`.
+  TableBlockNode({
+    required this.id,
+    required List<List<TextNode>> cells,
+    super.metadata,
+  }) : _cells = List.from(cells.map((row) => List<TextNode>.from(row))) {
+    initAddToMetadata({NodeMetadata.blockType: tableBlockAttribution});
+  }
+
+  @override
+  final String id;
+
+  final List<List<TextNode>> _cells;
+
+  int get rowCount => _cells.length;
+  int get columnCount => _cells.isEmpty ? 0 : _cells[0].length;
+
+  List<TextNode> getRow(int index) {
+    if (index < 0 || index >= _cells.length) {
+      throw RangeError.range(index, 0, _cells.length - 1, 'index');
+    }
+    return UnmodifiableListView(_cells[index]);
+  }
+
+  List<TextNode> getColumn(int index) {
+    if (_cells.isEmpty || index < 0 || index >= _cells[0].length) {
+      throw RangeError.range(index, 0, _cells[0].length - 1, 'index');
+    }
+    return UnmodifiableListView(_cells.map((row) => row[index]));
+  }
+
+  TextNode getCell({
+    required int rowIndex,
+    required int columnIndex,
+  }) {
+    if (rowIndex < 0 || rowIndex >= _cells.length) {
+      throw RangeError.range(rowIndex, 0, _cells.length - 1, 'rowIndex');
+    }
+    final row = _cells[rowIndex];
+    if (columnIndex < 0 || columnIndex >= row.length) {
+      throw RangeError.range(columnIndex, 0, row.length - 1, 'cellIndex');
+    }
+    return row[columnIndex];
+  }
+
+  @override
+  DocumentNode copyAndReplaceMetadata(Map<String, dynamic> newMetadata) {
+    return TableBlockNode(
+      id: id,
+      metadata: newMetadata,
+      cells: _cells,
+    );
+  }
+
+  @override
+  DocumentNode copyWithAddedMetadata(Map<String, dynamic> newProperties) {
+    return TableBlockNode(
+      id: id,
+      cells: _cells,
+      metadata: {
+        ...metadata,
+        ...newProperties,
+      },
+    );
+  }
+
+  @override
+  String? copyContent(NodeSelection selection) {
+    if (selection is! UpstreamDownstreamNodeSelection) {
+      // We don't know how to handle this selection type.
+      return null;
+    }
+    if (selection.isCollapsed) {
+      // This selection doesn't include the table - it's a collapsed selection
+      // either on the upstream or downstream edge.
+      return null;
+    }
+
+    final buffer = StringBuffer();
+    for (int i = 0; i < _cells.length; i++) {
+      final row = _cells[i];
+      if (i > 0) {
+        // Separate rows with a newline.
+        buffer.writeln('');
+      }
+
+      for (int j = 0; j < row.length; j++) {
+        final cell = row[j];
+        if (j > 0) {
+          // Separate cells with a tab.
+          buffer.write('\t');
+        }
+
+        buffer.write(cell.text.toPlainText(includePlaceholders: false));
+      }
+    }
+    return buffer.toString();
+  }
+
+  @override
+  bool operator ==(Object other) =>
+      identical(this, other) ||
+      other is TableBlockNode &&
+          runtimeType == other.runtimeType &&
+          id == other.id &&
+          metadata == other.metadata &&
+          const DeepCollectionEquality().equals(_cells, other._cells);
+
+  @override
+  int get hashCode => id.hashCode ^ _cells.hashCode ^ metadata.hashCode;
+}
+
+const tableBlockAttribution = NamedAttribution("tableBlock");
+const tableHeaderAttribution = NamedAttribution("tableHeader");
diff --git a/super_editor/lib/src/infrastructure/serialization/html/document_to_html.dart b/super_editor/lib/src/infrastructure/serialization/html/document_to_html.dart
index 36ddead57e..9a55458737 100644
--- a/super_editor/lib/src/infrastructure/serialization/html/document_to_html.dart
+++ b/super_editor/lib/src/infrastructure/serialization/html/document_to_html.dart
@@ -8,6 +8,7 @@ import 'package:super_editor/src/infrastructure/serialization/html/html_images.d
 import 'package:super_editor/src/infrastructure/serialization/html/html_inline_text_styles.dart';
 import 'package:super_editor/src/infrastructure/serialization/html/html_list_items.dart';
 import 'package:super_editor/src/infrastructure/serialization/html/html_paragraphs.dart';
+import 'package:super_editor/src/infrastructure/serialization/html/html_table.dart';
 
 extension HtmlSerialization on Document {
   /// Converts this [Document] to an HTML representation.
@@ -98,6 +99,7 @@ const NodeHtmlSerializerChain defaultNodeHtmlSerializerChain = [
   defaultBlockquoteToHtmlSerializer,
   defaultCodeBlockToHtmlSerializer,
   defaultParagraphToHtmlSerializer,
+  defaultTableToHtmlSerializer,
 ];
 
 /// A priority-order list of [NodeHtmlSerializer]s, which can be used to serialize
diff --git a/super_editor/lib/src/infrastructure/serialization/html/html_table.dart b/super_editor/lib/src/infrastructure/serialization/html/html_table.dart
new file mode 100644
index 0000000000..8fc47e8d48
--- /dev/null
+++ b/super_editor/lib/src/infrastructure/serialization/html/html_table.dart
@@ -0,0 +1,125 @@
+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/text.dart';
+import 'package:super_editor/src/infrastructure/serialization/html/html_inline_text_styles.dart';
+
+/// Serializes a [TableBlockNode] to HTML.
+///
+/// Table rows that contain only header cells (i.e., cells with the [tableHeaderAttribution]
+/// metadata) and appear at the beginning of the table are serialized inside a `<thead>` element.
+///
+/// All other rows are serialized inside a `<tbody>` element.
+///
+/// Each cell with the [tableHeaderAttribution] metadata is serialized as a `<th>` element.
+///
+/// If the [selection] is non-`null`, it must be an [UpstreamDownstreamNodeSelection]. Returns
+/// an empty string if the selection is collapsed.
+String? defaultTableToHtmlSerializer(
+  Document document,
+  DocumentNode node,
+  NodeSelection? selection,
+  InlineHtmlSerializerChain inlineSerializers,
+) {
+  if (node is! TableBlockNode) {
+    return null;
+  }
+  if (selection != null) {
+    if (selection is! UpstreamDownstreamNodeSelection) {
+      // We don't know how to handle this selection type.
+      return null;
+    }
+    if (selection.isCollapsed) {
+      // This selection doesn't include the table - it's a collapsed selection
+      // either on the upstream or downstream edge. Return an empty string to
+      // signal we handled the serialization, but there's no content to include.
+      return '';
+    }
+  }
+
+  return node.toHtml(document, inlineSerializers);
+}
+
+extension TableBlockNodeToHtml on TableBlockNode {
+  String toHtml(Document document, InlineHtmlSerializerChain inlineSerializers) {
+    final htmlBuffer = StringBuffer();
+    htmlBuffer.write('<table>');
+
+    final headers = <List<TextNode>>[];
+    final dataRows = <List<TextNode>>[];
+
+    for (int i = 0; i < rowCount; i++) {
+      final row = getRow(i);
+
+      if (dataRows.isNotEmpty) {
+        // We already have data rows. Each row after the first data row is also
+        // a data row.
+        dataRows.add(row);
+        continue;
+      }
+
+      bool doesRowContainOnlyHeaders = true;
+      for (final cell in row) {
+        if (cell.getMetadataValue(NodeMetadata.blockType) != tableHeaderAttribution) {
+          doesRowContainOnlyHeaders = false;
+          break;
+        }
+      }
+
+      if (doesRowContainOnlyHeaders) {
+        headers.add(row);
+      } else {
+        dataRows.add(row);
+      }
+    }
+
+    if (headers.isNotEmpty) {
+      htmlBuffer.write('<thead>');
+      for (final headerRow in headers) {
+        htmlBuffer.write('<tr>');
+        for (final cell in headerRow) {
+          final cellContent = cell.text.toHtml(
+            serializers: inlineSerializers,
+          );
+          htmlBuffer.write('<th${_getTextAlignStyle(cell)}>$cellContent</th>');
+        }
+        htmlBuffer.write('</tr>');
+      }
+      htmlBuffer.write('</thead>');
+    }
+
+    if (dataRows.isNotEmpty) {
+      htmlBuffer.write('<tbody>');
+      for (final row in dataRows) {
+        htmlBuffer.write('<tr>');
+        for (final cell in row) {
+          final cellContent = cell.text.toHtml(
+            serializers: inlineSerializers,
+          );
+          final tag = cell.getMetadataValue(NodeMetadata.blockType) == tableHeaderAttribution ? 'th' : 'td';
+          htmlBuffer.write('<$tag${_getTextAlignStyle(cell)}>$cellContent</$tag>');
+        }
+        htmlBuffer.write('</tr>');
+      }
+      htmlBuffer.write('</tbody>');
+    }
+
+    htmlBuffer.write('</table>');
+    return htmlBuffer.toString();
+  }
+
+  String _getTextAlignStyle(TextNode cell) {
+    final textAlign = cell.getMetadataValue('textAlign');
+    if (textAlign == TextAlign.left) {
+      // Default alignment is left, so we don't need to specify it.
+      return '';
+    }
+    final textAlignString = switch (textAlign) {
+      TextAlign.center => 'center',
+      TextAlign.right => 'right',
+      _ => 'left',
+    };
+    return textAlign != null ? ' style="text-align:$textAlignString"' : '';
+  }
+}
diff --git a/super_editor/lib/super_editor.dart b/super_editor/lib/super_editor.dart
index 8cb0a52bf4..d51aabb8bd 100644
--- a/super_editor/lib/super_editor.dart
+++ b/super_editor/lib/super_editor.dart
@@ -49,6 +49,7 @@ 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/tasks.dart';
 export 'src/default_editor/text.dart';
 export 'src/default_editor/text_ai.dart';
diff --git a/super_editor_clipboard/lib/src/document_copy.dart b/super_editor_clipboard/lib/src/document_copy.dart
index 09156e8353..a392711ba7 100644
--- a/super_editor_clipboard/lib/src/document_copy.dart
+++ b/super_editor_clipboard/lib/src/document_copy.dart
@@ -1,8 +1,9 @@
 import 'package:super_clipboard/super_clipboard.dart';
 import 'package:super_editor/super_editor.dart';
+import 'package:super_editor_markdown/super_editor_markdown.dart';
 
 extension RichTextCopy on Document {
-  Future<void> copyAsRichText({
+  Future<void> copyAsRichTextWithPlainTextFallback({
     DocumentSelection? selection,
   }) async {
     final clipboard = SystemClipboard.instance;
@@ -27,6 +28,33 @@ extension RichTextCopy on Document {
     // Write the document to the clipboard.
     await clipboard.write([item]);
   }
+
+  Future<void> copyAsRichTextWithMarkdownFallback({
+    DocumentSelection? selection,
+  }) async {
+    final clipboard = SystemClipboard.instance;
+    if (clipboard == null) {
+      return; // Clipboard API is not supported on this platform.
+    }
+
+    final item = DataWriterItem();
+
+    // Serialize to HTML as the most common representation of rich text
+    // across apps.
+    item.add(Formats.htmlText(toHtml(
+      selection: selection,
+      nodeSerializers: SuperEditorClipboardConfig.nodeHtmlSerializers,
+      inlineSerializers: SuperEditorClipboardConfig.inlineHtmlSerializers,
+    )));
+
+    // Serialize to Markdown as a plain text representation of rich text.
+    item.add(Formats.plainText(
+      serializeDocumentToMarkdown(this, selection: selection),
+    ));
+
+    // Write the document to the clipboard.
+    await clipboard.write([item]);
+  }
 }
 
 /// A global configuration for rich text serializers, which can be globally customized
diff --git a/super_editor_clipboard/lib/src/super_editor_copy.dart b/super_editor_clipboard/lib/src/super_editor_copy.dart
index bf95266ae2..8e7e73a86c 100644
--- a/super_editor_clipboard/lib/src/super_editor_copy.dart
+++ b/super_editor_clipboard/lib/src/super_editor_copy.dart
@@ -23,7 +23,7 @@ ExecutionInstruction copyAsRichTextWhenCmdCOrCtrlCIsPressed({
     return ExecutionInstruction.haltExecution;
   }
 
-  editContext.document.copyAsRichText(
+  editContext.document.copyAsRichTextWithPlainTextFallback(
     selection: editContext.composer.selection!,
   );
 
diff --git a/super_editor_clipboard/lib/src/super_reader_copy.dart b/super_editor_clipboard/lib/src/super_reader_copy.dart
index e8f02def05..cb4b037bb4 100644
--- a/super_editor_clipboard/lib/src/super_reader_copy.dart
+++ b/super_editor_clipboard/lib/src/super_reader_copy.dart
@@ -14,7 +14,7 @@ final copyAsRichTextWhenCmdCIsPressedOnMac = createShortcut(
       return ExecutionInstruction.haltExecution;
     }
 
-    documentContext.document.copyAsRichText(
+    documentContext.document.copyAsRichTextWithPlainTextFallback(
       selection: documentContext.composer.selection!,
     );
 
@@ -37,7 +37,7 @@ final copyAsRichTextWhenCtrlCIsPressedOnWindowsAndLinux = createShortcut(
       return ExecutionInstruction.haltExecution;
     }
 
-    documentContext.document.copyAsRichText(
+    documentContext.document.copyAsRichTextWithPlainTextFallback(
       selection: documentContext.composer.selection!,
     );
 
diff --git a/super_editor_clipboard/pubspec.yaml b/super_editor_clipboard/pubspec.yaml
index 5dd61a08a6..215e2b2fdc 100644
--- a/super_editor_clipboard/pubspec.yaml
+++ b/super_editor_clipboard/pubspec.yaml
@@ -12,11 +12,14 @@ dependencies:
   flutter:
     sdk: flutter
   super_clipboard: ^0.9.1
-  super_editor: 0.3.0-dev.28
+  super_editor: 0.3.0-dev.29
+  super_editor_markdown: 0.1.8
 
 dependency_overrides:
   super_editor:
     path: ../super_editor
+  super_editor_markdown:
+    path: ../super_editor_markdown
 
 dev_dependencies:
   flutter_test:
diff --git a/super_editor_markdown/lib/src/document_to_markdown_serializer.dart b/super_editor_markdown/lib/src/document_to_markdown_serializer.dart
index 8a628d2267..be90d24229 100644
--- a/super_editor_markdown/lib/src/document_to_markdown_serializer.dart
+++ b/super_editor_markdown/lib/src/document_to_markdown_serializer.dart
@@ -1,3 +1,5 @@
+import 'dart:ui';
+
 import 'package:flutter/foundation.dart';
 import 'package:super_editor/super_editor.dart';
 
@@ -5,6 +7,8 @@ import 'super_editor_syntax.dart';
 
 /// Serializes the given [doc] to Markdown text.
 ///
+/// When [selection] is provided, only the selected range of the document is serialized.
+///
 /// The given [syntax] controls how the [doc] is serialized, e.g., [MarkdownSyntax.normal]
 /// for standard Markdown syntax, or [MarkdownSyntax.superEditor] to use Super Editor's
 /// extended syntax.
@@ -13,6 +17,7 @@ import 'super_editor_syntax.dart';
 /// provide [customNodeSerializers] to serialize those custom nodes.
 String serializeDocumentToMarkdown(
   Document doc, {
+  DocumentSelection? selection,
   MarkdownSyntax syntax = MarkdownSyntax.superEditor,
   List<DocumentNodeMarkdownSerializer> customNodeSerializers = const [],
 }) {
@@ -27,21 +32,58 @@ String serializeDocumentToMarkdown(
     const TaskNodeSerializer(),
     HeaderNodeSerializer(syntax),
     ParagraphNodeSerializer(syntax),
+    const TableBlockNodeSerializer(),
   ];
 
   StringBuffer buffer = StringBuffer();
 
-  for (int i = 0; i < doc.nodeCount; ++i) {
-    if (i > 0) {
-      // Add a new line before every node, except the first node.
-      buffer.writeln("");
+  late final DocumentRange? selectedRange;
+  late final List<DocumentNode> selectedNodes;
+  if (selection != null) {
+    selectedRange = selection.normalize(doc);
+    selectedNodes = doc.getNodesInside(
+      selectedRange.start,
+      selectedRange.end,
+    );
+  } else {
+    selectedRange = null;
+    selectedNodes = doc.toList(growable: false);
+  }
+
+  for (int i = 0; i < selectedNodes.length; ++i) {
+    final node = selectedNodes[i];
+    late final NodeSelection? nodeSelection;
+    if (selectedRange != null && node.id == selectedRange.start.nodeId && node.id == selectedRange.end.nodeId) {
+      // The entire copy selection is within this node.
+      nodeSelection = node.computeSelection(
+        base: selectedRange.start.nodePosition,
+        extent: selectedRange.end.nodePosition,
+      );
+    } else if (selectedRange != null && node.id == selectedRange.start.nodeId) {
+      // The selection starts somewhere in this node and goes to the end of the node.
+      nodeSelection = node.computeSelection(
+        base: selectedRange.start.nodePosition,
+        extent: node.endPosition,
+      );
+    } else if (selectedRange != null && node.id == selectedRange.end.nodeId) {
+      // The selection starts at the beginning of this node and ends somewhere within this node.
+      nodeSelection = node.computeSelection(
+        base: node.beginningPosition,
+        extent: selectedRange.end.nodePosition,
+      );
+    } else {
+      // The node is fully selected, so we don't need to specify a selection.
+      nodeSelection = null;
     }
 
-    // Serialize the current node to markdown.
-    final node = doc.getNodeAt(i)!;
     for (final serializer in nodeSerializers) {
-      final serialization = serializer.serialize(doc, node);
+      final serialization = serializer.serialize(doc, node, selection: nodeSelection);
       if (serialization != null) {
+        if (i > 0) {
+          // Add a new line before every node, except the first node.
+          buffer.writeln("");
+        }
+
         buffer.write(serialization);
         break;
       }
@@ -53,7 +95,17 @@ String serializeDocumentToMarkdown(
 
 /// Serializes a given [DocumentNode] to a Markdown `String`.
 abstract class DocumentNodeMarkdownSerializer {
-  String? serialize(Document document, DocumentNode node);
+  /// Serializes the given [node] to a Markdown `String`.
+  ///
+  /// When [selection] is `null`, the entire node is converted to markdown. When
+  /// [selection] is non-`null`, only the selected range is converted to markdown.
+  ///
+  /// Returns `null` if the [node] is not supported by this serializer.
+  String? serialize(
+    Document document,
+    DocumentNode node, {
+    NodeSelection? selection,
+  });
 }
 
 /// A [DocumentNodeMarkdownSerializer] that automatically rejects any
@@ -65,16 +117,24 @@ abstract class NodeTypedDocumentNodeMarkdownSerializer<NodeType> implements Docu
   const NodeTypedDocumentNodeMarkdownSerializer();
 
   @override
-  String? serialize(Document document, DocumentNode node) {
+  String? serialize(
+    Document document,
+    DocumentNode node, {
+    NodeSelection? selection,
+  }) {
     if (node is! NodeType) {
       return null;
     }
 
-    return doSerialization(document, node as NodeType);
+    return doSerialization(document, node as NodeType, selection: selection);
   }
 
   @protected
-  String doSerialization(Document document, NodeType node);
+  String doSerialization(
+    Document document,
+    NodeType node, {
+    NodeSelection? selection,
+  });
 }
 
 /// [DocumentNodeMarkdownSerializer] for serializing [ImageNode]s as standard Markdown
@@ -87,7 +147,23 @@ class ImageNodeSerializer extends NodeTypedDocumentNodeMarkdownSerializer<ImageN
   final bool useSizeNotation;
 
   @override
-  String doSerialization(Document document, ImageNode node) {
+  String doSerialization(
+    Document document,
+    ImageNode node, {
+    NodeSelection? selection,
+  }) {
+    if (selection != null) {
+      if (selection is! UpstreamDownstreamNodeSelection) {
+        // We don't know how to handle this selection type.
+        return '';
+      }
+      if (selection.isCollapsed) {
+        // This selection doesn't include the image - it's a collapsed selection
+        // either on the upstream or downstream edge.
+        return '';
+      }
+    }
+
     if (!useSizeNotation || (node.expectedBitmapSize?.width == null && node.expectedBitmapSize?.height == null)) {
       // We don't want to use size notation or the image doesn't have
       // size information. Use the regular syntax.
@@ -117,7 +193,23 @@ class HorizontalRuleNodeSerializer extends NodeTypedDocumentNodeMarkdownSerializ
   const HorizontalRuleNodeSerializer();
 
   @override
-  String doSerialization(Document document, HorizontalRuleNode node) {
+  String doSerialization(
+    Document document,
+    HorizontalRuleNode node, {
+    NodeSelection? selection,
+  }) {
+    if (selection != null) {
+      if (selection is! UpstreamDownstreamNodeSelection) {
+        // We don't know how to handle this selection type.
+        return '';
+      }
+      if (selection.isCollapsed) {
+        // This selection doesn't include the horizontal rule - it's a collapsed selection
+        // either on the upstream or downstream edge.
+        return '';
+      }
+    }
+
     return '---';
   }
 }
@@ -130,13 +222,30 @@ class ListItemNodeSerializer extends NodeTypedDocumentNodeMarkdownSerializer<Lis
   const ListItemNodeSerializer();
 
   @override
-  String doSerialization(Document document, ListItemNode node) {
+  String doSerialization(
+    Document document,
+    ListItemNode node, {
+    NodeSelection? selection,
+  }) {
+    if (selection != null && selection is! TextNodeSelection) {
+      // We don't know how to handle this selection type.
+      return '';
+    }
+    final textSelection = selection as TextNodeSelection?;
+    if (textSelection != null && textSelection.isCollapsed) {
+      // Selection is collapsed. Nothing is selected for copy.
+      return '';
+    }
+    final textToConvert = textSelection != null //
+        ? node.text.copyText(textSelection.start, textSelection.end)
+        : node.text;
+
     final buffer = StringBuffer();
 
     final indent = List.generate(node.indent + 1, (index) => '  ').join('');
     final symbol = node.type == ListItemType.unordered ? '*' : '1.';
 
-    buffer.write('$indent$symbol ${node.text.toMarkdown()}');
+    buffer.write('$indent$symbol ${textToConvert.toMarkdown()}');
 
     final nodeIndex = document.getNodeIndexById(node.id);
     final nodeBelow = nodeIndex < document.nodeCount - 1 ? document.getNodeAt(nodeIndex + 1) : null;
@@ -160,30 +269,49 @@ class ParagraphNodeSerializer extends NodeTypedDocumentNodeMarkdownSerializer<Pa
   final MarkdownSyntax markdownSyntax;
 
   @override
-  String doSerialization(Document document, ParagraphNode node) {
+  String doSerialization(
+    Document document,
+    ParagraphNode node, {
+    NodeSelection? selection,
+  }) {
+    if (selection != null && selection is! TextNodeSelection) {
+      // We don't know how to handle this selection type.
+      return '';
+    }
+    final textSelection = selection as TextNodeSelection?;
+    if (textSelection != null && textSelection.isCollapsed) {
+      // Selection is collapsed. Nothing is selected for copy.
+      return '';
+    }
+
     final buffer = StringBuffer();
 
     final Attribution? blockType = node.getMetadataValue('blockType');
 
+    final inlineMarkdown = (textSelection != null //
+            ? node.text.copyText(textSelection.start, textSelection.end)
+            : node.text)
+        .toMarkdown();
+
     if (blockType == header1Attribution) {
-      buffer.write('# ${node.text.toMarkdown()}');
+      buffer.write('# $inlineMarkdown');
     } else if (blockType == header2Attribution) {
-      buffer.write('## ${node.text.toMarkdown()}');
+      buffer.write('## $inlineMarkdown');
     } else if (blockType == header3Attribution) {
-      buffer.write('### ${node.text.toMarkdown()}');
+      buffer.write('### $inlineMarkdown');
     } else if (blockType == header4Attribution) {
-      buffer.write('#### ${node.text.toMarkdown()}');
+      buffer.write('#### $inlineMarkdown');
     } else if (blockType == header5Attribution) {
-      buffer.write('##### ${node.text.toMarkdown()}');
+      buffer.write('##### $inlineMarkdown');
     } else if (blockType == header6Attribution) {
-      buffer.write('###### ${node.text.toMarkdown()}');
+      buffer.write('###### $inlineMarkdown');
     } else if (blockType == blockquoteAttribution) {
       // TODO: handle multiline
-      buffer.write('> ${node.text.toMarkdown()}');
+      buffer.write('> $inlineMarkdown');
     } else if (blockType == codeAttribution) {
       buffer //
         ..writeln('```') //
-        ..writeln(node.text.toMarkdown()) //
+        ..writeln(inlineMarkdown) //
         ..write('```');
     } else {
       final String? textAlign = node.getMetadataValue('textAlign');
@@ -194,7 +322,7 @@ class ParagraphNodeSerializer extends NodeTypedDocumentNodeMarkdownSerializer<Pa
           buffer.writeln(alignmentToken);
         }
       }
-      buffer.write(node.text.toMarkdown());
+      buffer.write(inlineMarkdown);
     }
 
     // We're not at the end of the document yet. Add a blank line after the
@@ -217,8 +345,25 @@ class TaskNodeSerializer extends NodeTypedDocumentNodeMarkdownSerializer<TaskNod
   const TaskNodeSerializer();
 
   @override
-  String doSerialization(Document document, TaskNode node) {
-    return '- [${node.isComplete ? 'x' : ' '}] ${node.text.toPlainText()}';
+  String doSerialization(
+    Document document,
+    TaskNode node, {
+    NodeSelection? selection,
+  }) {
+    if (selection != null && selection is! TextNodeSelection) {
+      // We don't know how to handle this selection type.
+      return '';
+    }
+    final textSelection = selection as TextNodeSelection?;
+    if (textSelection != null && textSelection.isCollapsed) {
+      // Selection is collapsed. Nothing is selected for copy.
+      return '';
+    }
+    final textToConvert = textSelection != null //
+        ? node.text.copyText(textSelection.start, textSelection.end)
+        : node.text;
+
+    return '- [${node.isComplete ? 'x' : ' '}] ${textToConvert.toMarkdown()}';
   }
 }
 
@@ -402,7 +547,11 @@ class HeaderNodeSerializer extends NodeTypedDocumentNodeMarkdownSerializer<Parag
   final MarkdownSyntax markdownSyntax;
 
   @override
-  String? serialize(Document document, DocumentNode node) {
+  String? serialize(
+    Document document,
+    DocumentNode node, {
+    NodeSelection? selection,
+  }) {
     if (node is! ParagraphNode) {
       return null;
     }
@@ -424,7 +573,24 @@ class HeaderNodeSerializer extends NodeTypedDocumentNodeMarkdownSerializer<Parag
   }
 
   @override
-  String doSerialization(Document document, ParagraphNode node) {
+  String doSerialization(
+    Document document,
+    ParagraphNode node, {
+    NodeSelection? selection,
+  }) {
+    if (selection != null && selection is! TextNodeSelection) {
+      // We don't know how to handle this selection type.
+      return '';
+    }
+    final textSelection = selection as TextNodeSelection?;
+    if (textSelection != null && textSelection.isCollapsed) {
+      // Selection is collapsed. Nothing is selected for copy.
+      return '';
+    }
+    final textToConvert = textSelection != null //
+        ? node.text.copyText(textSelection.start, textSelection.end)
+        : node.text;
+
     final buffer = StringBuffer();
 
     final Attribution? blockType = node.getMetadataValue('blockType');
@@ -439,17 +605,17 @@ class HeaderNodeSerializer extends NodeTypedDocumentNodeMarkdownSerializer<Parag
     }
 
     if (blockType == header1Attribution) {
-      buffer.write('# ${node.text.toMarkdown()}');
+      buffer.write('# ${textToConvert.toMarkdown()}');
     } else if (blockType == header2Attribution) {
-      buffer.write('## ${node.text.toMarkdown()}');
+      buffer.write('## ${textToConvert.toMarkdown()}');
     } else if (blockType == header3Attribution) {
-      buffer.write('### ${node.text.toMarkdown()}');
+      buffer.write('### ${textToConvert.toMarkdown()}');
     } else if (blockType == header4Attribution) {
-      buffer.write('#### ${node.text.toMarkdown()}');
+      buffer.write('#### ${textToConvert.toMarkdown()}');
     } else if (blockType == header5Attribution) {
-      buffer.write('##### ${node.text.toMarkdown()}');
+      buffer.write('##### ${textToConvert.toMarkdown()}');
     } else if (blockType == header6Attribution) {
-      buffer.write('###### ${node.text.toMarkdown()}');
+      buffer.write('###### ${textToConvert.toMarkdown()}');
     }
 
     // We're not at the end of the document yet. Add a blank line after the
@@ -463,3 +629,91 @@ class HeaderNodeSerializer extends NodeTypedDocumentNodeMarkdownSerializer<Parag
     return buffer.toString();
   }
 }
+
+/// [DocumentNodeMarkdownSerializer] for serializing [TableBlockNode]s as the extended Markdown
+/// syntax for tables.
+///
+/// See https://www.markdownguide.org/extended-syntax/#tables for the specification.
+class TableBlockNodeSerializer extends NodeTypedDocumentNodeMarkdownSerializer<TableBlockNode> {
+  const TableBlockNodeSerializer();
+
+  @override
+  String doSerialization(
+    Document document,
+    TableBlockNode node, {
+    NodeSelection? selection,
+  }) {
+    if (selection != null) {
+      if (selection is! UpstreamDownstreamNodeSelection) {
+        // We don't know how to handle this selection type.
+        return '';
+      }
+      if (selection.isCollapsed) {
+        // This selection doesn't include the table - it's a collapsed selection
+        // either on the upstream or downstream edge.
+        return '';
+      }
+    }
+
+    if (node.rowCount == 0) {
+      // The table must have at least one row (the header row) to be serialized.
+      return '';
+    }
+
+    final buffer = StringBuffer();
+
+    final headerRow = node.getRow(0);
+
+    // Serialize the header values.
+    buffer.write('|');
+    for (final cell in headerRow) {
+      buffer.write(' ');
+      buffer.write(cell.text.toMarkdown());
+      buffer.write(' |');
+    }
+
+    // Serialize the header separator row.
+    buffer.writeln();
+    buffer.write('|');
+    for (int i = 0; i < headerRow.length; i++) {
+      buffer.write(' ');
+
+      final firstDataCell = node.rowCount > 1 //
+          ? node.getCell(rowIndex: 1, columnIndex: i)
+          : null;
+
+      buffer.write(_getHeaderSeparatorColumnContent(firstDataCell));
+      buffer.write(' |');
+    }
+
+    // Serialize the data rows.
+    if (node.rowCount > 1) {
+      for (int i = 1; i < node.rowCount; i++) {
+        buffer.writeln();
+        final row = node.getRow(i);
+
+        buffer.write('|');
+        for (final cell in row) {
+          buffer.write(' ');
+          buffer.write(cell.text.toMarkdown());
+          buffer.write(' |');
+        }
+      }
+    }
+
+    return buffer.toString();
+  }
+
+  String _getHeaderSeparatorColumnContent(TextNode? firstDataCell) {
+    if (firstDataCell == null) {
+      return '---';
+    }
+
+    final textAlign = firstDataCell.getMetadataValue('textAlign');
+    return switch (textAlign) {
+      TextAlign.center => ':--:',
+      TextAlign.right => '--:',
+      _ => '---',
+    };
+  }
+}


Test patch:
diff --git a/super_editor/test/infrastructure/serialization/html/document_to_html_test.dart b/super_editor/test/infrastructure/serialization/html/document_to_html_test.dart
index 40c2417e40..ddd42948ab 100644
--- a/super_editor/test/infrastructure/serialization/html/document_to_html_test.dart
+++ b/super_editor/test/infrastructure/serialization/html/document_to_html_test.dart
@@ -1,6 +1,6 @@
+import 'dart:ui';
+
 import 'package:flutter_test/flutter_test.dart';
-import 'package:super_editor/src/infrastructure/serialization/html/document_to_html.dart';
-import 'package:super_editor/src/infrastructure/serialization/html/html_inline_text_styles.dart';
 import 'package:super_editor/super_editor.dart';
 import 'package:super_editor_markdown/super_editor_markdown.dart';
 
@@ -278,6 +278,497 @@ void main() {
       );
     });
 
+    group('tables >', () {
+      test("with header and body", () {
+        final document = MutableDocument(
+          nodes: [
+            TableBlockNode(id: "1", cells: [
+              [
+                TextNode(
+                  id: "1.0.1",
+                  text: AttributedText("Column 1"),
+                  metadata: const {NodeMetadata.blockType: tableHeaderAttribution},
+                ),
+                TextNode(
+                  id: "1.0.2",
+                  text: AttributedText("Column 2"),
+                  metadata: const {NodeMetadata.blockType: tableHeaderAttribution},
+                ),
+                TextNode(
+                  id: "1.0.3",
+                  text: AttributedText("Column 3"),
+                  metadata: const {NodeMetadata.blockType: tableHeaderAttribution},
+                ),
+              ],
+              [
+                TextNode(id: "1.1.1", text: AttributedText("Value 1.1")),
+                TextNode(id: "1.1.2", text: AttributedText("Value 1.2")),
+                TextNode(id: "1.1.3", text: AttributedText("Value 1.3")),
+              ],
+              [
+                TextNode(id: "1.2.1", text: AttributedText("Value 2.1")),
+                TextNode(id: "1.2.2", text: AttributedText("Value 2.2")),
+                TextNode(id: "1.2.3", text: AttributedText("Value 2.3")),
+              ],
+            ]),
+          ],
+        );
+
+        expect(
+          document.toHtml(),
+          [
+            '<table>',
+            '<thead>',
+            '<tr>',
+            '<th>Column 1</th>',
+            '<th>Column 2</th>',
+            '<th>Column 3</th>',
+            '</tr>',
+            '</thead>',
+            '<tbody>',
+            '<tr>',
+            '<td>Value 1.1</td>',
+            '<td>Value 1.2</td>',
+            '<td>Value 1.3</td>',
+            '</tr>',
+            '<tr>',
+            '<td>Value 2.1</td>',
+            '<td>Value 2.2</td>',
+            '<td>Value 2.3</td>',
+            '</tr>',
+            '</tbody>',
+            '</table>',
+          ].join(),
+        );
+      });
+
+      test("with multiple header rows", () {
+        final document = MutableDocument(
+          nodes: [
+            TableBlockNode(
+              id: "1",
+              cells: [
+                [
+                  TextNode(
+                    id: "1.0.1",
+                    text: AttributedText("Column 1"),
+                    metadata: const {NodeMetadata.blockType: tableHeaderAttribution},
+                  ),
+                  TextNode(
+                    id: "1.0.2",
+                    text: AttributedText("Column 2"),
+                    metadata: const {NodeMetadata.blockType: tableHeaderAttribution},
+                  ),
+                ],
+                [
+                  TextNode(
+                    id: "2.0.1",
+                    text: AttributedText("Another Column 1"),
+                    metadata: const {NodeMetadata.blockType: tableHeaderAttribution},
+                  ),
+                  TextNode(
+                    id: "2.0.2",
+                    text: AttributedText("Another Column 2"),
+                    metadata: const {NodeMetadata.blockType: tableHeaderAttribution},
+                  ),
+                ],
+                [
+                  TextNode(
+                    id: "1.1.1",
+                    text: AttributedText("Value 1.1"),
+                  ),
+                  TextNode(
+                    id: "1.1.2",
+                    text: AttributedText("Value 1.2"),
+                  ),
+                ],
+              ],
+            ),
+          ],
+        );
+
+        expect(
+          document.toHtml(),
+          [
+            '<table>',
+            '<thead>',
+            '<tr>',
+            '<th>Column 1</th>',
+            '<th>Column 2</th>',
+            '</tr>',
+            '<tr>',
+            '<th>Another Column 1</th>',
+            '<th>Another Column 2</th>',
+            '</tr>',
+            '</thead>',
+            '<tbody>',
+            '<tr>',
+            '<td>Value 1.1</td>',
+            '<td>Value 1.2</td>',
+            '</tr>',
+            '</tbody>',
+            '</table>',
+          ].join(),
+        );
+      });
+
+      test("rows after the first data row are serialized inside <tbody>", () {
+        final document = MutableDocument(
+          nodes: [
+            TableBlockNode(
+              id: "1",
+              cells: [
+                [
+                  TextNode(
+                    id: "1.1",
+                    text: AttributedText("Column 1"),
+                    metadata: const {NodeMetadata.blockType: tableHeaderAttribution},
+                  ),
+                  TextNode(
+                    id: "1.2",
+                    text: AttributedText("Column 2"),
+                    metadata: const {NodeMetadata.blockType: tableHeaderAttribution},
+                  ),
+                ],
+                [
+                  TextNode(
+                    id: "2.1",
+                    text: AttributedText("Value 1"),
+                  ),
+                  TextNode(
+                    id: "2.2",
+                    text: AttributedText("Value 2"),
+                  ),
+                ],
+                [
+                  TextNode(
+                    id: "3.1",
+                    text: AttributedText("Value 3"),
+                    metadata: const {NodeMetadata.blockType: tableHeaderAttribution},
+                  ),
+                  TextNode(
+                    id: "3.2",
+                    text: AttributedText("Value 4"),
+                    metadata: const {NodeMetadata.blockType: tableHeaderAttribution},
+                  ),
+                ],
+              ],
+            ),
+          ],
+        );
+
+        expect(
+          document.toHtml(),
+          [
+            '<table>',
+            '<thead>',
+            '<tr>',
+            '<th>Column 1</th>',
+            '<th>Column 2</th>',
+            '</tr>',
+            '</thead>',
+            '<tbody>',
+            '<tr>',
+            '<td>Value 1</td>',
+            '<td>Value 2</td>',
+            '</tr>',
+            '<tr>',
+            '<th>Value 3</th>',
+            '<th>Value 4</th>',
+            '</tr>',
+            '</tbody>',
+            '</table>',
+          ].join(),
+        );
+      });
+
+      test("rows containing data cells are serialized inside <tbody>", () {
+        final document = MutableDocument(
+          nodes: [
+            TableBlockNode(
+              id: "1",
+              cells: [
+                [
+                  TextNode(
+                    id: "1.1",
+                    text: AttributedText("Column 1"),
+                    metadata: const {NodeMetadata.blockType: tableHeaderAttribution},
+                  ),
+                  TextNode(
+                    id: "1.2",
+                    text: AttributedText("Column 2"),
+                    metadata: const {NodeMetadata.blockType: tableHeaderAttribution},
+                  ),
+                ],
+                [
+                  TextNode(
+                    id: "2.1",
+                    text: AttributedText("Value 1"),
+                    metadata: const {NodeMetadata.blockType: tableHeaderAttribution},
+                  ),
+                  TextNode(
+                    id: "2.2",
+                    text: AttributedText("Value 2"),
+                  ),
+                ],
+                [
+                  TextNode(
+                    id: "3.1",
+                    text: AttributedText("Value 3"),
+                  ),
+                  TextNode(
+                    id: "3.2",
+                    text: AttributedText("Value 4"),
+                  ),
+                ],
+              ],
+            ),
+          ],
+        );
+
+        expect(
+          document.toHtml(),
+          [
+            '<table>',
+            '<thead>',
+            '<tr>',
+            '<th>Column 1</th>',
+            '<th>Column 2</th>',
+            '</tr>',
+            '</thead>',
+            '<tbody>',
+            '<tr>',
+            '<th>Value 1</th>',
+            '<td>Value 2</td>',
+            '</tr>',
+            '<tr>',
+            '<td>Value 3</td>',
+            '<td>Value 4</td>',
+            '</tr>',
+            '</tbody>',
+            '</table>',
+          ].join(),
+        );
+      });
+
+      test("without body", () {
+        final document = MutableDocument(
+          nodes: [
+            TableBlockNode(id: "1", cells: [
+              [
+                TextNode(
+                  id: "1.0.1",
+                  text: AttributedText("Column 1"),
+                  metadata: const {NodeMetadata.blockType: tableHeaderAttribution},
+                ),
+                TextNode(
+                  id: "1.0.2",
+                  text: AttributedText("Column 2"),
+                  metadata: const {NodeMetadata.blockType: tableHeaderAttribution},
+                ),
+                TextNode(
+                  id: "1.0.3",
+                  text: AttributedText("Column 3"),
+                  metadata: const {NodeMetadata.blockType: tableHeaderAttribution},
+                ),
+              ],
+            ]),
+          ],
+        );
+
+        expect(
+          document.toHtml(),
+          [
+            '<table>',
+            '<thead>',
+            '<tr>',
+            '<th>Column 1</th>',
+            '<th>Column 2</th>',
+            '<th>Column 3</th>',
+            '</tr>',
+            '</thead>',
+            '</table>',
+          ].join(),
+        );
+      });
+
+      test("with header cell inside data row", () {
+        final document = MutableDocument(
+          nodes: [
+            TableBlockNode(
+              id: "1",
+              cells: [
+                [
+                  TextNode(
+                    id: "1.1",
+                    text: AttributedText("Value 1.1"),
+                    metadata: const {NodeMetadata.blockType: tableHeaderAttribution},
+                  ),
+                  TextNode(
+                    id: "1.2",
+                    text: AttributedText("Value 1.2"),
+                  ),
+                ],
+                [
+                  TextNode(
+                    id: "2.1",
+                    text: AttributedText("Value 2.1"),
+                  ),
+                  TextNode(
+                    id: "2.2",
+                    text: AttributedText("Value 2.2"),
+                    metadata: const {NodeMetadata.blockType: tableHeaderAttribution},
+                  ),
+                ],
+              ],
+            ),
+          ],
+        );
+
+        expect(
+          document.toHtml(),
+          [
+            '<table>',
+            '<tbody>',
+            '<tr>',
+            '<th>Value 1.1</th>',
+            '<td>Value 1.2</td>',
+            '</tr>',
+            '<tr>',
+            '<td>Value 2.1</td>',
+            '<th>Value 2.2</th>',
+            '</tr>',
+            '</tbody>',
+            '</table>',
+          ].join(),
+        );
+      });
+
+      test("with alignment", () {
+        final document = MutableDocument(
+          nodes: [
+            TableBlockNode(
+              id: "1",
+              cells: [
+                [
+                  TextNode(
+                    id: "1.0.1",
+                    text: AttributedText("Column 1"),
+                    metadata: const {NodeMetadata.blockType: tableHeaderAttribution},
+                  ),
+                  TextNode(
+                    id: "1.0.2",
+                    text: AttributedText("Column 2"),
+                    metadata: const {NodeMetadata.blockType: tableHeaderAttribution},
+                  ),
+                ],
+                [
+                  TextNode(
+                    id: "1.1.1",
+                    text: AttributedText("Value 1.1"),
+                    metadata: const {"textAlign": TextAlign.right},
+                  ),
+                  TextNode(
+                    id: "1.1.2",
+                    text: AttributedText("Value 1.2"),
+                    metadata: const {"textAlign": TextAlign.center},
+                  ),
+                ],
+              ],
+            ),
+          ],
+        );
+
+        expect(
+          document.toHtml(),
+          [
+            '<table>',
+            '<thead>',
+            '<tr>',
+            '<th>Column 1</th>',
+            '<th>Column 2</th>',
+            '</tr>',
+            '</thead>',
+            '<tbody>',
+            '<tr>',
+            '<td style="text-align:right">Value 1.1</td>',
+            '<td style="text-align:center">Value 1.2</td>',
+            '</tr>',
+            '</tbody>',
+            '</table>',
+          ].join(),
+        );
+      });
+
+      test("with inline styles", () {
+        final document = MutableDocument(
+          nodes: [
+            TableBlockNode(
+              id: "1",
+              cells: [
+                [
+                  TextNode(
+                    id: "1.0.1",
+                    text: AttributedText("Column 1"),
+                    metadata: const {NodeMetadata.blockType: tableHeaderAttribution},
+                  ),
+                  TextNode(
+                    id: "1.0.2",
+                    text: AttributedText("Column 2"),
+                    metadata: const {NodeMetadata.blockType: tableHeaderAttribution},
+                  ),
+                ],
+                [
+                  TextNode(
+                    id: "1.1.1",
+                    text: AttributedText(
+                      "Value 1.1",
+                      AttributedSpans(
+                        attributions: const [
+                          SpanMarker(attribution: boldAttribution, offset: 0, markerType: SpanMarkerType.start),
+                          SpanMarker(attribution: boldAttribution, offset: 8, markerType: SpanMarkerType.end),
+                        ],
+                      ),
+                    ),
+                  ),
+                  TextNode(
+                    id: "1.1.2",
+                    text: AttributedText(
+                      "Value 1.2",
+                      AttributedSpans(
+                        attributions: const [
+                          SpanMarker(attribution: italicsAttribution, offset: 0, markerType: SpanMarkerType.start),
+                          SpanMarker(attribution: italicsAttribution, offset: 8, markerType: SpanMarkerType.end),
+                        ],
+                      ),
+                    ),
+                  ),
+                ],
+              ],
+            ),
+          ],
+        );
+
+        expect(
+          document.toHtml(),
+          [
+            '<table>',
+            '<thead><tr>',
+            '<th>Column 1</th>',
+            '<th>Column 2</th>',
+            '</tr></thead>',
+            '<tbody>',
+            '<tr>',
+            '<td><strong>Value 1.1</strong></td>',
+            '<td><i>Value 1.2</i></td>',
+            '</tr>',
+            '</tbody>',
+            '</table>',
+          ].join(),
+        );
+      });
+    });
+
     group("custom serialization >", () {
       test("custom node type", () {
         expect(
diff --git a/super_editor_markdown/test/custom_parsers/callout_block.dart b/super_editor_markdown/test/custom_parsers/callout_block.dart
index 075e09b944..f12b92e769 100644
--- a/super_editor_markdown/test/custom_parsers/callout_block.dart
+++ b/super_editor_markdown/test/custom_parsers/callout_block.dart
@@ -192,7 +192,7 @@ class _InlineMarkdownToDocument implements md.NodeVisitor {
 
 class CalloutSerializer implements DocumentNodeMarkdownSerializer {
   @override
-  String? serialize(Document document, DocumentNode node) {
+  String? serialize(Document document, DocumentNode node, {NodeSelection? selection}) {
     if (node is! ParagraphNode) {
       return null;
     }
diff --git a/super_editor_markdown/test/custom_parsers/upsell_block.dart b/super_editor_markdown/test/custom_parsers/upsell_block.dart
index 26f842ad51..0fde7c59f1 100644
--- a/super_editor_markdown/test/custom_parsers/upsell_block.dart
+++ b/super_editor_markdown/test/custom_parsers/upsell_block.dart
@@ -69,7 +69,7 @@ class UpsellElementToNodeConverter implements ElementToNodeConverter {
 
 class UpsellSerializer extends NodeTypedDocumentNodeMarkdownSerializer<UpsellNode> {
   @override
-  String doSerialization(Document document, UpsellNode node) {
+  String doSerialization(Document document, UpsellNode node, {NodeSelection? selection}) {
     return "@@@ upsell\n";
   }
 }
diff --git a/super_editor_markdown/test/super_editor_markdown_test.dart b/super_editor_markdown/test/super_editor_markdown_test.dart
index a1aa93d14d..af3621a969 100644
--- a/super_editor_markdown/test/super_editor_markdown_test.dart
+++ b/super_editor_markdown/test/super_editor_markdown_test.dart
@@ -749,13 +749,25 @@ Paragraph3""");
           serializeDocumentToMarkdown(doc),
           '''
 - [x] Task 1
-- [ ] Task 2
+- [ ] Task 2  
 with multiple lines
 - [ ] Task 3
 - [x] Task 4''',
         );
       });
 
+      test('task with styles', () {
+        final doc = MutableDocument(nodes: [
+          TaskNode(
+            id: '1',
+            text: attributedTextFromMarkdown('**Task** 1'),
+            isComplete: false,
+          ),
+        ]);
+
+        expect(serializeDocumentToMarkdown(doc), '- [ ] **Task** 1');
+      });
+
       test('example doc', () {
         final doc = MutableDocument(nodes: [
           ImageNode(
@@ -1454,7 +1466,7 @@ This is some code
         const markdown = '''
 - [x] Task 1
 - [ ] Task 2
-- [ ] Task 3
+- [ ] Task 3  
 with multiple lines
 - [x] Task 4''';