Skip to main content

livemd_flutter 0.6.0 copy "livemd_flutter: ^0.6.0" to clipboard
livemd_flutter: ^0.6.0 copied to clipboard

A lossless continuous Markdown Live Preview editor for Flutter.

LiveMD Flutter #

A lossless, continuous Markdown Live Preview editor for Flutter. It follows the editing model used by Obsidian rather than a conventional WYSIWYG editor: Markdown remains the only source of truth, rendered syntax stays in the same native editing surface, and the caret reveals source only when it enters that source range.

Quick start (5 minutes) #

Requires Dart ^3.13.1 and Flutter >=3.35.0. Your Flutter installation must satisfy both constraints; check with flutter --version.

1. Install #

From your Flutter app's directory, run:

flutter pub add livemd_flutter:^0.6.0

2. Replace lib/main.dart #

This complete example runs without assets, storage services, or player plugins:

import 'package:flutter/material.dart';
import 'package:livemd_flutter/livemd_flutter.dart';

void main() => runApp(const MaterialApp(home: MarkdownPage()));

class MarkdownPage extends StatefulWidget {
  const MarkdownPage({super.key});

  @override
  State<MarkdownPage> createState() => _MarkdownPageState();
}

class _MarkdownPageState extends State<MarkdownPage> {
  late final LiveMarkdownController _controller;

  @override
  void initState() {
    super.initState();
    _controller = LiveMarkdownController(
      text: '# Hello\n\nEdit **Markdown** here.\n\n- [ ] Try a task',
    );
  }

  @override
  void dispose() {
    _controller.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Markdown editor')),
      body: LiveMarkdownEditor(
        controller: _controller,
        onChanged: (markdown) {
          // Demo only: replace this with your app's persistence logic.
          debugPrint(markdown);
        },
      ),
    );
  }
}

3. Run #

flutter run

You should see an editable heading, bold text, and a task checkbox. Move the caret into formatted text to reveal its Markdown source. Changes are printed to the debug console, not saved; restarting the app restores the sample.

Already have an app? Use MarkdownPage as a route, or put LiveMarkdownEditor in your own page. Create the controller once in initState, not in build, and dispose it when the page is removed. Inside a Column, wrap the editor in Expanded to give it a bounded editing area.

Connect it to your app #

  • Load and save: pass your stored Markdown as the controller's initial text. Read controller.text for the current source, or persist the markdown received by onChanged. The package does not provide file, database, or cloud storage. Debounce frequent writes in your app if needed.
  • Open links: implement onLinkTap: (destination, label) { ... } for external links and onWikiLinkTap: (target, alias) { ... } for note navigation. The editor reports taps; your app validates destinations and decides where to navigate.
  • Task checkboxes: toggling a task updates the Markdown automatically. Use onTaskToggle only if you also need a task-specific notification.
  • Images and playback: network and asset: images have a default resolver; declare asset files in your app's pubspec.yaml. Use imageResolver for custom image loading, and inject videoBuilder / audioBuilder for actual playback. No audio/video player is bundled.

For parameters and types, see the API reference. The quick-start source matches the example above. The full example app demonstrates links, tables, images, and injected audio/video players. To run either from a checkout of this repository:

cd example
flutter pub get
flutter run -t lib/quick_start.dart # Minimal editor
# Or: flutter run                 # Full feature demo

Why the continuous engine matters #

The editor owns one global TextEditingValue, selection, composing range, and undo history for the entire document. Its main surface is one continuous native editor projected from exact UTF-16 source ranges. A pointer-edited table cell temporarily receives native text focus, but every local value is mapped back into that same global source and history.

That makes these native editing behaviors possible:

  • selection, Shift+Arrow, copy, and IME composition across block boundaries;
  • programmatic global selections that stay synchronized with the visible caret;
  • one undo/redo history for typing, structural commands, and task toggles;
  • stable line boxes and baselines while inline source markers appear;
  • pointer actions that are distinct from arrow-key source traversal.

For example, clicking a rendered link invokes onLinkTap, while moving the caret into that same link with an arrow key reveals [label](destination). Clicking a task checkbox toggles [ ]/[x]; moving left from the task text into the prefix reveals the complete task source.

Supported behavior #

