livemd_flutter 0.4.0
livemd_flutter: ^0.4.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.
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.
Usage #
import 'package:livemd_flutter/livemd_flutter.dart';
late final LiveMarkdownController controller;
@override
void initState() {
super.initState();
controller = LiveMarkdownController(
text: '# Hello\n\nMove the caret into **Markdown**.',
);
}
@override
Widget build(BuildContext context) {
return LiveMarkdownEditor(
controller: controller,
onChanged: (markdown) => save(markdown),
onLinkTap: (destination, label) {
openLink(destination);
},
onWikiLinkTap: (target, alias) {
openNote(target);
},
onTaskToggle: (sourceRange, checked) {
logTaskChange(sourceRange, checked);
},
);
}
@override
void dispose() {
controller.dispose();
super.dispose();
}
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.4.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.4.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.