Skip to main content

livemd_flutter 0.4.0 copy "livemd_flutter: ^0.4.0" to clipboard
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'
      '行内图片 ![Inline image](asset:assets/livemd_demo.png) 和行内音频 '
      '![Inline audio](asset:assets/demo_track.wav) 保持正文行高。\n\n'
      '## Image preview\n\n'
      '图片直接渲染本地资源;光标方向键进入图片范围才揭示 `![...](...)` 源码。\n\n'
      '![LiveMD demo](asset:assets/livemd_demo.png)\n\n'
      '## Video preview\n\n'
      '目标为视频文件时渲染视频槽位(可注入 `videoBuilder` 播放器);\n'
      '方向键进入范围仍揭示 `![...](...)` 源码。\n\n'
      '![Demo clip](asset:assets/demo_clip.mp4)\n\n'
      '## Audio preview\n\n'
      '目标为音频文件时渲染音频槽位(可注入 `audioBuilder` 播放器)。\n\n'
      '![Demo track](asset:assets/demo_track.wav)\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)}';
}
0
likes
0
points
235
downloads

Publisher

unverified uploader

Weekly Downloads

A lossless continuous Markdown Live Preview editor for Flutter.

Repository (GitHub)
View/report issues

License

unknown (license)

Dependencies

flutter, flutter_markdown_plus

More

Packages that depend on livemd_flutter