Area Current support
Document editing One global source, selection, composing range, scroll surface, and undo/redo history
Headings ATX levels 1–6, closing hashes, stable content typography and baseline
Inline Nested bold/italic, strikethrough, highlight, multi-backtick code spans
Links Markdown destinations/titles, balanced destination parentheses, Wiki targets and aliases, pointer callbacks
Images Standalone previews (network or asset: destinations) and line-height inline thumbnails via an injectable resolver; pointer clicks report onImageTap, while source reveal keeps a standalone preview below the source line
Video ![alt](destination.mp4) uses an injectable videoBuilder: a 240 px standalone preview or a line-height inline projection, with distinct pointer action and source traversal
Audio ![alt](destination.mp3) uses an injectable audioBuilder: a compact 48 px standalone player or a line-height inline projection, with distinct pointer action and source traversal
Literal states Escapes, word-internal underscores, and incomplete syntax stay lossless and literal
Lists Unordered, ordered, tasks, nesting, Enter continuation/exit, Shift+Enter continuation, Tab/Shift+Tab, marker Backspace
Ordered lists Following same-level items are renumbered after structural Enter
Quotes Rendered quote marker plus Enter/exit/nesting commands
Fenced code The full-width code container stays mounted while fences/source appear; raw and rendered text share one width, scaler, and height budget
Tables One real GFM table layout drives rich inline/media rendering, hit testing, wrapped row heights, and native pointer cell editing; arrow traversal opens a native whole-table source layer in the same footprint; Tab/Shift+Tab/Enter navigate cells, gutter controls add rows/columns, and the context menu mutates both axes
Other complex blocks Pointer-edit Callout titles and bodies inside the rendered shell; keyboard source traversal and thematic breaks preserve the block height budget
Layout No preview/editor remount; rendered Markdown owns its run-metric line heights (heading lines, rows, inline placeholders) with regression tests for Y stability

Without a real wrap, inline media reveal at the same footprint as their source, so following text keeps its X/Y position. Standalone media are the deliberate exception: revealing source adds exactly one normal text line above the retained preview, while the preview itself neither resizes nor collapses. Media source uses a natural-height caret rather than stretching it to the preview. If an embed source cannot fit on one line at the available width, the editor keeps it as literal source instead of constructing a projection that would overflow or reorder its wrapped lines.

The positioned syntax tree is intentionally conservative while text is being edited: unsupported or incomplete constructs remain literal source instead of being guessed and reformatted.

Visual customization #

For optional clipboard image import, see Pasting media.

The APIs below require livemd_flutter 0.5.0 or later. All Markdown styling uses LiveMarkdownThemeData. Register application-wide defaults in ThemeData.extensions, then override only what differs on an editor:

ThemeData(
  extensions: const [
    LiveMarkdownThemeData(
      bodyStyle: TextStyle(fontSize: 15, height: 1.3),
      h1Style: TextStyle(fontSize: 26),
      linkStyle: TextStyle(decoration: TextDecoration.none),
      appearance: LiveMarkdownAppearance(
        highlightColor: Color(0xffd9c9ff),
        calloutBorderRadius: BorderRadius.zero,
      ),
    ),
  ],
)
LiveMarkdownEditor(
  controller: controller,
  theme: const LiveMarkdownThemeData(
    bodyStyle: TextStyle(fontSize: 17),
    appearance: LiveMarkdownAppearance(
      calloutBorderSide: BorderSide.none,
    ),
  ),
)

The local font size preserves the global line height, colors, and other fields. There is only one precedence order:

  1. Host-adaptive Openbox defaults.
  2. The nearest ThemeData extension for LiveMarkdownThemeData.
  3. The editor's theme, merged field by field.

TextStyles merge their properties (inherit: false replaces a style). Appearance merges individual fields. Decorations, borders and layout policies replace the corresponding value. merge overlays partial themes; copyWith replaces non-null fields. To remove local overrides, supply a new partial theme or set theme to null. Remove the global extension too to restore defaults.

Supported theme fields #

Fields What they control
bodyStyle Body font, size, color and line height; also the base for table and Callout text
h1Style–h6Style Heading typography
linkStyle Markdown/Wiki link styles; decoration defaults to underline unless explicitly specified
codeStyle, codeBlockDecoration Code typography and painted fenced-code background
tableBodyStyle, tableHeadStyle, tableBorder Table text and border
tableColumnWidth, tableVerticalAlignment Column width policy and vertical cell alignment; horizontal text alignment comes from Markdown
tableCellDecoration, tableHeaderCellDecoration Body/header cell decoration
tableCellHorizontalPadding, tableHeaderCellHorizontalPadding Symmetric left/right cell padding, in nonnegative logical pixels; vertical spacing remains layout-owned
appearance Source, highlight, quote, task, Callout and active-cell paint; Callout left border and corner radius

