live_markdown_editor 0.11.0
live_markdown_editor: ^0.11.0 copied to clipboard
A source-preserving Flutter Markdown editor with raw, live, and read modes.
live_markdown_editor #
A source-preserving, Obsidian-inspired Markdown editor for Flutter with three modes — Raw (literal source), Live (formatted with hidden delimiters, revealed at caret/selection), and Read (delimiter-free, selectable).
The editor is package-owned: its custom editable engine uses only
Flutter foundation, services, widgets, and rendering layers (zero Material or
Cupertino dependencies in lib/). On desktop and mobile, selection menus are
the platform's own native system menus; on web, the browser owns
selection and context menus natively.
Supported Platforms #
| Platform | Selection Menu Mechanism | Native Menu | Manually Tested |
|---|---|---|---|
| Android | Floating ActionMode toolbar |
Yes | ✅ |
| Linux | GTK popover menu (GtkMenu) |
Yes | ✅ |
| Web | Browser-owned DOM selection & context menu | Browser | ✅ |
| iOS | UIEditMenuInteraction / UIMenuController |
Yes | ⏳ |
| macOS | Native NSMenu popup |
Yes | ⏳ |
| Windows | Win32 TrackPopupMenuEx |
Yes | ⏳ |
Note: On mobile and desktop, native menus never render under the keyboard or off-screen. On Web, the editor coordinates with the browser engine's text input connection for genuine browser-native context menus and copy/paste.
Supported Markdown Syntax #
| Syntax | Markdown Pattern | Live Mode Presentation |
|---|---|---|
| Paragraphs | Plain text with words |
Preserves source, wraps naturally |
| Headings (H1–H6) | # H1 through ###### H6 |
Styled heading scales; # revealed at caret |
| Bold | **bold** or __bold__ |
Bold weight; delimiters revealed at selection |
| Italic | *italic* or _italic_ |
Italic style; delimiters revealed at selection |
| Strikethrough | ~~strikethrough~~ |
Strikethrough line; delimiters revealed at caret |
| Highlight | ==highlight== |
Highlight tint background; delimiters revealed |
| Inline Code | `code` or code |
Monospace font with subtle background pill |
| Fenced Code Blocks | ```dart ... ``` |
Monospace block with background fill |
| Blockquotes | > Quoted line |
Left border accent decoration with inset text |
| Unordered Lists | - Item or * Item or + Item |
Bullet markers with smart list continuation |
| Ordered Lists | 1. Item or 2. Item |
Decimal numerals with auto-increment on Enter |
| Task Lists | - [ ] Pending, - [x] Done, or 1. [ ] Ordered |
Interactive checkbox widgets (toggle in-place; ordered numbers stay visible, marker reveals at the caret) |
| Horizontal Rules | --- or *** or ___ |
Rendered divider line decoration |
| Links | [Label](https://...) or [Label](url "Title") |
Clickable/tappable link with URL tap callback |
| Images |  or ![Alt][ref] |
Bounded widget with alt-text semantics; replaceable through MarkdownMediaBuilder |
| Tables | GFM pipe tables: ` | a |
CommonMark Compatibility #
The parser targets CommonMark 0.31.2 and runs the official conformance examples in CI. Raw HTML is deliberately excluded: HTML blocks, tags, declarations, processing instructions, and comments stay literal source in every mode. The compatibility claim is therefore "CommonMark 0.31.2 except raw HTML" — no unqualified conformance claim is made.
Supported CommonMark constructs include ATX and Setext headings, thematic breaks, fenced and indented code blocks, blockquotes (with lazy continuation), tight and loose ordered/unordered lists, link reference definitions, inline and reference links, inline and reference images, URI and email autolinks, code spans, emphasis and strong emphasis, entity and numeric character references, and backslash escapes.
Two parse profiles are available:
| Profile | Constructors | Contents |
|---|---|---|
| Editor defaults | MarkdownSyntaxConfiguration.editorDefaults() |
CommonMark core plus the built-in GFM table, task-list, strikethrough, and highlight extensions |
| Core-safe | MarkdownSyntaxConfiguration.commonMarkSafe() |
CommonMark 0.31.2 without raw HTML and without built-in extensions |
Both profiles run the same open-container block engine and hierarchical inline
engine; they differ only in the built-in descriptors a profile installs. Task
controls render only where the installed grammar recognizes [ ]/[x]
markers, so the core-safe profile keeps them as literal text.
Folding and line numbers #
An opt-in gutter adds source-stable line numbers and fold affordances in Raw and Live; Read always renders the complete document.
LiveMarkdownEditor(
controller: controller,
gutter: const MarkdownGutterConfiguration(
lineNumbers: true,
folding: true,
),
)
- Headings fold their whole section (down to the next heading of the same or higher level); list items, blockquotes, and fenced code blocks fold beneath their first line.
- Numbers are 1-based source line numbers, so Raw and Live agree, hidden content keeps its numbers, and typing above never renumbers.
- The gutter sits on the document's resolved-direction side and reserves its width from the content.
- Fold state belongs to
MarkdownEditorController(toggleFoldAt,foldedBlockStarts,clearFolds): it is view state shared by every surface, never an undo step, and it self-heals across edits. - Raw stays byte-identical to the source while nothing is folded; folding collapses those lines visually, and copying still returns exact source.
Malformed, unsupported, excluded, and resource-limited constructs always stay literal and round-trip without loss.
Syntax Extensions #
Community Dart packages can add Markdown syntax through an immutable configuration. Additive extensions claim source the core declines; explicit overrides replace one named rule:
LiveMarkdownEditor(
controller: controller,
syntax: const MarkdownSyntaxConfiguration.editorDefaults(
extensions: [KeyboardKeySyntax()],
overrides: [CustomLinkSyntaxOverride()],
),
)
A minimal inline extension needs only public imports:
import 'package:flutter/widgets.dart';
import 'package:live_markdown_editor/live_markdown_editor.dart';
final class KeycapSyntax extends MarkdownSyntaxExtension {
const KeycapSyntax();
@override
final String id = 'dev.example.keycap';
@override
final int apiVersion = MarkdownSyntaxExtension.apiVersion1;
@override
final List<String> dependencies = const [];
@override
final List<MarkdownSyntaxRule> rules = const [KeycapRule()];
@override
MarkdownExtensionPresentation? presentInline(
MarkdownExtensionPresentationContext context,
MarkdownInlineNode node,
) {
final label = node.contentRange.slice(context.source);
return MarkdownExtensionPresentation(
parts: [
MarkdownExtensionHiddenPart(sourceRange: node.openingRange!),
MarkdownExtensionWidgetPart(
sourceRange: node.contentRange,
id: 'keycap',
builder: (_, _) => Container(
padding: const EdgeInsets.all(2),
decoration: BoxDecoration(
color: const Color(0xFFEEEEEE),
borderRadius: BorderRadius.circular(4),
),
child: Text(label),
),
),
MarkdownExtensionHiddenPart(sourceRange: node.closingRange!),
],
semanticsLabel: 'key ${label}',
);
}
}
final class KeycapRule implements MarkdownInlineRule {
const KeycapRule();
@override
final MarkdownSyntaxRuleId id =
const MarkdownSyntaxRuleId('dev.example.inline.keycap');
@override
MarkdownExtensionInvalidationScope get invalidationScope =>
MarkdownExtensionInvalidationScope.localBlock;
@override
Set<int> get triggerCodeUnits => const {0x5E};
@override
MarkdownInlineMatch? matchInline(MarkdownInlineParseContext context) {
final source = context.source;
final cursor = context.cursor;
if (cursor + 2 >= context.enclosingRange.end) return null;
if (source.codeUnitAt(cursor) != 0x5E ||
source.codeUnitAt(cursor + 1) != 0x5E) {
return null;
}
final closing = source.indexOf('^^', cursor + 2);
if (closing < 0 || closing + 2 > context.enclosingRange.end) return null;
return MarkdownInlineMatch(
node: MarkdownInlineNode(
type: MarkdownInlineType.extension,
range: SourceRange(cursor, closing + 2),
contentRange: SourceRange(cursor + 2, closing),
openingRange: SourceRange(cursor, cursor + 2),
closingRange: SourceRange(closing, closing + 2),
extensionData: MarkdownExtensionNodeData(
extensionId: 'dev.example.keycap',
ruleId: 'dev.example.inline.keycap',
),
),
);
}
}
Extensions can also declare per-editor sessions, block decorations, semantics
labels, activation callbacks, and pure editing rules (such as delimiter
auto-pairing). All extension code runs inside guarded boundaries: invalid
ranges, overlaps, or thrown exceptions literalize the construct and report an
onExtensionError with the extension ID, phase, and source range. Extensions
never mutate canonical source and cannot construct their own source mappers.
A configuration containing any override reports a customized profile and
cannot claim CommonMark core compatibility. Treat extension code as
application code under your security policy.
Image Caching #
The default image widget loads http/https through Image.network,
relative and asset: destinations through Image.asset, and never touches
file: or arbitrary schemes; empty or unsupported destinations render the alt
text. Applications with a cached-image package replace it through
MarkdownMediaBuilder:
LiveMarkdownEditor(
controller: controller,
componentBuilders: MarkdownEditorComponentBuilders(
mediaBuilder: (context, media) =>
CachedNetworkImage(imageUrl: media.destination),
),
)
The builder receives the resolved destination, alt text, title, source range, mode, direction, constraints, and load state. The core adds no caching or filesystem dependency.
Installation #
Add live_markdown_editor to your pubspec.yaml:
dependencies:
live_markdown_editor: ^0.11.0
Or run:
flutter pub add live_markdown_editor
Quick Start #
import 'package:flutter/widgets.dart';
import 'package:live_markdown_editor/live_markdown_editor.dart';
class MyEditorPage extends StatefulWidget {
const MyEditorPage({super.key});
@override
State<MyEditorPage> createState() => _MyEditorPageState();
}
class _MyEditorPageState extends State<MyEditorPage> {
late final MarkdownEditorController _controller = MarkdownEditorController(
text: '# My Notes\n\n- [ ] Ship to production\n- [x] Write tests',
);
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return LiveMarkdownEditor(
controller: _controller,
onChanged: (markdown) => print('Saved: $markdown'),
onLinkTap: (url) => print('Open: $url'),
);
}
}
Modes #
The canonical source of truth is always controller.text (UTF-16
TextEditingValue). Modes differ only in visual projection and delimiter
visibility:
| Mode | Property | Behavior |
|---|---|---|
| Raw | MarkdownEditorMode.raw |
Displays literal Markdown source code without hiding delimiters or applying table layout. |
| Live | MarkdownEditorMode.live |
Formats text visually, renders task boxes as interactive widgets, and reveals delimiters only when the caret or selection touches them. |
| Read | MarkdownEditorMode.read |
Delimiter-free reading view. Preserves text selection, link taps, and in-place task checkbox toggling. |
Switch modes smoothly at runtime without losing selection, undo history, or scroll position:
_controller.mode = MarkdownEditorMode.read;
Local GitLab Pages deployment #
The demo is built locally and published from the generated pages branch; the
only GitLab job uploads the resulting static public/ artifact. Install the
tracked guard once per clone, then publish through the wrapper:
tool/install-pages-hook.sh
tool/push-with-pages.sh
The first publish creates and pushes the static-only pages branch. Later
publishes rebuild the demo and API docs locally, update that branch only when
the output changes, then atomically push main and pages. Direct main
pushes are intentionally rejected by the hook. GitLab Pages unique domains
(GitLab's default) serve this project from /, which is the wrapper default.
For a path-based Pages URL, override the base path:
PAGES_BASE_HREF=/live_markdown_editor/ tool/push-with-pages.sh
Customization & Advanced Usage #
1. Theme Configuration (MarkdownEditorThemeData) #
Customize all visual properties (colors, typography, heading scales, task sizing, blockquote margins) using MarkdownEditorThemeData:
LiveMarkdownEditor(
controller: _controller,
theme: MarkdownEditorThemeData.light().copyWith(
accentColor: const Color(0xFFC03A1E),
backgroundColor: const Color(0xFFFBF6E9),
textColor: const Color(0xFF221C10),
bodyStyle: const TextStyle(
fontSize: 17,
height: 1.7,
fontFamily: 'IBMPlexSans',
),
taskCheckedColor: const Color(0xFFC03A1E),
quoteBorderColor: const Color(0xFFC9BBA0),
quoteBorderWidth: 3.0,
contentPadding: const EdgeInsets.symmetric(horizontal: 32, vertical: 24),
),
)
Tables stay borderless by default. Set tableStyle only when the host wants
table decoration. This collapsed example uses one shared grid:
final tableStyle = MarkdownTableStyle(
borderModel: MarkdownTableBorderModel.collapsed,
outerBorder: MarkdownTableBorder.all(
const MarkdownTableBorderSide(color: Color(0xFF64748B), width: 1.5),
),
cellBorder: MarkdownTableBorder.all(
const MarkdownTableBorderSide(color: Color(0xFFCBD5E1)),
),
cellPadding: const EdgeInsetsDirectional.symmetric(
horizontal: 10,
vertical: 6,
),
headerBackgroundColor: const Color(0xFFF1F5F9),
oddRowBackgroundColor: const Color(0xFFF8FAFC),
headerTextStyle: const TextStyle(fontWeight: FontWeight.w600),
);
LiveMarkdownEditor(
controller: _controller,
theme: MarkdownEditorThemeData.light().copyWith(tableStyle: tableStyle),
)
Separated tables give every cell its own box and expose the table background through independent gaps. A resolver can override any cell token:
MarkdownTableStyle(
borderModel: MarkdownTableBorderModel.separated,
columnSpacing: 8,
rowSpacing: 6,
backgroundColor: const Color(0xFFF1F5F9),
cellBorder: MarkdownTableBorder.all(
const MarkdownTableBorderSide(
color: Color(0xFF94A3B8),
pattern: MarkdownTableBorderPattern.dashed,
),
),
cellRadius: const BorderRadius.all(Radius.circular(6)),
cellStyleResolver: (cell) => cell.column == 1
? const MarkdownTableCellStyle(
backgroundColor: Color(0xFFDBEAFE),
padding: EdgeInsetsDirectional.fromSTEB(14, 6, 10, 6),
)
: null,
)
Resolver row and column indexes always follow Markdown source order. The table
direction mirrors physical column placement once; each cell still resolves its
own text direction. Logical start/end borders and padding resolve from the
table direction, so the same style works for LTR and RTL tables.
Wide tables keep a platform-neutral horizontal viewport. A direct touch drag pans the table with the finger on any touchscreen, while mouse and stylus drags continue selecting text. Native horizontal pointer-scroll works on web and desktop, including Linux touchpad pan/zoom events. Hovering a wide table shows a proportional scrollbar; touch selection still starts with long-press or double-tap and continues through the selection handles.
2. Replaceable Component Builders (MarkdownEditorComponentBuilders) #
Customize task checkboxes or selection handle presentation while maintaining package-owned gestures and geometry:
LiveMarkdownEditor(
controller: _controller,
componentBuilders: MarkdownEditorComponentBuilders(
// Inject a custom task toggle widget (e.g. Material Checkbox or CupertinoSwitch):
taskToggleBuilder: (context, taskContext) => Checkbox(
value: taskContext.checked,
onChanged: taskContext.enabled ? (_) => taskContext.onToggle() : null,
),
),
)
3. Editing Behavior (MarkdownEditingBehavior) #
Control typing auto-pairing, wrapping, and list continuations:
LiveMarkdownEditor(
controller: _controller,
behavior: const MarkdownEditingBehavior(
autoPair: true, // Auto-pairs delimiters like **, ``, _, ~~
wrapSelection: true, // Typing a delimiter around selected text wraps it
smartLists: true, // Enter on a list/task item auto-continues the list
autoCloseFences: true, // Triple backticks auto-closes the code fence block
),
)
4. Bidi & RTL Direction (directionMode) #
Supports per-line first-strong bidirectional text shaping:
LiveMarkdownEditor(
controller: _controller,
// MarkdownDirectionMode.aware resolves direction independently per hard line:
directionMode: MarkdownDirectionMode.aware,
fallbackDirection: TextDirection.ltr,
)
MarkdownDirectionMode.aware(default): Analyzes first-strong letters of each line independently (ideal for mixed Arabic/English documents).MarkdownDirectionMode.auto: Computes document-level direction.MarkdownDirectionMode.ltr/MarkdownDirectionMode.rtl: Forces a fixed text direction.
5. Programmatic Commands & Undo/Redo #
Execute atomic, undoable formatting transactions directly on the controller:
// Formatting commands
_controller.execute(MarkdownCommand.bold);
_controller.execute(MarkdownCommand.italic);
_controller.execute(MarkdownCommand.strikethrough);
_controller.execute(MarkdownCommand.code);
_controller.execute(MarkdownCommand.link, argument: 'https://flutter.cn');
_controller.execute(MarkdownCommand.taskList);
_controller.execute(MarkdownCommand.blockquote);
// History control
if (_controller.canUndo) _controller.undo();
if (_controller.canRedo) _controller.redo();
Example App #
Check out example/ for the Editor Studio, an editor-first
responsive demonstration featuring:
- A full-height mobile writing surface with one compact mode toolbar
- A four-plate casebook — Field notes, The Arabic manuscript, Bilingual proof, and a blank folio — where every content plate covers the complete surface: inline typography, headings, nested quotes, tight and loose lists, task controls, code, formatted tables, extension syntax, media, entities, and the fold gutter
- The gutter opens enabled (line numbers and folding) with studio toggles, so the demo doubles as a live regression surface for folding
- On-demand documents, editor settings, theme, and platform preview tools
- Cool-neutral light and dark themes with cohesive Latin/Arabic typography
- Foundation by default, with Material and Cupertino previews that share the same canonical editor source
- Adaptive phone, tablet, and desktop layouts backed by visual regression tests
To run the demo:
cd example
flutter run
License #
MIT License — see LICENSE for details.