Flutter 中的 MarkdownWidget 是提供即時預覽的 Widget,該套件使用 markdown 解析產生出 HTML 標籤,然後再依據 HTML 標籤產生出對應的 Widget ,本文解釋了大部分的邏輯。由於我感興趣的部分是學習渲染部分並來實作 org-mode 的即時預覽,所以 Toc 跟滾動的細節本文略過。

MarkdownWidget

MarkdownWidget 最後的產出是一個 ListView ,而 MarkdownGenerator 負責解析 markdown 字串,建立成對應的 Widget , 儲存 SplayTreeSet 中,最後透過 ListView 組合。 SplayTreeSet 是一個可以相對排序的集合,基於平衡樹(Self-balancing binary search tree),允許大多數操作在對數時間完成。

整理相關類圖如下:


classDiagram
    class MarkdownWidget {
    }
    class _MarkdownWidgetState {
    }
    class SelectionArea {
    }
    class AutoScroll {
    }
    class VisibilityDetector {
    }
    class MarkdownGenerator {
    }
    class SplayTreeSet {
    }
    class ListView {
    }

    MarkdownWidget --> _MarkdownWidgetState
    _MarkdownWidgetState --> MarkdownGenerator
    MarkdownGenerator --> "產生" SplayTreeSet
    _MarkdownWidgetState --> "產生" SelectionArea

    SelectionArea *-- AutoScroll
    AutoScroll *-- VisibilityDetector
    VisibilityDetector *-- ListView

    ListView --> "使用" SplayTreeSet

核心邏輯在 BuildMarkdownWidget

  Widget buildMarkdownWidget() {
    final markdownWidget = NotificationListener<UserScrollNotification>(
      onNotification: (notification) {
        final ScrollDirection direction = notification.direction;
        isForward = direction == ScrollDirection.forward;
        return true;
      },
      child: ListView.builder(
        shrinkWrap: widget.shrinkWrap,
        physics: widget.physics,
        controller: controller,
        itemBuilder: (ctx, index) => wrapByAutoScroll(index,
            wrapByVisibilityDetector(index, _widgets[index]), controller),
        itemCount: _widgets.length,
        padding: widget.padding,
      ),
    );
    return widget.selectable
        ? SelectionArea(child: markdownWidget)
        : markdownWidget;
  }

MarkdownConfig

MarkdownConfig 用來拿 WidgetConfigWidgetConfig 用來設定 MarkdownTag 產生的 Widget 對應的 style config。

MarkdownGenerator


classDiagram
    %% Package: markdown_widget
    class MarkdownGenerator {
    }
    class MarkdownGeneratorConfig {
    }
    class WidgetVisitor {
    }
    class WidgetConfig {
    }
    class SpanNode {
    }
    class MarkdownTag {
    }

    %% Package: markdown
    class Document {
    }

    %% Relationships
    MarkdownGenerator --> MarkdownGeneratorConfig : ""
    MarkdownGenerator --> WidgetVisitor : ""
    WidgetVisitor --> Document : ""
    WidgetVisitor --> SpanNode : ""
    WidgetVisitor --> WidgetConfig : ""
    WidgetVisitor --> MarkdownTag : ""

首先用 Document.parseLine 解析出 Nodes,透過 WidgetVisitor 再根據 MarkdownTag 去呼叫對應的 NodeGenerator,產生對應的 SpanNode ,最後再組合起來。WidgetVisitor 是採用 Visitor Pattern 方式實作。

  ///convert [data] to widgets
  ///[onTocList] can provider [Toc] list
  List<Widget> buildWidgets(String data,
      {ValueCallback<List<Toc>>? onTocList}) {
    final m.Document document = m.Document(
      extensionSet: m.ExtensionSet.gitHubFlavored,
      encodeHtml: false,
      inlineSyntaxes: inlineSyntaxes,
      blockSyntaxes: blockSyntaxes,
    );
    final List<String> lines = data.split(RegExp(r'(\r?\n)|(\r?\t)|(\r)'));
    final List<m.Node> nodes = document.parseLines(lines);
    final List<Toc> tocList = [];
    final visitor = WidgetVisitor(
        config: config,
        generators: generators,
        textGenerator: textGenerator,
        onNodeAccepted: (node, index) {
          onNodeAccepted?.call(node, index);
          if (node is HeadingNode) {
            final listLength = tocList.length;
            tocList.add(
                Toc(node: node, widgetIndex: index, selfIndex: listLength));
          }
        });
    final spans = visitor.visit(nodes);
    onTocList?.call(tocList);
    final List<Widget> widgets = [];
    spans.forEach((span) {
      widgets.add(Padding(
        padding: linesMargin,
        child: Text.rich(span.build()),
      ));
    });
    return widgets;
  }