The appearance defaults are host outline for document syntax, onSurfaceVariant for table syntax, yellow at alpha 0.38 for highlights, primary at alpha 0.5 for quotes, and primary for checked tasks. Callout accent inherits the effective task color; its background uses the accent at alpha 0.10. Active cells use surfaceContainerHighest at alpha 0.78. Explicit fills retain their alpha.

Callout border defaults to a left edge of width 3 in the accent color, and its radius defaults to 6. Use BorderSide.none or BorderRadius.zero for a borderless or square shell. These settings paint without adding padding or clipping content; choose border widths that fit the existing inset.

Defaults and application-owned layout #

LiveMarkdownThemeData.openbox() is the public default preset. It keeps the bodyMedium-based text with height 1.5, heading sizes 32/28/24/21/18/16, and host-derived colors. It works in light/dark hosts without a fixed page palette. Local DefaultTextStyle is not automatically adopted; pass its style as theme.bodyStyle when the page's local typography should apply.

padding and editorDecoration configure the editing surface, not Markdown semantics. The host owns page background, width, positioning and toolbars. videoBuilder and audioBuilder own player appearance within existing slots. Cursor and selection colors follow the host textSelectionTheme.

Keep the same controller/editor key when changing style. Paint-only changes preserve document state and geometry. Font, line-height and horizontal-padding changes may reflow text. Flutter theme transitions interpolate explicit values; use the same set of fields in light/dark themes for predictable transitions. Column-width policies and vertical-alignment enums switch discretely.

Callout padding/icon, media slot dimensions, table vertical spacing, arbitrary block replacement and menu customization are not styling controls. See the embedded-host example for Openbox, compact desktop and dark borderless hosts using one controller.

Pasting media #

These APIs are available from 0.6.0. The optional clipboard adapter provides native clipboard reads and browser/iOS paste events. The core package has no native clipboard dependency.

LiveMarkdownEditor(
  controller: controller,
  pasteConfiguration: LiveMarkdownPasteConfiguration(
    clipboard: const SuperClipboardAdapter(),
    onImportMedia: (request) async {
      // Your application saves the stream and attachment MIME metadata.
      // request.itemId is unique; operationId identifies the entire batch.
      final saved = await attachments.importMedia(request);
      if (saved == null) return null;
      return LiveMarkdownImportedMedia(
        destination: saved.uri,
        alt: saved.name,
      );
    },
    onResult: handlePasteResult,
  ),
  mediaTypeResolver: (destination) => attachments.typeOf(destination),
  imageResolver: resolveImage,
  videoBuilder: buildVideoPlayer,
  audioBuilder: buildAudioPlayer,
)

SuperClipboardAdapter comes from package:livemd_flutter_clipboard/livemd_flutter_clipboard.dart. The storage service, result handler, image resolver and players above are application-owned; see the companion example for persistent file/IndexedDB storage, restored attachment metadata and real audio/video playback. Storage and player dependencies live only in that example, so an app can choose its own player. Persist attachment metadata if references have no extension: mediaTypeResolver returns image/video/audio or null to use the normal extension fallback, and is used consistently in documents, tables, Callouts and tap events. It does not automatically probe URLs or retain a temporary type cache.

The importer is called once per media item. Requests contain operationId, a unique itemId, itemIndex, itemCount, MIME/name/optional size, a single-use media.openRead() stream, and cancellation. Consume the stream before returning; return null to reject, or throw to fail. Default admission accepts PNG/JPEG/GIF/ WebP/BMP, audio and video, up to 32 items with at most 2 concurrent imports. Adjust allowedMimeTypes, maxItems and maxConcurrentImports for the application. The adapter preserves original animations and media rather than choosing their image thumbnail. An unavailable format is reported as unsupported.

A batch inserts atomically, in original clipboard order, as one undo step. A failure or rejection leaves the original text intact. result.items describes per-item outcomes and result.importedMedia includes any successful uploads that were not inserted. Let the application's storage service release those imports; it must account for reused/shared attachments. Undo itself never deletes files or repeats an upload. result.imported remains a single-item convenience.

