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