livemd_flutter 0.3.0
livemd_flutter: ^0.3.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 TextEditingValue, FocusNode, input connection,
selection, composing range, and undo history for the entire document. Rendered
content is projected from exact UTF-16 source ranges instead of switching
between per-block preview widgets and temporary text fields.
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, focus, scroll surface, undo/redo |
| 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 | Rendered inline pictures (network or asset: bundle destinations via an injectable resolver) inside a reserved row height with a broken-image fallback; pointer clicks report onImageTap while arrow traversal reveals source |
| Video |  renders a video slot (an injectable videoBuilder supplies the player) with the same reserved row, onVideoTap pointer action, and source reveal |
| Audio |  renders an audio slot (an injectable audioBuilder supplies the player) with the same reserved row, onAudioTap pointer action, and source reveal |
| 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 |
| Complex blocks | Rendered fenced code, Callout shell, GFM table, and thematic break until the caret enters their source |
| Layout | No preview/editor remount; rendered Markdown owns its run-metric line heights (heading lines, rows, inline placeholders) with regression tests for Y stability |
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 #
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.
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:
git:
url: https://github.com/sd0116baka/livemd-flutter.git
ref: main
Once published to pub.dev, the same dependency becomes:
dependencies:
livemd_flutter: ^0.3.0
Media playback is intentionally app-owned: the editor renders image, video, and
audio embeds into reserved rows 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.
Current boundary #
Complex blocks keep a rendered shell and preserve source offsets, but table cell editing and editing Callout text inside its rendered shell are not yet a 1:1 Obsidian implementation. Entering those source ranges currently reveals the complete block source. Embeds, 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.