forked from eatvio/eatvio_app
503 lines
18 KiB
Dart
503 lines
18 KiB
Dart
import 'dart:async';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter_keyboard_visibility/flutter_keyboard_visibility.dart';
|
|
import 'package:eatvio_app/core/widgets/custom_text_form_field.dart';
|
|
|
|
class MarkdownEditor extends StatefulWidget {
|
|
const MarkdownEditor({
|
|
super.key,
|
|
this.initialText,
|
|
this.hintText,
|
|
this.hasError = false,
|
|
this.minLines = 4,
|
|
this.maxLines = 6,
|
|
this.heightPreview = 120,
|
|
this.onChanged,
|
|
this.onToolbarVisibilityChanged,
|
|
this.showInlineToolbar = true,
|
|
});
|
|
|
|
final String? initialText;
|
|
final String? hintText;
|
|
final bool hasError;
|
|
final int minLines;
|
|
final int maxLines;
|
|
final double heightPreview;
|
|
final void Function(String value)? onChanged;
|
|
final void Function(bool visible)? onToolbarVisibilityChanged;
|
|
final bool showInlineToolbar;
|
|
|
|
@override
|
|
State<MarkdownEditor> createState() => MarkdownEditorState();
|
|
}
|
|
|
|
class MarkdownEditorState extends State<MarkdownEditor> {
|
|
late final TextEditingController _controller;
|
|
final FocusNode _focusNode = FocusNode();
|
|
bool _isPreview = false;
|
|
bool _enablePanel = false;
|
|
late final KeyboardVisibilityController _keyboardVisibilityController;
|
|
late final StreamSubscription<bool> _keyboardSubscription;
|
|
|
|
bool get isToolbarVisible => _enablePanel || _isPreview;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_controller = TextEditingController(text: widget.initialText ?? '');
|
|
_keyboardVisibilityController = KeyboardVisibilityController();
|
|
_keyboardSubscription = _keyboardVisibilityController.onChange.listen((visible) {
|
|
setState(() {
|
|
_enablePanel = visible;
|
|
});
|
|
widget.onToolbarVisibilityChanged?.call(isToolbarVisible);
|
|
});
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
widget.onToolbarVisibilityChanged?.call(isToolbarVisible);
|
|
});
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_keyboardSubscription.cancel();
|
|
_focusNode.dispose();
|
|
_controller.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
bool _hasFormatting(String prefix, String suffix) {
|
|
final text = _controller.text;
|
|
final selection = _controller.selection;
|
|
if (selection.isValid && !selection.isCollapsed) {
|
|
final selectedText = text.substring(selection.start, selection.end);
|
|
return selectedText.startsWith(prefix) && selectedText.endsWith(suffix);
|
|
} else {
|
|
final cursorPosition = selection.baseOffset;
|
|
if (cursorPosition >= prefix.length && text.length >= cursorPosition + suffix.length) {
|
|
final beforeCursor = text.substring(cursorPosition - prefix.length, cursorPosition);
|
|
final afterCursor = text.substring(cursorPosition, cursorPosition + suffix.length);
|
|
return beforeCursor == prefix && afterCursor == suffix;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
void _removeFormatting(String prefix, String suffix) {
|
|
final text = _controller.text;
|
|
final selection = _controller.selection;
|
|
if (selection.isValid && !selection.isCollapsed) {
|
|
final selectedText = text.substring(selection.start, selection.end);
|
|
final unformattedText = selectedText.substring(prefix.length, selectedText.length - suffix.length);
|
|
final newText = text.replaceRange(selection.start, selection.end, unformattedText);
|
|
_controller.text = newText;
|
|
_controller.selection = TextSelection(baseOffset: selection.start, extentOffset: selection.start + unformattedText.length);
|
|
} else {
|
|
final cursorPosition = selection.baseOffset;
|
|
if (cursorPosition >= prefix.length && text.length >= cursorPosition + suffix.length) {
|
|
final beforeCursor = text.substring(0, cursorPosition - prefix.length);
|
|
final afterCursor = text.substring(cursorPosition + suffix.length);
|
|
final newText = beforeCursor + afterCursor;
|
|
_controller.text = newText;
|
|
_controller.selection = TextSelection.collapsed(offset: cursorPosition - prefix.length);
|
|
}
|
|
}
|
|
widget.onChanged?.call(_controller.text);
|
|
}
|
|
|
|
void _applyMarkdownFormat(String prefix, String suffix) {
|
|
final text = _controller.text;
|
|
final selection = _controller.selection;
|
|
if (selection.isValid && !selection.isCollapsed) {
|
|
final selectedText = text.substring(selection.start, selection.end);
|
|
final formattedText = '$prefix$selectedText$suffix';
|
|
final newText = text.replaceRange(selection.start, selection.end, formattedText);
|
|
_controller.text = newText;
|
|
_controller.selection = TextSelection(baseOffset: selection.start, extentOffset: selection.start + formattedText.length);
|
|
} else {
|
|
final cursorPosition = selection.baseOffset;
|
|
final newText = '${text.substring(0, cursorPosition)}$prefix$suffix${text.substring(cursorPosition)}';
|
|
_controller.text = newText;
|
|
_controller.selection = TextSelection.collapsed(offset: cursorPosition + prefix.length);
|
|
}
|
|
widget.onChanged?.call(_controller.text);
|
|
}
|
|
|
|
void _applyHeading() {
|
|
if (_hasFormatting('# ', '')) {
|
|
_removeFormatting('# ', '');
|
|
} else {
|
|
_applyMarkdownFormat('# ', '');
|
|
}
|
|
}
|
|
|
|
void _applyBold() {
|
|
if (_hasFormatting('**', '**')) {
|
|
_removeFormatting('**', '**');
|
|
} else {
|
|
_applyMarkdownFormat('**', '**');
|
|
}
|
|
}
|
|
|
|
void _applyItalic() {
|
|
if (_hasFormatting('*', '*')) {
|
|
_removeFormatting('*', '*');
|
|
} else {
|
|
_applyMarkdownFormat('*', '*');
|
|
}
|
|
}
|
|
|
|
void _applyNumberedList() {
|
|
if (_hasFormatting('1. ', '')) {
|
|
_removeFormatting('1. ', '');
|
|
} else {
|
|
_applyMarkdownFormat('1. ', '');
|
|
}
|
|
}
|
|
|
|
void _applyBulletedList() {
|
|
if (_hasFormatting('- ', '')) {
|
|
_removeFormatting('- ', '');
|
|
} else {
|
|
_applyMarkdownFormat('- ', '');
|
|
}
|
|
}
|
|
|
|
void _applyLink() {
|
|
final text = _controller.text;
|
|
final selection = _controller.selection;
|
|
if (selection.isValid && !selection.isCollapsed) {
|
|
final selectedText = text.substring(selection.start, selection.end);
|
|
final linkRegex = RegExp(r'^\[(.*?)\]\((.*?)\)$');
|
|
if (linkRegex.hasMatch(selectedText)) {
|
|
final match = linkRegex.firstMatch(selectedText);
|
|
final linkText = match?.group(1) ?? selectedText;
|
|
final newText = text.replaceRange(selection.start, selection.end, linkText);
|
|
_controller.text = newText;
|
|
_controller.selection = TextSelection(baseOffset: selection.start, extentOffset: selection.start + linkText.length);
|
|
} else {
|
|
final formattedText = '[$selectedText](url)';
|
|
final newText = text.replaceRange(selection.start, selection.end, formattedText);
|
|
_controller.text = newText;
|
|
_controller.selection = TextSelection(baseOffset: selection.start + selectedText.length + 2, extentOffset: selection.start + selectedText.length + 5);
|
|
}
|
|
} else {
|
|
_applyMarkdownFormat('[', '](url)');
|
|
}
|
|
widget.onChanged?.call(_controller.text);
|
|
}
|
|
|
|
void _applyHorizontalRule() {
|
|
final cursorPosition = _controller.selection.baseOffset;
|
|
final text = _controller.text;
|
|
final newText = '${text.substring(0, cursorPosition)}\n---\n${text.substring(cursorPosition)}';
|
|
_controller.text = newText;
|
|
_controller.selection = TextSelection.collapsed(offset: cursorPosition + 5);
|
|
widget.onChanged?.call(_controller.text);
|
|
}
|
|
|
|
void _togglePreview() {
|
|
setState(() {
|
|
_isPreview = !_isPreview;
|
|
});
|
|
if (!_isPreview) {
|
|
Future.delayed(const Duration(milliseconds: 100), () {
|
|
_focusNode.requestFocus();
|
|
});
|
|
} else {
|
|
_focusNode.unfocus();
|
|
}
|
|
widget.onToolbarVisibilityChanged?.call(isToolbarVisible);
|
|
}
|
|
|
|
Widget _mdButton(IconData icon, VoidCallback onTap) {
|
|
return Expanded(
|
|
child: GestureDetector(
|
|
onTap: onTap,
|
|
child: Container(
|
|
height: 36,
|
|
decoration: BoxDecoration(color: const Color(0xFFF1F2F6), borderRadius: BorderRadius.circular(10)),
|
|
child: Icon(icon),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _mdTextButton(String text, VoidCallback onTap) {
|
|
return Expanded(
|
|
child: GestureDetector(
|
|
onTap: onTap,
|
|
child: Container(
|
|
height: 36,
|
|
decoration: BoxDecoration(color: const Color(0xFFF1F2F6), borderRadius: BorderRadius.circular(10)),
|
|
child: Center(
|
|
child: Text(
|
|
text,
|
|
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w900, color: Colors.black87),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildMarkdownPreview(BuildContext context) {
|
|
return Container(
|
|
height: widget.heightPreview,
|
|
padding: const EdgeInsets.all(16),
|
|
width: MediaQuery.of(context).size.width - 32,
|
|
decoration: BoxDecoration(
|
|
color: const Color(0xFFF6F6F6),
|
|
borderRadius: BorderRadius.circular(20),
|
|
border: Border.all(color: const Color(0xFFE0E0E0)),
|
|
),
|
|
child: SingleChildScrollView(child: _parseMarkdown(_controller.text)),
|
|
);
|
|
}
|
|
|
|
Widget buildToolbar(BuildContext context) {
|
|
return Row(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
Container(
|
|
width: MediaQuery.of(context).size.width - 76,
|
|
height: 52,
|
|
decoration: BoxDecoration(
|
|
color: Colors.white,
|
|
borderRadius: BorderRadius.circular(20),
|
|
boxShadow: [BoxShadow(color: const Color(0x00000000).withAlpha((255 * 0.2).toInt()), spreadRadius: 0, blurRadius: 32, offset: const Offset(0, 0))],
|
|
),
|
|
child: Row(
|
|
children: [
|
|
const SizedBox(width: 8),
|
|
_mdTextButton('H', _applyHeading),
|
|
const SizedBox(width: 8),
|
|
_mdButton(Icons.format_bold, _applyBold),
|
|
const SizedBox(width: 8),
|
|
_mdButton(Icons.format_italic, _applyItalic),
|
|
const SizedBox(width: 8),
|
|
_mdButton(Icons.format_list_numbered, _applyNumberedList),
|
|
const SizedBox(width: 8),
|
|
_mdButton(Icons.format_list_bulleted, _applyBulletedList),
|
|
const SizedBox(width: 8),
|
|
_mdButton(Icons.link, _applyLink),
|
|
const SizedBox(width: 8),
|
|
_mdButton(Icons.horizontal_rule, _applyHorizontalRule),
|
|
const SizedBox(width: 8),
|
|
],
|
|
),
|
|
),
|
|
const SizedBox(width: 8),
|
|
Container(
|
|
width: 52,
|
|
height: 52,
|
|
padding: const EdgeInsets.symmetric(horizontal: 8),
|
|
decoration: BoxDecoration(
|
|
color: Colors.white,
|
|
borderRadius: BorderRadius.circular(20),
|
|
boxShadow: [BoxShadow(color: const Color(0x00000000).withAlpha((255 * 0.2).toInt()), spreadRadius: 0, blurRadius: 32, offset: const Offset(0, 0))],
|
|
),
|
|
child: Row(children: [_mdButton(_isPreview ? Icons.edit : Icons.visibility, _togglePreview)]),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
Widget _parseMarkdown(String text) {
|
|
if (text.isEmpty) {
|
|
return Text(widget.hintText ?? '');
|
|
}
|
|
final lines = text.split('\n');
|
|
final widgets = <Widget>[];
|
|
for (int i = 0; i < lines.length; i++) {
|
|
final line = lines[i];
|
|
if (line.trim().isEmpty) {
|
|
widgets.add(const SizedBox(height: 8));
|
|
continue;
|
|
}
|
|
if (line.startsWith('# ')) {
|
|
widgets.add(
|
|
Text(
|
|
line.substring(2),
|
|
style: const TextStyle(fontSize: 24, fontWeight: FontWeight.bold, color: Colors.black),
|
|
),
|
|
);
|
|
} else if (line.startsWith('## ')) {
|
|
widgets.add(
|
|
Text(
|
|
line.substring(3),
|
|
style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold, color: Colors.black),
|
|
),
|
|
);
|
|
} else if (line.startsWith('### ')) {
|
|
widgets.add(
|
|
Text(
|
|
line.substring(4),
|
|
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold, color: Colors.black),
|
|
),
|
|
);
|
|
} else if (line.startsWith('- ') || line.startsWith('* ')) {
|
|
widgets.add(
|
|
Padding(
|
|
padding: const EdgeInsets.only(left: 16),
|
|
child: Row(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
const Text('• ', style: TextStyle(fontSize: 16)),
|
|
Expanded(child: _parseInlineMarkdown(line.substring(2))),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
} else if (RegExp(r'^\d+\. ').hasMatch(line)) {
|
|
final match = RegExp(r'^(\d+)\. ').firstMatch(line);
|
|
final number = match?.group(1) ?? '1';
|
|
widgets.add(
|
|
Padding(
|
|
padding: const EdgeInsets.only(left: 16),
|
|
child: Row(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text('$number. ', style: const TextStyle(fontSize: 16)),
|
|
Expanded(child: _parseInlineMarkdown(line.substring(number.length + 2))),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
} else if (line.trim() == '---') {
|
|
widgets.add(const Divider(color: Colors.grey, thickness: 1));
|
|
} else {
|
|
widgets.add(_parseInlineMarkdown(line));
|
|
}
|
|
if (i < lines.length - 1) {
|
|
widgets.add(const SizedBox(height: 4));
|
|
}
|
|
}
|
|
return Column(crossAxisAlignment: CrossAxisAlignment.start, children: widgets);
|
|
}
|
|
|
|
Widget _parseInlineMarkdown(String text) {
|
|
final spans = <TextSpan>[];
|
|
final regex = RegExp(r'(\*\*.*?\*\*|\*.*?\*|\[.*?\]\(.*?\))');
|
|
int lastEnd = 0;
|
|
for (final match in regex.allMatches(text)) {
|
|
if (match.start > lastEnd) {
|
|
spans.add(
|
|
TextSpan(
|
|
text: text.substring(lastEnd, match.start),
|
|
style: const TextStyle(fontSize: 16, color: Colors.black),
|
|
),
|
|
);
|
|
}
|
|
final matchedText = match.group(0)!;
|
|
if (matchedText.startsWith('**') && matchedText.endsWith('**')) {
|
|
spans.add(
|
|
TextSpan(
|
|
text: matchedText.substring(2, matchedText.length - 2),
|
|
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: Colors.black),
|
|
),
|
|
);
|
|
} else if (matchedText.startsWith('*') && matchedText.endsWith('*')) {
|
|
spans.add(
|
|
TextSpan(
|
|
text: matchedText.substring(1, matchedText.length - 1),
|
|
style: const TextStyle(fontSize: 16, fontStyle: FontStyle.italic, color: Colors.black),
|
|
),
|
|
);
|
|
} else if (matchedText.startsWith('[') && matchedText.contains('](')) {
|
|
final linkMatch = RegExp(r'\[(.*?)\]\((.*?)\)').firstMatch(matchedText);
|
|
if (linkMatch != null) {
|
|
final linkText = linkMatch.group(1)!;
|
|
spans.add(
|
|
TextSpan(
|
|
text: linkText,
|
|
style: const TextStyle(fontSize: 16, color: Colors.blue, decoration: TextDecoration.underline),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
lastEnd = match.end;
|
|
}
|
|
if (lastEnd < text.length) {
|
|
spans.add(
|
|
TextSpan(
|
|
text: text.substring(lastEnd),
|
|
style: const TextStyle(fontSize: 16, color: Colors.black),
|
|
),
|
|
);
|
|
}
|
|
return RichText(text: TextSpan(children: spans));
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
if (_isPreview)
|
|
_buildMarkdownPreview(context)
|
|
else
|
|
CustomTextFormField(
|
|
controller: _controller,
|
|
focusNode: _focusNode,
|
|
maxLines: widget.maxLines,
|
|
minLines: widget.minLines,
|
|
hintText: widget.hintText,
|
|
hasError: widget.hasError,
|
|
onChanged: (value) => widget.onChanged?.call(value),
|
|
),
|
|
const SizedBox(height: 8),
|
|
if (widget.showInlineToolbar && (isToolbarVisible || !_isPreview))
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
child: Container(
|
|
height: 52,
|
|
decoration: BoxDecoration(
|
|
color: Colors.white,
|
|
borderRadius: BorderRadius.circular(20),
|
|
boxShadow: [
|
|
BoxShadow(color: const Color(0x00000000).withAlpha((255 * 0.2).toInt()), spreadRadius: 0, blurRadius: 32, offset: const Offset(0, 0)),
|
|
],
|
|
),
|
|
child: Row(
|
|
children: [
|
|
const SizedBox(width: 8),
|
|
_mdTextButton('H', _applyHeading),
|
|
const SizedBox(width: 8),
|
|
_mdButton(Icons.format_bold, _applyBold),
|
|
const SizedBox(width: 8),
|
|
_mdButton(Icons.format_italic, _applyItalic),
|
|
const SizedBox(width: 8),
|
|
_mdButton(Icons.format_list_numbered, _applyNumberedList),
|
|
const SizedBox(width: 8),
|
|
_mdButton(Icons.format_list_bulleted, _applyBulletedList),
|
|
const SizedBox(width: 8),
|
|
_mdButton(Icons.link, _applyLink),
|
|
const SizedBox(width: 8),
|
|
_mdButton(Icons.horizontal_rule, _applyHorizontalRule),
|
|
const SizedBox(width: 8),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(width: 8),
|
|
Container(
|
|
width: 52,
|
|
height: 52,
|
|
padding: const EdgeInsets.symmetric(horizontal: 8),
|
|
decoration: BoxDecoration(
|
|
color: Colors.white,
|
|
borderRadius: BorderRadius.circular(20),
|
|
boxShadow: [
|
|
BoxShadow(color: const Color(0x00000000).withAlpha((255 * 0.2).toInt()), spreadRadius: 0, blurRadius: 32, offset: const Offset(0, 0)),
|
|
],
|
|
),
|
|
child: Row(children: [_mdButton(_isPreview ? Icons.edit : Icons.visibility, _togglePreview)]),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|