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 ![Alt](url) 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.

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.9.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
  • 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.

Libraries

live_markdown_editor
A source-preserving Markdown editor for Flutter.