Keyboard/selection-menu paste works in the document, table cells/source and Callout title/body. Multiple items remain inline in tables, titles and existing text; an otherwise empty document line receives separate embeds, and quoted lines retain their prefixes. Code regions use only a supplied text fallback. Plain strings, paths and URLs stay text; browser File objects are handled as files, and text/HTML/RTF representations do not cause duplicate media insertion. HTML-to-Markdown conversion and drag-and-drop are separate features.

Original targets are tracked across precise edits. Conflicts, undo/redo, replacement of the document and disposal cancel pending insertion, including late results. Arbitrary external setText/setValue changes cancel when an exact edit range is unavailable. Moving the caret or rebuilding a parent to show progress does not cancel or steal focus. Active composition prevents a new media import. Media bytes and their storage remain application-owned.

Android keyboard insertion uses ContentInsertionConfiguration in every input surface. It works even without the optional adapter: omit clipboard to retain the SDK text clipboard plus IME media. contentInsertionMimeTypes defaults to PNG/JPEG/GIF/WebP; an empty list disables that entry. Flutter materializes IME bytes before delivery, unlike the clipboard adapter's lazy file streams; missing bytes report readFailed and are never treated as filesystem paths. iOS uses its native paste-selector event route, not this Android-only callback.

Validated with Flutter 3.47.1: macOS native clipboard; Windows and Linux/X11 CI; iOS 26.5 simulator native paste; Android API 36 emulator clipboard and actual commitContent; and Chrome browser paste events. Safari/Firefox, Linux Wayland and physical-mobile permission dialogs have not been separately exercised. The adapter's Android example uses Gradle 9.3.1 / AGP 9.1.0 / Kotlin 2.4.0. Gradle 9 hosts apply the package's compatibility script with one line in the root Android build file; Gradle 8 remains supported. Apple native dependencies use CocoaPods. See the companion README for setup and test commands.

Advanced source access #

LiveMarkdownController.value exposes the complete TextEditingValue; the compatibility properties text and selection remain available. The public MarkdownSyntaxParser also exposes lossless UTF-16 SourceRange nodes for applications that need source-aware commands or decorations.

Table structure operations are also available through applyMarkdownEditingCommand: insert/delete the current row or column with the six tableInsert... / tableDelete... enum values. The built-in table hover controls and context menu use the same command path, so each mutation is one document undo transaction.

Using as a dependency #

The library is a pure Dart/Flutter package with no native code, so it runs on every Flutter platform. Add it to your pubspec.yaml:

dependencies:
  livemd_flutter: ^0.6.0

The package requires Dart ^3.13.1 and Flutter >=3.35.0. Alternatively, pin the Git release tag:

dependencies:
  livemd_flutter:
    git:
      url: https://github.com/sd0116baka/livemd-flutter.git
      ref: v0.6.0

Media playback is intentionally app-owned: the editor renders standalone media slots and compact inline projections, and reports pointer actions, but the actual player widget is injected through imageResolver, videoBuilder, and audioBuilder. The example app wires video_player and just_audio; your app can supply any player. Revealing a standalone media source intentionally adds one source line above the still-visible preview, matching the observed Obsidian media-source treatment instead of collapsing the preview.

Current boundary #

Callout titles and bodies support pointer editing inside the rendered shell, while keyboard traversal can reveal the raw block source. A table pointer click uses a rendered cell editor, while arrow-key traversal opens a native whole-table source editor over an invisible copy of the same table geometry, so the following content stays fixed even for wrapped rich cells. Both the active cell and whole-table editor map selection, composing ranges, and undo transactions back to global UTF-16 offsets. A standalone media preview remains visible while its source is active, but its embedded controls are temporarily non-interactive; leave the source range to operate the player again. Obsidian ![[...]] vault embeds/transclusions, math, footnotes, reference links, and full CommonMark delimiter-flanking coverage are also future work.

The repository includes the observed behavior oracle in doc/obsidian_live_preview_behavior.md and a broad sample document in test/fixtures/obsidian_live_preview_matrix.md.

0
likes
160
points
234
downloads

Documentation

API reference

Publisher

unverified uploader

Weekly Downloads

A lossless continuous Markdown Live Preview editor for Flutter.

Repository (GitHub)

License

MIT (license)

Dependencies

flutter, flutter_markdown_plus, meta

More

Packages that depend on livemd_flutter