WidgetConfig

WidgetConfig 主要的子類是 BlockConfigInlineConfigLeafConfig。 這三者都是抽象類別。下面列出 code tag 的 config 實現。

///config class for code, tag: code
class CodeConfig implements InlineConfig {
  final TextStyle style;

  const CodeConfig(
      {this.style = const TextStyle(backgroundColor: Color(0xffeff1f3))});

  static CodeConfig get darkConfig =>
      CodeConfig(style: const TextStyle(backgroundColor: Color(0xff555555)));

  @nonVirtual
  @override
  String get tag => MarkdownTag.code.name;
}

SpanNodGeneratorWithTag

指定 Generator 要跟哪個 tag 綁一起。

ElementNode

ElementNodeSpanNode 的子類,允許接收其他 SpanNode 當成 children。在處理 ordered list 這種 block,或是處理文字裡面的連結時會用到 SpanNode

整理類圖如下:


classDiagram
    %% Package: markdown_widget/lib/widgets/span_node.dart
	class SpanNode { <<abstract<< }
	class ElementNode { <<abstract<< }
    SpanNode <|-- ElementNode

    %% Package: markdown_widget/lib/widgets/blocks/all.dart
    class TableNode {
    }
    class ListNode {
    }
    class BlackquoteNode {
    }
    class CodeBlockNode {
    }
    class HeadingNode {
    }
    class HrNode {
    }
    class LinkNode {
    }
    class ParagraphNode {
    }
    class CodeNode {
    }
    class ImgNode {
    }
    class InputNode {
    }

    ElementNode <|-- TableNode
    ElementNode <|-- ListNode
    ElementNode <|-- BlackquoteNode
    ElementNode <|-- CodeBlockNode
    ElementNode <|-- HeadingNode
    ElementNode <|-- HrNode
    ElementNode <|-- LinkNode
    ElementNode <|-- ParagraphNode
    ElementNode <|-- CodeNode
    ElementNode <|-- ImgNode
    ElementNode <|-- InputNode

舉例來說,下面這個包含連結的字串

aaaa link

就會變成一個 Node,裡面包函 aaaa、' '、 link 三個 Node。

ConcreteElementNode

這是一個特殊的 ElementNode,用來表示 Empty tag ,同時也用來表示 plain-text object。

///the default concrete node for ElementNode
class ConcreteElementNode extends ElementNode {
  final String tag;
  final TextStyle style;

  ConcreteElementNode({this.tag = '', TextStyle? style})
      : this.style = style ?? const TextStyle();

  @override
  InlineSpan build() => childrenSpan;
}

ImgNode

ImgNode 是另一個特殊的 Node,使用 WidgetSpan 組合文字跟圖片。可參閱 Flutter RichText 使用案例解析 Flutter WidgetSpan 设置图片显示一文,有比較完整的介紹。

MarkdownTag

定義在 lib/config/configs.dart

結論

要實現 org-mode 即時預覽, 我們需要另外改寫

  1. markdown.MarkdownWidget
  2. markdown.Node
  3. markdown_widget.MarkdownTag
  4. markdown_widget.WidgetVisitor

SpanNodeGenerator 則可以重複使用。至於 org-mode 語法 parser 的部分,可以透過實作自己的 BlockSyntaxInline subclasses,然後使用 DocumentNodeVisitor 的完整邏輯。