|
|
|
import 'dart:convert';
|
|
|
|
import 'dart:ui';
|
|
|
|
|
|
|
|
import 'package:collection/collection.dart';
|
|
|
|
import '../../../flutter_quill.dart';
|
|
|
|
import '../../../quill_delta.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;
|
|
|
|
}
|
|
|
|
}
|