livemd_flutter_clipboard 0.1.0
livemd_flutter_clipboard: ^0.1.0 copied to clipboard
Optional native and browser media clipboard adapter for the LiveMD Markdown editor.
example/lib/main.dart
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:livemd_flutter/livemd_flutter.dart';
import 'package:livemd_flutter_clipboard/livemd_flutter_clipboard.dart';
import 'attachment_repository.dart';
import 'media_player.dart';
import 'storage/open_storage.dart';
void main() {
WidgetsFlutterBinding.ensureInitialized();
runApp(const ClipboardDemo());
}
class ClipboardDemo extends StatefulWidget {
const ClipboardDemo({
super.key,
this.clipboard = const SuperClipboardAdapter(),
this.repository,
this.createPlayer = createDemoPlayer,
});
final LiveMarkdownClipboard clipboard;
/// An application can inject a repository with its own upload service.
/// The caller owns an injected repository and closes it after unmounting.
final DemoAttachmentRepository? repository;
final DemoPlayerFactory createPlayer;
@override
State<ClipboardDemo> createState() => _ClipboardDemoState();
}
class _ClipboardDemoState extends State<ClipboardDemo>
with WidgetsBindingObserver {
static const initialDocument =
'# Paste media\n\n'
'Copy images, audio or video files, then paste here.\n\n'
'| Place | Image |\n| --- | --- |\n| Table cell | here |\n\n'
'> [!tip] Callout\n> You can paste into this body too.\n\n';
DemoAttachmentRepository? _repository;
LiveMarkdownController? _document;
LiveMarkdownPasteConfiguration? _configuration;
Timer? _saveTimer;
String _lastText = '';
String _status = 'Ready';
String _saveStatus = 'Saved';
String? _loadError;
String? _cleanupError;
final _unused = <LiveMarkdownImportedMedia>[];
bool _importing = false;
bool _reopening = false;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
unawaited(_load());
}
Future<void> _load() async {
DemoAttachmentRepository? repository;
try {
repository =
widget.repository ??
DemoAttachmentRepository(await openDemoStorage());
final saved = await repository.loadDocument();
final text = saved ?? initialDocument;
if (saved == null) await repository.saveDocument(text);
if (!mounted) {
if (widget.repository == null) await repository.close();
return;
}
_repository = repository;
_installDocument(text);
_configuration = LiveMarkdownPasteConfiguration(
clipboard: widget.clipboard,
onImportMedia: (request) async {
if (mounted) {
setState(() {
_importing = true;
_status = 'Importing media…';
});
}
return repository!.importMedia(request);
},
onResult: (result) => unawaited(_handleResult(result)),
);
setState(() => _loadError = null);
} catch (_) {
if (repository != null && widget.repository == null) {
await repository.close();
}
if (mounted) {
setState(
() => _loadError =
'Could not open the saved document. Check storage and retry.',
);
}
}
}
void _installDocument(String text) {
_document?.removeListener(_changed);
_document?.dispose();
_document = LiveMarkdownController(text: text)..addListener(_changed);
_lastText = text;
}
void _changed() {
final text = _document!.text;
if (text == _lastText) return;
_lastText = text;
_saveTimer?.cancel();
_saveTimer = Timer(
const Duration(milliseconds: 300),
() => unawaited(_save()),
);
if (mounted) setState(() => _saveStatus = 'Unsaved changes');
}
Future<bool> _save() async {
_saveTimer?.cancel();
final repository = _repository;
final text = _document?.text;
if (repository == null || text == null) return false;
if (mounted) setState(() => _saveStatus = 'Saving…');
try {
await repository.saveDocument(text);
if (mounted) {
setState(
() => _saveStatus = _document!.text == text
? 'Saved'
: 'Unsaved changes',
);
}
return true;
} catch (_) {
if (mounted) {
setState(() => _saveStatus = 'Save failed — retry with Save now');
}
return false;
}
}
Future<void> _reopen() async {
if (_importing || _reopening) return;
FocusManager.instance.primaryFocus?.unfocus();
setState(() => _reopening = true);
try {
if (!await _save()) return;
final text = await _repository!.loadDocument();
if (mounted) {
setState(() {
_installDocument(text ?? initialDocument);
_status = 'Document reopened';
});
}
} catch (_) {
if (mounted) setState(() => _status = 'Could not reopen the document');
} finally {
if (mounted) setState(() => _reopening = false);
}
}
Future<void> _handleResult(LiveMarkdownPasteResult result) async {
if (result.status != LiveMarkdownPasteStatus.inserted) {
_unused.addAll(result.importedMedia);
await _cleanup();
}
if (!mounted) return;
setState(() {
if (result.status != LiveMarkdownPasteStatus.busy) _importing = false;
_status = switch (result.status) {
LiveMarkdownPasteStatus.inserted =>
result.items.length > 1
? '${result.items.length} items inserted'
: switch (_repository!
.lookup(result.imported!.destination)
?.type) {
LiveMarkdownMediaType.video => 'Video inserted',
LiveMarkdownMediaType.audio => 'Audio inserted',
_ => 'Image inserted',
},
LiveMarkdownPasteStatus.textPasted => 'Text pasted',
LiveMarkdownPasteStatus.empty => 'Clipboard is empty',
LiveMarkdownPasteStatus.unsupported =>
'Unsupported media or too many files',
LiveMarkdownPasteStatus.rejected =>
'File is empty or exceeds the 20 MiB limit',
LiveMarkdownPasteStatus.readFailed => 'Could not read the clipboard',
LiveMarkdownPasteStatus.importFailed =>
'Could not save or upload this media',
LiveMarkdownPasteStatus.cancelled => 'Paste cancelled',
LiveMarkdownPasteStatus.busy =>
'A media batch is already being imported',
};
});
}
Future<void> _cleanup() async {
if (_unused.isEmpty) return;
final batch = List<LiveMarkdownImportedMedia>.of(_unused);
try {
await _repository!.discard(batch);
for (final item in batch) {
_unused.remove(item);
}
if (mounted) setState(() => _cleanupError = null);
} catch (_) {
if (mounted) {
setState(
() => _cleanupError = 'Some unused attachments could not be removed.',
);
}
}
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state != AppLifecycleState.resumed) unawaited(_save());
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
_saveTimer?.cancel();
final repository = _repository;
final text = _document?.text;
_document?.removeListener(_changed);
_document?.dispose();
// Normal navigation gets a final flush. Abrupt process termination cannot
// await Dart futures; the visible Saved indicator is the durability boundary.
if (repository != null) {
unawaited(
() async {
try {
if (text != null) await repository.saveDocument(text);
} finally {
if (widget.repository == null) await repository.close();
}
}().catchError((Object _) {}),
);
}
super.dispose();
}
@override
Widget build(BuildContext context) => MaterialApp(
theme: ThemeData(colorSchemeSeed: const Color(0xff637d69)),
home: Scaffold(
appBar: AppBar(title: const Text('LiveMD · Media paste')),
body: _loadError != null
? Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(_loadError!),
TextButton(
onPressed: _load,
child: const Text('Retry opening'),
),
],
),
)
: _document == null
? const Center(child: CircularProgressIndicator())
: Column(
children: [
Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(_status, key: const ValueKey('paste-status')),
const SizedBox(height: 4),
Text(_repository!.storage.description),
Wrap(
spacing: 12,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
Text(_saveStatus, key: const ValueKey('save-status')),
TextButton.icon(
onPressed: _save,
icon: const Icon(Icons.save_outlined),
label: const Text('Save now'),
),
TextButton.icon(
onPressed: _importing || _reopening
? null
: _reopen,
icon: const Icon(Icons.refresh),
label: const Text('Reopen document'),
),
],
),
if (_cleanupError != null)
Wrap(
crossAxisAlignment: WrapCrossAlignment.center,
children: [
Text(_cleanupError!),
TextButton(
onPressed: _cleanup,
child: const Text('Retry cleanup'),
),
],
),
],
),
),
const Divider(height: 1),
Expanded(
child: Stack(
fit: StackFit.expand,
children: [
LiveMarkdownEditor(
controller: _document!,
autofocus: true,
pasteConfiguration: _configuration,
mediaTypeResolver: (destination) =>
_repository!.lookup(destination)?.type,
imageResolver: _repository!.imageResolver,
videoBuilder: (_, destination, alt) => DemoMediaPlayer(
key: ValueKey('video-$destination'),
repository: _repository!,
destination: destination,
label: _repository!.lookup(destination)?.name ?? alt,
video: true,
createPlayer: widget.createPlayer,
),
audioBuilder: (_, destination, alt) => DemoMediaPlayer(
key: ValueKey('audio-$destination'),
repository: _repository!,
destination: destination,
label: _repository!.lookup(destination)?.name ?? alt,
video: false,
createPlayer: widget.createPlayer,
),
),
if (_reopening)
const ModalBarrier(
dismissible: false,
color: Colors.transparent,
),
],
),
),
],
),
),
);
}