mixin_markdown_widget 0.4.0
mixin_markdown_widget: ^0.4.0 copied to clipboard
A desktop-first Flutter Markdown reader widget with theming and streaming-friendly controller APIs.
mixin_markdown_widget #
mixin_markdown_widget is a high-performance, desktop-first Flutter Markdown reader package. It focuses on a high-quality reading surface, highly customizable block-based rendering, model-driven text selection, and controller APIs optimized for streaming updates (like LLM responses).
Key Features #
- Streaming Ready:
MarkdownControllerwithappendChunkandcommitStreamsupports append-only incremental parsing for smooth LLM output rendering. - Custom Selection Engine: Built-in model-driven selection (
MarkdownSelectionController) supporting double-click word selection, triple-click block selection, table cell range selection, and predictable plain-text clipboard export. - Rich Syntax Support: Supports CommonMark, GFM task lists, tables, footnotes, definition lists, math (TeX), and common inline HTML tags.
- Highly Customizable Rendering:
- Override specific block rendering using
codeBlockBuilderandbulletBuilder. - Customize image rendering with
imageBuilderand link handling withonTapLink. - Disable default scroll view (ListView) using
useColumn: truefor easy integration into existing custom scrollable layouts.
- Override specific block rendering using
- Theming:
MarkdownThemeDataallows deep customization of text styles, spacing, colors, and block decorations (e.g., hiding heading dividers viashowHeading1Divider).
Usage #
Basic Rendering #
import 'package:flutter/material.dart';
import 'package:mixin_markdown_widget/mixin_markdown_widget.dart';
class ExamplePage extends StatelessWidget {
const ExamplePage({super.key});
@override
Widget build(BuildContext context) {
return MarkdownWidget(
data: '# Hello\n\nThis is **Markdown**.',
// Customize theme
theme: MarkdownThemeData.fallback(context).copyWith(
showHeading1Divider: false, // Hide H1 underlines
),
// Set to true if you are wrapping this in your own ScrollView
useColumn: false,
);
}
}
Customizing Blocks #
You can easily override the default rendering for specific blocks:
MarkdownWidget(
data: data,
// Custom code block rendering
codeBlockBuilder: (context, code, language, theme) {
return CustomCodeBlockView(code: code, language: language);
},
// Custom list marker rendering
bulletBuilder: (context, index, isOrdered, orderedStart, taskState, theme) {
if (taskState != null) return CustomCheckbox(state: taskState);
if (isOrdered) return Text('${orderedStart! + index}.');
return const Icon(Icons.circle, size: 8);
},
)
Right-Click Menu #
Right clicking keeps a selection under the pointer. Elsewhere, macOS and iOS select the word under the pointer and other platforms clear the selection. The default menu shows Copy when text is selected, Copy link over a link, Select all, and Copy all. It closes on Escape, scrolling, or any click.
contextMenuBuilder replaces the menu. details.buttonItems holds the default
items, which close the menu when pressed; call details.hide from your own.
Pass null to show no menu.
MarkdownWidget(
data: data,
contextMenuBuilder: (context, details) {
return AdaptiveTextSelectionToolbar.buttonItems(
anchors: details.anchors,
buttonItems: [
...details.buttonItems,
if (details.linkDestination case final url?)
ContextMenuButtonItem(
label: 'Open Link',
onPressed: () {
details.hide();
openUrl(url);
},
),
],
);
},
)
MixinSelectionArea takes the same contextMenuBuilder.
Generic Selection Area #
MixinSelectionArea can wrap non-Markdown widget trees. Text-like children opt in
with MixinSelectableText, or with MixinSelectable when a custom text widget
needs to provide its own hit-test and selection geometry.
MixinSelectionArea(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: const [
MixinSelectableText('Selectable custom text'),
SizedBox(height: 8),
MixinSelectableText('Drag selection can cross widgets'),
],
),
)
Streaming / Incremental Updates #
For streaming or incremental updates, keep a controller and append chunks as they arrive. appendChunk reparses only the unstable trailing block instead of the full document, ensuring high performance.
final controller = MarkdownController(data: '# Streamed output');
// As data arrives from network/LLM:
controller.appendChunk('\n\nNew content arrived.');
// When stream finishes:
controller.commitStream();
If you receive the full text so far instead of deltas, pass it to updateData. It appends only the new suffix when the text extends the current data, falls back to a full parse otherwise, and commits the stream unless streaming is true:
controller.updateData(fullTextSoFar, streaming: true);
// When stream finishes:
controller.updateData(finalText);
See the /example directory for a fully runnable desktop demo.