pull/1566/head
parent
64ea5efc39
commit
5fcda94b59
57 changed files with 1185 additions and 117 deletions
@ -0,0 +1,5 @@ |
||||
library quill_markdown; |
||||
|
||||
export 'src/packages/quill_markdown/delta_to_markdown.dart'; |
||||
export 'src/packages/quill_markdown/embeddable_table_syntax.dart'; |
||||
export 'src/packages/quill_markdown/markdown_to_delta.dart'; |
@ -0,0 +1,11 @@ |
||||
import 'package:flutter_quill/flutter_quill.dart'; |
||||
|
||||
/// Custom attribute to save the language of codeblock |
||||
class CodeBlockLanguageAttribute extends Attribute<String?> { |
||||
/// @nodoc |
||||
const CodeBlockLanguageAttribute(String? value) |
||||
: super(attrKey, AttributeScope.ignore, value); |
||||
|
||||
/// attribute key |
||||
static const attrKey = 'x-md-codeblock-lang'; |
||||
} |
@ -0,0 +1,359 @@ |
||||
import 'dart:convert'; |
||||
import 'dart:ui'; |
||||
|
||||
import 'package:collection/collection.dart'; |
||||
import 'package:flutter_quill/flutter_quill.dart'; |
||||
import './custom_quill_attributes.dart'; |
||||
import './utils.dart'; |
||||
|
||||
class _AttributeHandler { |
||||
_AttributeHandler({ |
||||
this.beforeContent, |
||||
this.afterContent, |
||||
}); |
||||
|
||||
final void Function( |
||||
Attribute<Object?> attribute, |
||||
Node node, |
||||
StringSink output, |
||||
)? beforeContent; |
||||
|
||||
final void Function( |
||||
Attribute<Object?> attribute, |
||||
Node node, |
||||
StringSink output, |
||||
)? afterContent; |
||||
} |
||||
|
||||
/// Outputs [Embed] element as markdown. |
||||
typedef EmbedToMarkdown = void Function(Embed embed, StringSink out); |
||||
|
||||
extension on Object? { |
||||
T? asNullable<T>() { |
||||
final self = this; |
||||
return self == null ? null : self as T; |
||||
} |
||||
} |
||||
|
||||
/// Convertor from [Delta] to quill Markdown string. |
||||
class DeltaToMarkdown extends Converter<Delta, String> |
||||
implements _NodeVisitor<StringSink> { |
||||
/// |
||||
DeltaToMarkdown({ |
||||
Map<String, EmbedToMarkdown>? customEmbedHandlers, |
||||
}) { |
||||
if (customEmbedHandlers != null) { |
||||
_embedHandlers.addAll(customEmbedHandlers); |
||||
} |
||||
} |
||||
|
||||
@override |
||||
String convert(Delta input) { |
||||
final newDelta = transform(input); |
||||
|
||||
final quillDocument = Document.fromDelta(newDelta); |
||||
|
||||
final outBuffer = quillDocument.root.accept(this); |
||||
|
||||
return outBuffer.toString(); |
||||
} |
||||
|
||||
final Map<String, _AttributeHandler> _blockAttrsHandlers = { |
||||
Attribute.codeBlock.key: _AttributeHandler( |
||||
beforeContent: (attribute, node, output) { |
||||
var infoString = ''; |
||||
if (node.containsAttr(CodeBlockLanguageAttribute.attrKey)) { |
||||
infoString = node.getAttrValueOr( |
||||
CodeBlockLanguageAttribute.attrKey, |
||||
'', |
||||
); |
||||
} |
||||
if (infoString.isEmpty) { |
||||
final linesWithLang = (node as Block).children.where((child) => |
||||
child.containsAttr(CodeBlockLanguageAttribute.attrKey)); |
||||
if (linesWithLang.isNotEmpty) { |
||||
infoString = linesWithLang.first.getAttrValueOr( |
||||
CodeBlockLanguageAttribute.attrKey, |
||||
'or', |
||||
); |
||||
} |
||||
} |
||||
|
||||
output.writeln('```$infoString'); |
||||
}, |
||||
afterContent: (attribute, node, output) => output.writeln('```'), |
||||
), |
||||
}; |
||||
|
||||
final Map<String, _AttributeHandler> _lineAttrsHandlers = { |
||||
Attribute.header.key: _AttributeHandler( |
||||
beforeContent: (attribute, node, output) { |
||||
output |
||||
..write('#' * (attribute.value.asNullable<int>() ?? 1)) |
||||
..write(' '); |
||||
}, |
||||
), |
||||
Attribute.blockQuote.key: _AttributeHandler( |
||||
beforeContent: (attribute, node, output) => output.write('> '), |
||||
), |
||||
Attribute.list.key: _AttributeHandler( |
||||
beforeContent: (attribute, node, output) { |
||||
final indentLevel = node.getAttrValueOr(Attribute.indent.key, 0); |
||||
final isNumbered = attribute.value == 'ordered'; |
||||
output |
||||
..write((isNumbered ? ' ' : ' ') * indentLevel) |
||||
..write('${isNumbered ? '1.' : '-'} '); |
||||
}, |
||||
), |
||||
}; |
||||
|
||||
final Map<String, _AttributeHandler> _textAttrsHandlers = { |
||||
Attribute.italic.key: _AttributeHandler( |
||||
beforeContent: (attribute, node, output) { |
||||
if (node.previous?.containsAttr(attribute.key) != true) { |
||||
output.write('_'); |
||||
} |
||||
}, |
||||
afterContent: (attribute, node, output) { |
||||
if (node.next?.containsAttr(attribute.key) != true) { |
||||
output.write('_'); |
||||
} |
||||
}, |
||||
), |
||||
Attribute.bold.key: _AttributeHandler( |
||||
beforeContent: (attribute, node, output) { |
||||
if (node.previous?.containsAttr(attribute.key) != true) { |
||||
output.write('**'); |
||||
} |
||||
}, |
||||
afterContent: (attribute, node, output) { |
||||
if (node.next?.containsAttr(attribute.key) != true) { |
||||
output.write('**'); |
||||
} |
||||
}, |
||||
), |
||||
Attribute.strikeThrough.key: _AttributeHandler( |
||||
beforeContent: (attribute, node, output) { |
||||
if (node.previous?.containsAttr(attribute.key) != true) { |
||||
output.write('~~'); |
||||
} |
||||
}, |
||||
afterContent: (attribute, node, output) { |
||||
if (node.next?.containsAttr(attribute.key) != true) { |
||||
output.write('~~'); |
||||
} |
||||
}, |
||||
), |
||||
Attribute.inlineCode.key: _AttributeHandler( |
||||
beforeContent: (attribute, node, output) { |
||||
if (node.previous?.containsAttr(attribute.key) != true) { |
||||
output.write('`'); |
||||
} |
||||
}, |
||||
afterContent: (attribute, node, output) { |
||||
if (node.next?.containsAttr(attribute.key) != true) { |
||||
output.write('`'); |
||||
} |
||||
}, |
||||
), |
||||
Attribute.link.key: _AttributeHandler( |
||||
beforeContent: (attribute, node, output) { |
||||
if (node.previous?.containsAttr(attribute.key, attribute.value) != |
||||
true) { |
||||
output.write('['); |
||||
} |
||||
}, |
||||
afterContent: (attribute, node, output) { |
||||
if (node.next?.containsAttr(attribute.key, attribute.value) != true) { |
||||
output.write('](${attribute.value.asNullable<String>() ?? ''})'); |
||||
} |
||||
}, |
||||
), |
||||
}; |
||||
|
||||
final Map<String, EmbedToMarkdown> _embedHandlers = { |
||||
BlockEmbed.imageType: (embed, out) => out.write(''), |
||||
horizontalRuleType: (embed, out) { |
||||
// adds new line after it |
||||
// make --- separated so it doesn't get rendered as header |
||||
out.writeln('- - -'); |
||||
}, |
||||
}; |
||||
|
||||
@override |
||||
StringSink visitRoot(Root root, [StringSink? output]) { |
||||
final out = output ??= StringBuffer(); |
||||
for (final container in root.children) { |
||||
container.accept(this, out); |
||||
} |
||||
return out; |
||||
} |
||||
|
||||
@override |
||||
StringSink visitBlock(Block block, [StringSink? output]) { |
||||
final out = output ??= StringBuffer(); |
||||
_handleAttribute(_blockAttrsHandlers, block, output, () { |
||||
for (final line in block.children) { |
||||
line.accept(this, out); |
||||
} |
||||
}); |
||||
return out; |
||||
} |
||||
|
||||
@override |
||||
StringSink visitLine(Line line, [StringSink? output]) { |
||||
final out = output ??= StringBuffer(); |
||||
final style = line.style; |
||||
_handleAttribute(_lineAttrsHandlers, line, output, () { |
||||
for (final leaf in line.children) { |
||||
leaf.accept(this, out); |
||||
} |
||||
}); |
||||
if (style.isEmpty || |
||||
style.values.every((item) => item.scope != AttributeScope.block)) { |
||||
out.writeln(); |
||||
} |
||||
if (style.containsKey(Attribute.list.key) && |
||||
line.nextLine?.style.containsKey(Attribute.list.key) != true) { |
||||
out.writeln(); |
||||
} |
||||
out.writeln(); |
||||
return out; |
||||
} |
||||
|
||||
@override |
||||
StringSink visitText(QuillText text, [StringSink? output]) { |
||||
final out = output ??= StringBuffer(); |
||||
final style = text.style; |
||||
_handleAttribute( |
||||
_textAttrsHandlers, |
||||
text, |
||||
output, |
||||
() { |
||||
var content = text.value; |
||||
if (!(style.containsKey(Attribute.codeBlock.key) || |
||||
style.containsKey(Attribute.inlineCode.key) || |
||||
(text.parent?.style.containsKey(Attribute.codeBlock.key) ?? |
||||
false))) { |
||||
content = content.replaceAllMapped( |
||||
RegExp(r'[\\\`\*\_\{\}\[\]\(\)\#\+\-\.\!\>\<]'), (match) { |
||||
return '\\${match[0]}'; |
||||
}); |
||||
} |
||||
out.write(content); |
||||
}, |
||||
sortedAttrsBySpan: true, |
||||
); |
||||
return out; |
||||
} |
||||
|
||||
@override |
||||
StringSink visitEmbed(Embed embed, [StringSink? output]) { |
||||
final out = output ??= StringBuffer(); |
||||
|
||||
final type = embed.value.type; |
||||
|
||||
_embedHandlers[type]!.call(embed, out); |
||||
|
||||
return out; |
||||
} |
||||
|
||||
void _handleAttribute( |
||||
Map<String, _AttributeHandler> handlers, |
||||
Node node, |
||||
StringSink output, |
||||
VoidCallback contentHandler, { |
||||
bool sortedAttrsBySpan = false, |
||||
}) { |
||||
final attrs = sortedAttrsBySpan |
||||
? node.attrsSortedByLongestSpan() |
||||
: node.style.attributes.values.toList(); |
||||
final handlersToUse = attrs |
||||
.where((attr) => handlers.containsKey(attr.key)) |
||||
.map((attr) => MapEntry(attr.key, handlers[attr.key]!)) |
||||
.toList(); |
||||
for (final handlerEntry in handlersToUse) { |
||||
handlerEntry.value.beforeContent?.call( |
||||
node.style.attributes[handlerEntry.key]!, |
||||
node, |
||||
output, |
||||
); |
||||
} |
||||
contentHandler(); |
||||
for (final handlerEntry in handlersToUse.reversed) { |
||||
handlerEntry.value.afterContent?.call( |
||||
node.style.attributes[handlerEntry.key]!, |
||||
node, |
||||
output, |
||||
); |
||||
} |
||||
} |
||||
} |
||||
|
||||
//// AST with visitor |
||||
|
||||
abstract class _NodeVisitor<T> { |
||||
const _NodeVisitor._(); |
||||
|
||||
T visitRoot(Root root, [T? context]); |
||||
|
||||
T visitBlock(Block block, [T? context]); |
||||
|
||||
T visitLine(Line line, [T? context]); |
||||
|
||||
T visitText(QuillText text, [T? context]); |
||||
|
||||
T visitEmbed(Embed embed, [T? context]); |
||||
} |
||||
|
||||
extension _NodeX on Node { |
||||
T accept<T>(_NodeVisitor<T> visitor, [T? context]) { |
||||
switch (runtimeType) { |
||||
case Root _: |
||||
return visitor.visitRoot(this as Root, context); |
||||
case Block _: |
||||
return visitor.visitBlock(this as Block, context); |
||||
case Line _: |
||||
return visitor.visitLine(this as Line, context); |
||||
case QuillText _: |
||||
return visitor.visitText(this as QuillText, context); |
||||
case Embed _: |
||||
return visitor.visitEmbed(this as Embed, context); |
||||
} |
||||
throw Exception('Container of type $runtimeType cannot be visited'); |
||||
} |
||||
|
||||
bool containsAttr(String attributeKey, [Object? value]) { |
||||
if (!style.containsKey(attributeKey)) { |
||||
return false; |
||||
} |
||||
if (value == null) { |
||||
return true; |
||||
} |
||||
return style.attributes[attributeKey]!.value == value; |
||||
} |
||||
|
||||
T getAttrValueOr<T>(String attributeKey, T or) { |
||||
final attrs = style.attributes; |
||||
final attrValue = attrs[attributeKey]?.value as T?; |
||||
return attrValue ?? or; |
||||
} |
||||
|
||||
List<Attribute<Object?>> attrsSortedByLongestSpan() { |
||||
final attrCount = <Attribute<dynamic>, int>{}; |
||||
var node = this; |
||||
// get the first node |
||||
while (node.previous != null) { |
||||
node = node.previous!; |
||||
node.style.attributes.forEach((key, value) { |
||||
attrCount[value] = (attrCount[value] ?? 0) + 1; |
||||
}); |
||||
node = node.next!; |
||||
} |
||||
|
||||
final attrs = style.attributes.values.sorted( |
||||
(attr1, attr2) => attrCount[attr2]!.compareTo(attrCount[attr1]!)); |
||||
|
||||
return attrs; |
||||
} |
||||
} |
@ -0,0 +1,116 @@ |
||||
import 'package:charcode/charcode.dart'; |
||||
import 'package:flutter_quill/flutter_quill.dart' hide Node; |
||||
import 'package:markdown/markdown.dart'; |
||||
|
||||
/// Parses markdown table and saves the table markdown content into the element attributes. |
||||
class EmbeddableTableSyntax extends BlockSyntax { |
||||
/// @nodoc |
||||
const EmbeddableTableSyntax(); |
||||
static const _base = TableSyntax(); |
||||
|
||||
@override |
||||
bool canEndBlock(BlockParser parser) => false; |
||||
|
||||
@override |
||||
RegExp get pattern => _base.pattern; |
||||
|
||||
@override |
||||
bool canParse(BlockParser parser) => _base.canParse(parser); |
||||
|
||||
/// Parses a table into its three parts: |
||||
/// |
||||
/// * a head row of head cells (`<th>` cells) |
||||
/// * a divider of hyphens and pipes (not rendered) |
||||
/// * many body rows of body cells (`<td>` cells) |
||||
@override |
||||
Node? parse(BlockParser parser) { |
||||
final columnCount = _columnCount(parser.next!.content); |
||||
final headCells = _columnCount(parser.current.content); |
||||
final valBuf = |
||||
StringBuffer('${parser.current.content}\n${parser.next!.content}'); |
||||
parser.advance(); |
||||
if (columnCount != headCells) { |
||||
return null; |
||||
} |
||||
|
||||
// advance header and divider of hyphens. |
||||
parser.advance(); |
||||
|
||||
while (!parser.isDone && !BlockSyntax.isAtBlockEnd(parser)) { |
||||
valBuf.write('\n${parser.current.content}'); |
||||
parser.advance(); |
||||
} |
||||
|
||||
return Element.empty(EmbeddableTable.tableType) |
||||
..attributes['data'] = valBuf.toString(); |
||||
} |
||||
|
||||
int _columnCount(String line) { |
||||
final startIndex = _walkPastOpeningPipe(line); |
||||
|
||||
var endIndex = line.length - 1; |
||||
while (endIndex > 0) { |
||||
final ch = line.codeUnitAt(endIndex); |
||||
if (ch == $pipe) { |
||||
endIndex--; |
||||
break; |
||||
} |
||||
if (ch != $space && ch != $tab) { |
||||
break; |
||||
} |
||||
endIndex--; |
||||
} |
||||
|
||||
return line.substring(startIndex, endIndex + 1).split('|').length; |
||||
} |
||||
|
||||
int _walkPastWhitespace(String line, int index) { |
||||
while (index < line.length) { |
||||
final ch = line.codeUnitAt(index); |
||||
if (ch != $space && ch != $tab) { |
||||
break; |
||||
} |
||||
//ignore: parameter_assignments |
||||
index++; |
||||
} |
||||
return index; |
||||
} |
||||
|
||||
int _walkPastOpeningPipe(String line) { |
||||
var index = 0; |
||||
while (index < line.length) { |
||||
final ch = line.codeUnitAt(index); |
||||
if (ch == $pipe) { |
||||
index++; |
||||
index = _walkPastWhitespace(line, index); |
||||
} |
||||
if (ch != $space && ch != $tab) { |
||||
// No leading pipe. |
||||
break; |
||||
} |
||||
index++; |
||||
} |
||||
return index; |
||||
} |
||||
} |
||||
|
||||
/// An [Embeddable] table that can used to render a table in quill_editor |
||||
class EmbeddableTable extends BlockEmbed { |
||||
/// @nodoc |
||||
EmbeddableTable(String data) : super(tableType, data); |
||||
|
||||
/// [Embeddable] type |
||||
static const tableType = 'x-embed-table'; |
||||
|
||||
/// Create from markdown. |
||||
//ignore: prefer_constructors_over_static_methods |
||||
static EmbeddableTable fromMdSyntax(Map<String, String> attributes) => |
||||
EmbeddableTable(attributes['data']!); |
||||
|
||||
/// Outputs table markdown to output. |
||||
static void toMdSyntax(Embed embed, StringSink out) { |
||||
out |
||||
..writeln(embed.value.data) |
||||
..writeln(); |
||||
} |
||||
} |
@ -0,0 +1,424 @@ |
||||
import 'dart:collection'; |
||||
import 'dart:convert'; |
||||
|
||||
import 'package:collection/collection.dart'; |
||||
import 'package:flutter_quill/flutter_quill.dart'; |
||||
import 'package:markdown/markdown.dart' as md; |
||||
|
||||
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()); |
||||
} |
||||
|
||||
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; |
||||
} |
||||
} |
@ -0,0 +1,59 @@ |
||||
//ignore_for_file: cast_nullable_to_non_nullable |
||||
import 'package:flutter_quill/flutter_quill.dart'; |
||||
|
||||
import './embeddable_table_syntax.dart'; |
||||
|
||||
/// To allow embedding images/videos in horizontal mode. |
||||
const BlockEmbed horizontalRule = BlockEmbed(horizontalRuleType, 'hr'); |
||||
|
||||
/// Necessary for [horizontalRule] BlockEmbed. |
||||
const String horizontalRuleType = 'divider'; |
||||
|
||||
/// Format the passed delta to ensure that there is new line |
||||
/// after embeds |
||||
Delta transform(Delta delta) { |
||||
final res = Delta(); |
||||
final ops = delta.toList(); |
||||
for (var i = 0; i < ops.length; i++) { |
||||
final op = ops[i]; |
||||
res.push(op); |
||||
autoAppendNewlineAfterEmbeddable(i, ops, op, res, [ |
||||
'hr', |
||||
EmbeddableTable.tableType, |
||||
]); |
||||
} |
||||
return res; |
||||
} |
||||
|
||||
/// Appends new line after embeds if needed |
||||
void autoAppendNewlineAfterEmbeddable( |
||||
int i, |
||||
List<Operation> ops, |
||||
Operation op, |
||||
Delta res, |
||||
List<String> types, |
||||
) { |
||||
final nextOpIsEmbed = i + 1 < ops.length && |
||||
ops[i + 1].isInsert && |
||||
ops[i + 1].data is Map && |
||||
types.any((type) => (ops[i + 1].data as Map).containsKey(type)); |
||||
|
||||
if (nextOpIsEmbed && |
||||
op.data is String && |
||||
(op.data as String).isNotEmpty && |
||||
!(op.data as String).endsWith('\n')) { |
||||
res.push(Operation.insert('\n')); |
||||
} |
||||
// embed could be image or video |
||||
final opInsertEmbed = op.isInsert && |
||||
op.data is Map && |
||||
types.any((type) => (op.data as Map).containsKey(type)); |
||||
final nextOpIsLineBreak = i + 1 < ops.length && |
||||
ops[i + 1].isInsert && |
||||
ops[i + 1].data is String && |
||||
(ops[i + 1].data as String).startsWith('\n'); |
||||
if (opInsertEmbed && (i + 1 == ops.length - 1 || !nextOpIsLineBreak)) { |
||||
// automatically append '\n' for embeddable |
||||
res.push(Operation.insert('\n')); |
||||
} |
||||
} |
Loading…
Reference in new issue