livemd_flutter 0.6.0
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. Readcontroller.textfor the current source, or persist themarkdownreceived byonChanged. 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 andonWikiLinkTap: (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
onTaskToggleonly 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'spubspec.yaml. UseimageResolverfor custom image loading, and injectvideoBuilder/audioBuilderfor 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 |  uses an injectable videoBuilder: a 240 px standalone preview or a line-height inline projection, with distinct pointer action and source traversal |
| Audio |  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:
- Host-adaptive Openbox defaults.
- The nearest ThemeData extension for LiveMarkdownThemeData.
- 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.