dartlangeditorflutterflutter-appsflutter-examplesflutter-packageflutter-widgetquillquill-deltaquilljsreactquillrich-textrich-text-editorwysiwygwysiwyg-editor
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
425 lines
12 KiB
425 lines
12 KiB
import 'dart:collection'; |
|
import 'dart:convert'; |
|
|
|
import 'package:collection/collection.dart'; |
|
import 'package:markdown/markdown.dart' as md; |
|
|
|
import '../../../flutter_quill.dart'; |
|
import '../../../quill_delta.dart'; |
|
import './custom_quill_attributes.dart'; |
|
import './embeddable_table_syntax.dart'; |
|
import './utils.dart'; |
|
|
|
/// Converts markdown [md.Element] to list of [Attribute]. |
|
typedef ElementToAttributeConvertor = List<Attribute<dynamic>> Function( |
|
md.Element element, |
|
); |
|
|
|
/// Converts markdown [md.Element] to [Embeddable]. |
|
typedef ElementToEmbeddableConvertor = Embeddable Function( |
|
Map<String, String> elAttrs, |
|
); |
|
|
|
/// Convertor from Markdown string to quill [Delta]. |
|
class MarkdownToDelta extends Converter<String, Delta> |
|
implements md.NodeVisitor { |
|
/// |
|
MarkdownToDelta({ |
|
required this.markdownDocument, |
|
this.customElementToInlineAttribute = const {}, |
|
this.customElementToBlockAttribute = const {}, |
|
this.customElementToEmbeddable = const {}, |
|
this.softLineBreak = false, |
|
}); |
|
|
|
final md.Document markdownDocument; |
|
final Map<String, ElementToAttributeConvertor> customElementToInlineAttribute; |
|
final Map<String, ElementToAttributeConvertor> customElementToBlockAttribute; |
|
final Map<String, ElementToEmbeddableConvertor> customElementToEmbeddable; |
|
final bool softLineBreak; |
|
|
|
// final _blockTags = <String>[ |
|
// 'p', |
|
// 'h1', |
|
// 'h2', |
|
// 'h3', |
|
// 'h4', |
|
// 'h5', |
|
// 'h6', |
|
// 'li', |
|
// 'blockquote', |
|
// 'pre', |
|
// 'ol', |
|
// 'ul', |
|
// 'hr', |
|
// 'table', |
|
// 'thead', |
|
// 'tbody', |
|
// 'tr' |
|
// ]; |
|
|
|
final _elementToBlockAttr = <String, ElementToAttributeConvertor>{ |
|
'ul': (_) => [Attribute.ul], |
|
'ol': (_) => [Attribute.ol], |
|
'pre': (element) { |
|
final codeChild = element.children!.first as md.Element; |
|
final language = (codeChild.attributes['class'] ?? '') |
|
.split(' ') |
|
.where((class_) => class_.startsWith('language-')) |
|
.firstOrNull |
|
?.split('-') |
|
.lastOrNull; |
|
return [ |
|
Attribute.codeBlock, |
|
if (language != null) CodeBlockLanguageAttribute(language), |
|
]; |
|
}, |
|
'blockquote': (_) => [Attribute.blockQuote], |
|
'h1': (_) => [Attribute.h1], |
|
'h2': (_) => [Attribute.h2], |
|
'h3': (_) => [Attribute.h3], |
|
}; |
|
|
|
final _elementToInlineAttr = <String, ElementToAttributeConvertor>{ |
|
'em': (_) => [Attribute.italic], |
|
'strong': (_) => [Attribute.bold], |
|
'del': (_) => [Attribute.strikeThrough], |
|
'a': (element) => [LinkAttribute(element.attributes['href'])], |
|
'code': (_) => [Attribute.inlineCode], |
|
}; |
|
|
|
final _elementToEmbed = <String, ElementToEmbeddableConvertor>{ |
|
'hr': (_) => horizontalRule, |
|
'img': (elAttrs) => BlockEmbed.image(elAttrs['src'] ?? ''), |
|
}; |
|
|
|
var _delta = Delta(); |
|
final _activeInlineAttributes = Queue<List<Attribute<dynamic>>>(); |
|
final _activeBlockAttributes = Queue<List<Attribute<dynamic>>>(); |
|
final _topLevelNodes = <md.Node>[]; |
|
bool _isInBlockQuote = false; |
|
bool _isInCodeblock = false; |
|
bool _justPreviousBlockExit = false; |
|
String? _lastTag; |
|
String? _currentBlockTag; |
|
int _listItemIndent = -1; |
|
|
|
@override |
|
Delta convert(String input) { |
|
_delta = Delta(); |
|
_activeInlineAttributes.clear(); |
|
_activeBlockAttributes.clear(); |
|
_topLevelNodes.clear(); |
|
_lastTag = null; |
|
_currentBlockTag = null; |
|
_isInBlockQuote = false; |
|
_isInCodeblock = false; |
|
_justPreviousBlockExit = false; |
|
_listItemIndent = -1; |
|
|
|
final lines = const LineSplitter().convert(input); |
|
final mdNodes = markdownDocument.parseLines(lines); |
|
|
|
_topLevelNodes.addAll(mdNodes); |
|
|
|
for (final node in mdNodes) { |
|
node.accept(this); |
|
} |
|
|
|
// Ensure the delta ends with a newline. |
|
_appendLastNewLineIfNeeded(); |
|
|
|
return _delta; |
|
} |
|
|
|
void _appendLastNewLineIfNeeded() { |
|
if (_delta.isEmpty) return; |
|
final dynamic lastValue = _delta.last.value; |
|
if (!(lastValue is String && lastValue.endsWith('\n'))) { |
|
_delta.insert('\n', _effectiveBlockAttrs()); |
|
} |
|
} |
|
|
|
@override |
|
void visitText(md.Text text) { |
|
String renderedText; |
|
if (_isInBlockQuote) { |
|
renderedText = text.text; |
|
} else if (_isInCodeblock) { |
|
renderedText = text.text.endsWith('\n') |
|
? text.text.substring(0, text.text.length - 1) |
|
: text.text; |
|
} else { |
|
renderedText = _trimTextToMdSpec(text.text); |
|
} |
|
|
|
if (renderedText.contains('\n')) { |
|
var lines = renderedText.split('\n'); |
|
if (renderedText.endsWith('\n')) { |
|
lines = lines.sublist(0, lines.length - 1); |
|
} |
|
for (var i = 0; i < lines.length; i++) { |
|
final isLastItem = i == lines.length - 1; |
|
final line = lines[i]; |
|
_delta.insert(line, _effectiveInlineAttrs()); |
|
if (!isLastItem) { |
|
_delta.insert('\n', _effectiveBlockAttrs()); |
|
} |
|
} |
|
} else { |
|
_delta.insert(renderedText, _effectiveInlineAttrs()); |
|
} |
|
_lastTag = null; |
|
_justPreviousBlockExit = false; |
|
} |
|
|
|
@override |
|
bool visitElementBefore(md.Element element) { |
|
_insertNewLineBeforeElementIfNeeded(element); |
|
|
|
final tag = element.tag; |
|
_currentBlockTag ??= tag; |
|
_lastTag = tag; |
|
|
|
if (_haveBlockAttrs(element)) { |
|
_activeBlockAttributes.addLast(_toBlockAttributes(element)); |
|
} |
|
if (_haveInlineAttrs(element)) { |
|
_activeInlineAttributes.addLast(_toInlineAttributes(element)); |
|
} |
|
|
|
if (tag == 'blockquote') { |
|
_isInBlockQuote = true; |
|
} |
|
|
|
if (tag == 'pre') { |
|
_isInCodeblock = true; |
|
} |
|
|
|
if (tag == 'li') { |
|
_listItemIndent++; |
|
} |
|
|
|
return true; |
|
} |
|
|
|
@override |
|
void visitElementAfter(md.Element element) { |
|
final tag = element.tag; |
|
|
|
if (_isEmbedElement(element)) { |
|
_delta.insert(_toEmbeddable(element).toJson(), element.attributes); |
|
} |
|
|
|
if (tag == 'br') { |
|
_delta.insert('\n'); |
|
} |
|
|
|
// exit block with new line |
|
// hr need to be followed by new line |
|
_insertNewLineAfterElementIfNeeded(element); |
|
|
|
if (tag == 'blockquote') { |
|
_isInBlockQuote = false; |
|
} |
|
|
|
if (tag == 'pre') { |
|
_isInCodeblock = false; |
|
} |
|
|
|
if (tag == 'li') { |
|
_listItemIndent--; |
|
} |
|
|
|
if (_haveBlockAttrs(element)) { |
|
_activeBlockAttributes.removeLast(); |
|
} |
|
|
|
if (_haveInlineAttrs(element)) { |
|
_activeInlineAttributes.removeLast(); |
|
} |
|
|
|
if (_currentBlockTag == tag) { |
|
_currentBlockTag = null; |
|
} |
|
_lastTag = tag; |
|
} |
|
|
|
void _insertNewLine() { |
|
_delta.insert('\n', _effectiveBlockAttrs()); |
|
} |
|
|
|
void _insertNewLineBeforeElementIfNeeded(md.Element element) { |
|
if (!_isInBlockQuote && |
|
_lastTag == 'blockquote' && |
|
element.tag == 'blockquote') { |
|
_insertNewLine(); |
|
return; |
|
} |
|
|
|
if (!_isInCodeblock && _lastTag == 'pre' && element.tag == 'pre') { |
|
_insertNewLine(); |
|
return; |
|
} |
|
|
|
if (_listItemIndent >= 0 && (element.tag == 'ul' || element.tag == 'ol')) { |
|
_insertNewLine(); |
|
return; |
|
} |
|
} |
|
|
|
void _insertNewLineAfterElementIfNeeded(md.Element element) { |
|
// TODO: refactor this to allow embeds to specify if they require |
|
// new line after them |
|
if (element.tag == 'hr' || element.tag == EmbeddableTable.tableType) { |
|
// Always add new line after divider |
|
_justPreviousBlockExit = true; |
|
_insertNewLine(); |
|
return; |
|
} |
|
|
|
// if all the p children are embeddable add a new line |
|
// example: images in a single line |
|
if (element.tag == 'p' && |
|
(element.children?.every( |
|
(child) => child is md.Element && _isEmbedElement(child), |
|
) ?? |
|
false)) { |
|
_justPreviousBlockExit = true; |
|
_insertNewLine(); |
|
return; |
|
} |
|
|
|
if (!_justPreviousBlockExit && |
|
(_isTopLevelNode(element) || |
|
_haveBlockAttrs(element) || |
|
element.tag == 'li')) { |
|
_justPreviousBlockExit = true; |
|
_insertNewLine(); |
|
return; |
|
} |
|
} |
|
|
|
bool _isTopLevelNode(md.Node node) => _topLevelNodes.contains(node); |
|
|
|
Map<String, dynamic>? _effectiveBlockAttrs() { |
|
if (_activeBlockAttributes.isEmpty) return null; |
|
final attrsRespectingExclusivity = <Attribute<dynamic>>[ |
|
if (_listItemIndent > 0) IndentAttribute(level: _listItemIndent), |
|
]; |
|
|
|
for (final attr in _activeBlockAttributes.expand((e) => e)) { |
|
final isExclusiveAttr = Attribute.exclusiveBlockKeys.contains( |
|
attr.key, |
|
); |
|
final isThereAlreadyExclusiveAttr = attrsRespectingExclusivity.any( |
|
(element) => Attribute.exclusiveBlockKeys.contains(element.key), |
|
); |
|
|
|
if (!(isExclusiveAttr && isThereAlreadyExclusiveAttr)) { |
|
attrsRespectingExclusivity.add(attr); |
|
} |
|
} |
|
|
|
return <String, dynamic>{ |
|
for (final a in attrsRespectingExclusivity) ...a.toJson(), |
|
}; |
|
} |
|
|
|
Map<String, dynamic>? _effectiveInlineAttrs() { |
|
if (_activeInlineAttributes.isEmpty) return null; |
|
return <String, dynamic>{ |
|
for (final attrs in _activeInlineAttributes) |
|
for (final a in attrs) ...a.toJson(), |
|
}; |
|
} |
|
|
|
// Define trim text function to remove spaces from text elements in |
|
// accordance with Markdown specifications. |
|
String _trimTextToMdSpec(String text) { |
|
var result = text; |
|
// The leading spaces pattern is used to identify spaces |
|
// at the beginning of a line of text. |
|
final leadingSpacesPattern = RegExp('^ *'); |
|
|
|
// The soft line break is used to identify the spaces at the end of a line |
|
// of text and the leading spaces in the immediately following the line |
|
// of text. These spaces are removed in accordance with the Markdown |
|
// specification on soft line breaks when lines of text are joined. |
|
final softLineBreak = RegExp(r' ?\n *'); |
|
|
|
// Leading spaces following a hard line break are ignored. |
|
// https://github.github.com/gfm/#example-657 |
|
if (const ['p', 'ol', 'li', 'br'].contains(_lastTag)) { |
|
result = result.replaceAll(leadingSpacesPattern, ''); |
|
} |
|
|
|
if (softLineBreak.hasMatch(result)) { |
|
return result; |
|
} |
|
return result.replaceAll(softLineBreak, ' '); |
|
} |
|
|
|
Map<String, ElementToAttributeConvertor> _effectiveElementToInlineAttr() { |
|
return { |
|
...customElementToInlineAttribute, |
|
..._elementToInlineAttr, |
|
}; |
|
} |
|
|
|
bool _haveInlineAttrs(md.Element element) { |
|
if (_isInCodeblock && element.tag == 'code') return false; |
|
return _effectiveElementToInlineAttr().containsKey(element.tag); |
|
} |
|
|
|
List<Attribute<dynamic>> _toInlineAttributes(md.Element element) { |
|
List<Attribute<dynamic>>? result; |
|
if (!(_isInCodeblock && element.tag == 'code')) { |
|
result = _effectiveElementToInlineAttr()[element.tag]?.call(element); |
|
} |
|
if (result == null) { |
|
throw Exception( |
|
'Element $element cannot be converted to inline attribute'); |
|
} |
|
return result; |
|
} |
|
|
|
Map<String, ElementToAttributeConvertor> _effectiveElementToBlockAttr() { |
|
return { |
|
...customElementToBlockAttribute, |
|
..._elementToBlockAttr, |
|
}; |
|
} |
|
|
|
bool _haveBlockAttrs(md.Element element) { |
|
return _effectiveElementToBlockAttr().containsKey(element.tag); |
|
} |
|
|
|
List<Attribute<dynamic>> _toBlockAttributes(md.Element element) { |
|
final result = _effectiveElementToBlockAttr()[element.tag]?.call(element); |
|
if (result == null) { |
|
throw Exception( |
|
'Element $element cannot be converted to block attribute'); |
|
} |
|
return result; |
|
} |
|
|
|
Map<String, ElementToEmbeddableConvertor> _effectiveElementToEmbed() { |
|
return { |
|
...customElementToEmbeddable, |
|
..._elementToEmbed, |
|
}; |
|
} |
|
|
|
bool _isEmbedElement(md.Element element) => |
|
_effectiveElementToEmbed().containsKey(element.tag); |
|
|
|
Embeddable _toEmbeddable(md.Element element) { |
|
final result = |
|
_effectiveElementToEmbed()[element.tag]?.call(element.attributes); |
|
if (result == null) { |
|
throw Exception('Element $element cannot be converted to Embeddable'); |
|
} |
|
return result; |
|
} |
|
}
|
|
|