livemd_flutter 0.4.0
livemd_flutter: ^0.4.0 copied to clipboard
A lossless continuous Markdown Live Preview editor for Flutter.
example/lib/main.dart
import 'package:flutter/material.dart';
import 'package:just_audio/just_audio.dart';
import 'package:livemd_flutter/livemd_flutter.dart';
import 'package:video_player/video_player.dart';
void main() => runApp(const LiveMdExampleApp());
class LiveMdExampleApp extends StatelessWidget {
const LiveMdExampleApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
debugShowCheckedModeBanner: false,
title: 'LiveMD Flutter',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: const Color(0xff7057d9)),
scaffoldBackgroundColor: const Color(0xfff7f6fb),
useMaterial3: true,
),
darkTheme: ThemeData(
colorScheme: ColorScheme.fromSeed(
seedColor: const Color(0xffa994ff),
brightness: Brightness.dark,
),
useMaterial3: true,
),
home: const EditorDemoPage(),
);
}
}
class EditorDemoPage extends StatefulWidget {
const EditorDemoPage({super.key});
@override
State<EditorDemoPage> createState() => _EditorDemoPageState();
}
class _EditorDemoPageState extends State<EditorDemoPage> {
late final LiveMarkdownController _controller;
String _lastAction = '方向键进入标记会显示源码;表格单元格点击后可直接编辑,悬停可增行增列。';
static const _sample =
'# LiveMD Flutter\n\n'
'Markdown 源码始终是唯一数据源,整篇文档共享一个全局编辑状态。\n\n'
'## Inline reveal\n\n'
'PLAIN before **BOLD token** middle *ITALIC token* middle '
'~~STRIKE token~~ middle ==HIGHLIGHT token== after.\n\n'
'NESTED **bold with *nested italic* tail** end.\n\n'
'LINK [Markdown label](https://example.com/path?q=1) WIKI '
'[[Internal Target|Alias Label]] INLINE `const x = 1`.\n\n'
'## Pointer and caret are different\n\n'
'- [ ] 点击这个复选框只切换状态\n'
'- [x] 光标在正文行首再按 Left,才显示完整任务源码\n'
'- 普通无序项使用稳定的圆点标记\n'
'1. Enter 会自动续成下一编号\n'
'2. 空列表项再次 Enter 会退出列表\n\n'
'> 普通引用保留视觉边线,结构化 Enter 会继续 `> `.\n\n'
'> [!tip] Callout shell\n'
'> 复杂块在光标进入前保持渲染。\n\n'
'```dart\n'
'final editor = LiveMarkdownEditor(controller: controller);\n'
'```\n\n'
'| Type | Pointer | Arrow key |\n'
'| --- | --- | --- |\n'
'| Link | action | source |\n'
'| Task | toggle | source |\n\n'
'---\n\n'
'## Inline media\n\n'
'行内图片  和行内音频 '
' 保持正文行高。\n\n'
'## Image preview\n\n'
'图片直接渲染本地资源;光标方向键进入图片范围才揭示 `` 源码。\n\n'
'\n\n'
'## Video preview\n\n'
'目标为视频文件时渲染视频槽位(可注入 `videoBuilder` 播放器);\n'
'方向键进入范围仍揭示 `` 源码。\n\n'
'\n\n'
'## Audio preview\n\n'
'目标为音频文件时渲染音频槽位(可注入 `audioBuilder` 播放器)。\n\n'
'\n\n'
'## Literal editing states\n\n'
'unclosed **bold marker\n'
r'escaped \*literal stars\*'
'\nplain_under_score_value\n';
@override
void initState() {
super.initState();
_controller = LiveMarkdownController(text: _sample);
_controller.addListener(_refreshToolbar);
}
void _refreshToolbar() {
if (mounted) setState(() {});
}
void _reportAction(String message) {
setState(() => _lastAction = message);
}
@override
void dispose() {
_controller.removeListener(_refreshToolbar);
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('LiveMD Flutter'),
Text(
'Continuous Markdown live preview',
style: TextStyle(fontSize: 12, fontWeight: FontWeight.normal),
),
],
),
actions: [
IconButton(
tooltip: 'Undo',
onPressed: _controller.canUndo ? _controller.undo : null,
icon: const Icon(Icons.undo),
),
IconButton(
tooltip: 'Redo',
onPressed: _controller.canRedo ? _controller.redo : null,
icon: const Icon(Icons.redo),
),
const SizedBox(width: 12),
],
),
body: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 860),
child: Column(
children: [
Padding(
padding: const EdgeInsets.fromLTRB(24, 16, 24, 0),
child: Material(
color: Theme.of(context).colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(10),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 14,
vertical: 10,
),
child: Row(
children: [
const Icon(Icons.keyboard_alt_outlined, size: 18),
const SizedBox(width: 10),
Expanded(child: Text(_lastAction)),
],
),
),
),
),
Expanded(
child: Card(
margin: const EdgeInsets.all(24),
clipBehavior: Clip.antiAlias,
child: LiveMarkdownEditor(
controller: _controller,
autofocus: true,
onLinkTap: (destination, label) => _reportAction(
'链接点击:$label → $destination(示例只报告,不打开浏览器)',
),
onWikiLinkTap: (target, alias) =>
_reportAction('Wiki 链接点击:${alias ?? target} → $target'),
onImageTap: (destination, alt) =>
_reportAction('图片点击:$alt(示例只报告,不打开预览)'),
onVideoTap: (destination, alt) =>
_reportAction('视频点击:$alt(示例只报告,不打开预览)'),
onAudioTap: (destination, alt) =>
_reportAction('音频点击:$alt(示例只报告,不打开预览)'),
videoBuilder: (context, destination, alt) =>
_VideoEmbed(destination: destination),
audioBuilder: (context, destination, alt) =>
_AudioEmbed(destination: destination),
onTaskToggle: (_, checked) =>
_reportAction(checked ? '任务已完成' : '任务已恢复为未完成'),
),
),
),
],
),
),
),
);
}
}
/// Strips the `asset:` scheme used by the library's default resolver so the
/// destination can be handed to a player. Network destinations pass through.
String _resolveMediaPath(String destination) {
const prefix = 'asset:';
if (destination.startsWith(prefix)) {
return destination.substring(prefix.length);
}
return destination;
}
/// A real video player for a rendered video embed. Falls back to a label when
/// the platform cannot initialize (for example inside widget tests).
class _VideoEmbed extends StatefulWidget {
const _VideoEmbed({required this.destination});
final String destination;
@override
State<_VideoEmbed> createState() => _VideoEmbedState();
}
class _VideoEmbedState extends State<_VideoEmbed> {
VideoPlayerController? _controller;
bool _playing = false;
double? _dragValue;
@override
void initState() {
super.initState();
final path = _resolveMediaPath(widget.destination);
if (path.isEmpty) return;
final controller = VideoPlayerController.asset(path);
_controller = controller;
controller.addListener(_onControllerChanged);
controller
.initialize()
.then((_) {
if (!mounted) return;
setState(() {});
controller.setLooping(true);
controller.play();
})
.catchError((Object _) {
if (mounted) setState(() {});
});
}
void _onControllerChanged() {
final controller = _controller;
if (controller == null || !mounted) return;
final playing = controller.value.isPlaying;
if (playing != _playing) {
setState(() => _playing = playing);
} else {
// Position ticks update the time label without a full rebuild.
setState(() {});
}
}
void _togglePlay() {
final controller = _controller;
if (controller == null) return;
if (controller.value.isPlaying) {
controller.pause();
} else {
controller.play();
}
}
@override
void dispose() {
_controller?.removeListener(_onControllerChanged);
_controller?.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final controller = _controller;
if (controller == null || !controller.value.isInitialized) {
return const Center(child: Text('video'));
}
final position = controller.value.position;
final duration = controller.value.duration;
final progress = duration.inMilliseconds == 0
? 0.0
: (position.inMilliseconds / duration.inMilliseconds).clamp(0.0, 1.0);
final sliderValue = (_dragValue ?? progress).clamp(0.0, 1.0);
return Center(
child: AspectRatio(
aspectRatio: controller.value.aspectRatio,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: _togglePlay,
child: Stack(
fit: StackFit.expand,
children: [
VideoPlayer(controller),
Align(
alignment: Alignment.bottomCenter,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Row(
children: [
Padding(
padding: const EdgeInsets.only(left: 8),
child: Text(
'${_formatDuration(position)} / '
'${_formatDuration(duration)}',
style: const TextStyle(
color: Colors.white,
fontSize: 12,
),
),
),
const Spacer(),
Padding(
padding: const EdgeInsets.only(right: 8),
child: Material(
color: Colors.black54,
shape: const CircleBorder(),
child: IconButton(
tooltip: _playing ? '暂停' : '播放',
iconSize: 22,
color: Colors.white,
icon: Icon(
_playing ? Icons.pause : Icons.play_arrow,
),
onPressed: _togglePlay,
),
),
),
],
),
SliderTheme(
data: SliderTheme.of(context).copyWith(
trackHeight: 3,
thumbShape: const RoundSliderThumbShape(
enabledThumbRadius: 6,
),
overlayShape: const RoundSliderOverlayShape(
overlayRadius: 12,
),
),
child: Slider(
value: sliderValue,
onChanged: (value) {
setState(() => _dragValue = value);
final d = controller.value.duration;
controller.seekTo(
Duration(
milliseconds: (d.inMilliseconds * value).round(),
),
);
},
onChangeEnd: (value) =>
setState(() => _dragValue = null),
),
),
],
),
),
],
),
),
),
);
}
}
/// A real audio player for a rendered audio embed. Falls back to a label when
/// the platform cannot initialize (for example inside widget tests).
class _AudioEmbed extends StatefulWidget {
const _AudioEmbed({required this.destination});
final String destination;
@override
State<_AudioEmbed> createState() => _AudioEmbedState();
}
class _AudioEmbedState extends State<_AudioEmbed> {
AudioPlayer? _player;
bool _ready = false;
bool _playing = false;
Duration _position = Duration.zero;
Duration _duration = Duration.zero;
double? _dragValue;
@override
void initState() {
super.initState();
final path = _resolveMediaPath(widget.destination);
if (path.isEmpty) return;
final player = AudioPlayer();
_player = player;
player.playerStateStream.listen((state) {
if (!mounted) return;
final playing = state.playing;
if (playing != _playing) setState(() => _playing = playing);
});
player.positionStream.listen((position) {
if (mounted) setState(() => _position = position);
});
player.durationStream.listen((duration) {
if (mounted) setState(() => _duration = duration ?? Duration.zero);
});
player
.setAsset(path)
.then((_) {
if (mounted) setState(() => _ready = true);
player.setLoopMode(LoopMode.one);
})
.catchError((Object _) {
if (mounted) setState(() {});
});
}
void _togglePlay() {
final player = _player;
if (player == null) return;
if (player.playing) {
player.pause();
} else {
player.play();
}
}
@override
void dispose() {
_player?.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final player = _player;
if (player == null || !_ready) {
return const Center(child: Text('audio'));
}
final progress = _duration.inMilliseconds == 0
? 0.0
: (_position.inMilliseconds / _duration.inMilliseconds).clamp(0.0, 1.0);
final sliderValue = (_dragValue ?? progress).clamp(0.0, 1.0);
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Row(
children: [
IconButton(
tooltip: _playing ? '暂停' : '播放',
iconSize: 28,
padding: EdgeInsets.zero,
constraints: const BoxConstraints.tightFor(width: 40, height: 40),
icon: Icon(
_playing ? Icons.pause_circle_outline : Icons.play_circle_outline,
),
onPressed: _togglePlay,
),
const SizedBox(width: 8),
Text(
'${_formatDuration(_position)} / ${_formatDuration(_duration)}',
style: const TextStyle(fontSize: 12),
),
const SizedBox(width: 8),
Expanded(
child: SliderTheme(
data: SliderTheme.of(context).copyWith(
trackHeight: 3,
thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 6),
overlayShape: const RoundSliderOverlayShape(overlayRadius: 12),
),
child: Slider(
value: sliderValue,
onChanged: (value) {
setState(() => _dragValue = value);
final d = _duration;
_player?.seek(
Duration(milliseconds: (d.inMilliseconds * value).round()),
);
},
onChangeEnd: (value) => setState(() => _dragValue = null),
),
),
),
],
),
);
}
}
/// Formats a [Duration] as `m:ss` (or `h:mm:ss` when it exceeds an hour).
String _formatDuration(Duration duration) {
final hours = duration.inHours;
final minutes = duration.inMinutes.remainder(60);
final seconds = duration.inSeconds.remainder(60);
String two(int value) => value.toString().padLeft(2, '0');
if (hours > 0) {
return '$hours:${two(minutes)}:${two(seconds)}';
}
return '$minutes:${two(seconds)}';
}