mirror of
https://github.com/AppFlowy-IO/AppFlowy.git
synced 2024-08-30 18:12:39 +00:00
[flutter]: add markdown parser
This commit is contained in:
parent
345a695bce
commit
0d362a4781
@ -1,4 +1,5 @@
|
||||
import 'package:app_flowy/workspace/domain/i_share.dart';
|
||||
import 'package:app_flowy/workspace/infrastructure/markdown/delta_markdown.dart';
|
||||
import 'package:flowy_sdk/protobuf/flowy-workspace-infra/export.pb.dart';
|
||||
import 'package:flowy_sdk/protobuf/flowy-workspace-infra/view_create.pb.dart';
|
||||
import 'package:flowy_sdk/protobuf/flowy-workspace/errors.pb.dart';
|
||||
@ -16,7 +17,7 @@ class DocShareBloc extends Bloc<DocShareEvent, DocShareState> {
|
||||
shareMarkdown: (ShareMarkdown value) async {
|
||||
await shareManager.exportMarkdown(view.id).then((result) {
|
||||
result.fold(
|
||||
(value) => emit(DocShareState.finish(left(value))),
|
||||
(value) => emit(DocShareState.finish(left(_convertDeltaToMarkdown(value)))),
|
||||
(error) => emit(DocShareState.finish(right(error))),
|
||||
);
|
||||
});
|
||||
@ -28,6 +29,12 @@ class DocShareBloc extends Bloc<DocShareEvent, DocShareState> {
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
ExportData _convertDeltaToMarkdown(ExportData value) {
|
||||
final result = deltaToMarkdown(value.data);
|
||||
value.data = result;
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
@freezed
|
||||
|
@ -0,0 +1,30 @@
|
||||
library delta_markdown;
|
||||
|
||||
import 'dart:convert';
|
||||
|
||||
import 'src/delta_markdown_decoder.dart';
|
||||
import 'src/delta_markdown_encoder.dart';
|
||||
import 'src/version.dart';
|
||||
|
||||
const version = packageVersion;
|
||||
|
||||
/// Codec used to convert between Markdown and Quill deltas.
|
||||
const DeltaMarkdownCodec _kCodec = DeltaMarkdownCodec();
|
||||
|
||||
String markdownToDelta(String markdown) {
|
||||
return _kCodec.decode(markdown);
|
||||
}
|
||||
|
||||
String deltaToMarkdown(String delta) {
|
||||
return _kCodec.encode(delta);
|
||||
}
|
||||
|
||||
class DeltaMarkdownCodec extends Codec<String, String> {
|
||||
const DeltaMarkdownCodec();
|
||||
|
||||
@override
|
||||
Converter<String, String> get decoder => DeltaMarkdownDecoder();
|
||||
|
||||
@override
|
||||
Converter<String, String> get encoder => DeltaMarkdownEncoder();
|
||||
}
|
113
app_flowy/lib/workspace/infrastructure/markdown/src/ast.dart
Normal file
113
app_flowy/lib/workspace/infrastructure/markdown/src/ast.dart
Normal file
@ -0,0 +1,113 @@
|
||||
// Copyright (c) 2012, the Dart project authors. Please see the AUTHORS file
|
||||
// for details. All rights reserved. Use of this source code is governed by a
|
||||
// BSD-style license that can be found in the LICENSE file.
|
||||
|
||||
typedef Resolver = Node? Function(String name, [String? title]);
|
||||
|
||||
/// Base class for any AST item.
|
||||
///
|
||||
/// Roughly corresponds to Node in the DOM. Will be either an Element or Text.
|
||||
class Node {
|
||||
void accept(NodeVisitor visitor) {}
|
||||
|
||||
bool isToplevel = false;
|
||||
|
||||
String? get textContent {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// A named tag that can contain other nodes.
|
||||
class Element extends Node {
|
||||
/// Instantiates a [tag] Element with [children].
|
||||
Element(this.tag, this.children) : attributes = <String, String>{};
|
||||
|
||||
/// Instantiates an empty, self-closing [tag] Element.
|
||||
Element.empty(this.tag)
|
||||
: children = null,
|
||||
attributes = {};
|
||||
|
||||
/// Instantiates a [tag] Element with no [children].
|
||||
Element.withTag(this.tag)
|
||||
: children = [],
|
||||
attributes = {};
|
||||
|
||||
/// Instantiates a [tag] Element with a single Text child.
|
||||
Element.text(this.tag, String text)
|
||||
: children = [Text(text)],
|
||||
attributes = {};
|
||||
|
||||
final String tag;
|
||||
final List<Node>? children;
|
||||
final Map<String, String> attributes;
|
||||
String? generatedId;
|
||||
|
||||
/// Whether this element is self-closing.
|
||||
bool get isEmpty => children == null;
|
||||
|
||||
@override
|
||||
void accept(NodeVisitor visitor) {
|
||||
if (visitor.visitElementBefore(this)) {
|
||||
if (children != null) {
|
||||
for (final child in children!) {
|
||||
child.accept(visitor);
|
||||
}
|
||||
}
|
||||
visitor.visitElementAfter(this);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
String get textContent => children == null
|
||||
? ''
|
||||
: children!.map((child) => child.textContent).join();
|
||||
}
|
||||
|
||||
/// A plain text element.
|
||||
class Text extends Node {
|
||||
Text(this.text);
|
||||
|
||||
final String text;
|
||||
|
||||
@override
|
||||
void accept(NodeVisitor visitor) => visitor.visitText(this);
|
||||
|
||||
@override
|
||||
String get textContent => text;
|
||||
}
|
||||
|
||||
/// Inline content that has not been parsed into inline nodes (strong, links,
|
||||
/// etc).
|
||||
///
|
||||
/// These placeholder nodes should only remain in place while the block nodes
|
||||
/// of a document are still being parsed, in order to gather all reference link
|
||||
/// definitions.
|
||||
class UnparsedContent extends Node {
|
||||
UnparsedContent(this.textContent);
|
||||
|
||||
@override
|
||||
final String textContent;
|
||||
|
||||
@override
|
||||
void accept(NodeVisitor visitor);
|
||||
}
|
||||
|
||||
/// Visitor pattern for the AST.
|
||||
///
|
||||
/// Renderers or other AST transformers should implement this.
|
||||
abstract class NodeVisitor {
|
||||
/// Called when a Text node has been reached.
|
||||
void visitText(Text text);
|
||||
|
||||
/// Called when an Element has been reached, before its children have been
|
||||
/// visited.
|
||||
///
|
||||
/// Returns `false` to skip its children.
|
||||
bool visitElementBefore(Element element);
|
||||
|
||||
/// Called when an Element has been reached, after its children have been
|
||||
/// visited.
|
||||
///
|
||||
/// Will not be called if [visitElementBefore] returns `false`.
|
||||
void visitElementAfter(Element element);
|
||||
}
|
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,255 @@
|
||||
import 'dart:collection';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter_quill/models/documents/attribute.dart';
|
||||
import 'package:flutter_quill/models/quill_delta.dart';
|
||||
|
||||
import 'ast.dart' as ast;
|
||||
import 'document.dart';
|
||||
|
||||
class DeltaMarkdownDecoder extends Converter<String, String> {
|
||||
@override
|
||||
String convert(String input) {
|
||||
final lines = input.replaceAll('\r\n', '\n').split('\n');
|
||||
|
||||
final markdownDocument = Document().parseLines(lines);
|
||||
|
||||
return jsonEncode(_DeltaVisitor().convert(markdownDocument).toJson());
|
||||
}
|
||||
}
|
||||
|
||||
class _DeltaVisitor implements ast.NodeVisitor {
|
||||
static final _blockTags =
|
||||
RegExp('h1|h2|h3|h4|h5|h6|hr|pre|ul|ol|blockquote|p|pre');
|
||||
|
||||
static final _embedTags = RegExp('hr|img');
|
||||
|
||||
late Delta delta;
|
||||
|
||||
late Queue<Attribute> activeInlineAttributes;
|
||||
Attribute? activeBlockAttribute;
|
||||
late Set<String> uniqueIds;
|
||||
|
||||
ast.Element? previousElement;
|
||||
late ast.Element previousToplevelElement;
|
||||
|
||||
Delta convert(List<ast.Node> nodes) {
|
||||
delta = Delta();
|
||||
activeInlineAttributes = Queue<Attribute>();
|
||||
uniqueIds = <String>{};
|
||||
|
||||
for (final node in nodes) {
|
||||
node.accept(this);
|
||||
}
|
||||
|
||||
// Ensure the delta ends with a newline.
|
||||
if (delta.length > 0 && delta.last.value != '\n') {
|
||||
delta.insert('\n', activeBlockAttribute?.toJson());
|
||||
}
|
||||
|
||||
return delta;
|
||||
}
|
||||
|
||||
@override
|
||||
void visitText(ast.Text text) {
|
||||
// Remove trailing newline
|
||||
//final lines = text.text.trim().split('\n');
|
||||
|
||||
/*
|
||||
final attributes = Map<String, dynamic>();
|
||||
for (final attr in activeInlineAttributes) {
|
||||
attributes.addAll(attr.toJson());
|
||||
}
|
||||
|
||||
for (final l in lines) {
|
||||
delta.insert(l, attributes);
|
||||
delta.insert('\n', activeBlockAttribute.toJson());
|
||||
}*/
|
||||
|
||||
final str = text.text;
|
||||
//if (str.endsWith('\n')) str = str.substring(0, str.length - 1);
|
||||
|
||||
final attributes = <String, dynamic>{};
|
||||
for (final attr in activeInlineAttributes) {
|
||||
attributes.addAll(attr.toJson());
|
||||
}
|
||||
|
||||
var newlineIndex = str.indexOf('\n');
|
||||
var startIndex = 0;
|
||||
while (newlineIndex != -1) {
|
||||
final previousText = str.substring(startIndex, newlineIndex);
|
||||
if (previousText.isNotEmpty) {
|
||||
delta.insert(previousText, attributes.isNotEmpty ? attributes : null);
|
||||
}
|
||||
delta.insert('\n', activeBlockAttribute?.toJson());
|
||||
|
||||
startIndex = newlineIndex + 1;
|
||||
newlineIndex = str.indexOf('\n', newlineIndex + 1);
|
||||
}
|
||||
|
||||
if (startIndex < str.length) {
|
||||
final lastStr = str.substring(startIndex);
|
||||
delta.insert(lastStr, attributes.isNotEmpty ? attributes : null);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
bool visitElementBefore(ast.Element element) {
|
||||
// Hackish. Separate block-level elements with newlines.
|
||||
final attr = _tagToAttribute(element);
|
||||
|
||||
if (delta.isNotEmpty && _blockTags.firstMatch(element.tag) != null) {
|
||||
if (element.isToplevel) {
|
||||
// If the last active block attribute is not a list, we need to finish
|
||||
// it off.
|
||||
if (previousToplevelElement.tag != 'ul' &&
|
||||
previousToplevelElement.tag != 'ol' &&
|
||||
previousToplevelElement.tag != 'pre' &&
|
||||
previousToplevelElement.tag != 'hr') {
|
||||
delta.insert('\n', activeBlockAttribute?.toJson());
|
||||
}
|
||||
|
||||
// Only separate the blocks if both are paragraphs.
|
||||
//
|
||||
// TODO(kolja): Determine which behavior we really want here.
|
||||
// We can either insert an additional newline or just have the
|
||||
// paragraphs as single lines. Zefyr will by default render two lines
|
||||
// are different paragraphs so for now we will not add an additonal
|
||||
// newline here.
|
||||
//
|
||||
// if (previousToplevelElement != null &&
|
||||
// previousToplevelElement.tag == 'p' &&
|
||||
// element.tag == 'p') {
|
||||
// delta.insert('\n');
|
||||
// }
|
||||
} else if (element.tag == 'p' &&
|
||||
previousElement != null &&
|
||||
!previousElement!.isToplevel &&
|
||||
!previousElement!.children!.contains(element)) {
|
||||
// Here we have two children of the same toplevel element. These need
|
||||
// to be separated by additional newlines.
|
||||
|
||||
delta
|
||||
// Finish off the last lower-level block.
|
||||
..insert('\n', activeBlockAttribute?.toJson())
|
||||
// Add an empty line between the lower-level blocks.
|
||||
..insert('\n', activeBlockAttribute?.toJson());
|
||||
}
|
||||
}
|
||||
|
||||
// Keep track of the top-level block attribute.
|
||||
if (element.isToplevel && element.tag != 'hr') {
|
||||
// Hacky solution for horizontal rule so that the attribute is not added
|
||||
// to the line feed at the end of the line.
|
||||
activeBlockAttribute = attr;
|
||||
}
|
||||
|
||||
if (_embedTags.firstMatch(element.tag) != null) {
|
||||
// We write out the element here since the embed has no children or
|
||||
// content.
|
||||
delta.insert(attr!.toJson());
|
||||
} else if (_blockTags.firstMatch(element.tag) == null && attr != null) {
|
||||
activeInlineAttributes.addLast(attr);
|
||||
}
|
||||
|
||||
previousElement = element;
|
||||
if (element.isToplevel) {
|
||||
previousToplevelElement = element;
|
||||
}
|
||||
|
||||
if (element.isEmpty) {
|
||||
// Empty element like <hr/>.
|
||||
//buffer.write(' />');
|
||||
|
||||
if (element.tag == 'br') {
|
||||
delta.insert('\n');
|
||||
}
|
||||
|
||||
return false;
|
||||
} else {
|
||||
//buffer.write('>');
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void visitElementAfter(ast.Element element) {
|
||||
if (element.tag == 'li' &&
|
||||
(previousToplevelElement.tag == 'ol' ||
|
||||
previousToplevelElement.tag == 'ul')) {
|
||||
delta.insert('\n', activeBlockAttribute?.toJson());
|
||||
}
|
||||
|
||||
final attr = _tagToAttribute(element);
|
||||
if (attr == null || !attr.isInline || activeInlineAttributes.last != attr) {
|
||||
return;
|
||||
}
|
||||
activeInlineAttributes.removeLast();
|
||||
|
||||
// Always keep track of the last element.
|
||||
// This becomes relevant if we have something like
|
||||
//
|
||||
// <ul>
|
||||
// <li>...</li>
|
||||
// <li>...</li>
|
||||
// </ul>
|
||||
previousElement = element;
|
||||
}
|
||||
|
||||
/// Uniquifies an id generated from text.
|
||||
String uniquifyId(String id) {
|
||||
if (!uniqueIds.contains(id)) {
|
||||
uniqueIds.add(id);
|
||||
return id;
|
||||
}
|
||||
|
||||
var suffix = 2;
|
||||
var suffixedId = '$id-$suffix';
|
||||
while (uniqueIds.contains(suffixedId)) {
|
||||
suffixedId = '$id-${suffix++}';
|
||||
}
|
||||
uniqueIds.add(suffixedId);
|
||||
return suffixedId;
|
||||
}
|
||||
|
||||
Attribute? _tagToAttribute(ast.Element el) {
|
||||
switch (el.tag) {
|
||||
case 'em':
|
||||
return Attribute.italic;
|
||||
case 'strong':
|
||||
return Attribute.bold;
|
||||
case 'ul':
|
||||
return Attribute.ul;
|
||||
case 'ol':
|
||||
return Attribute.ol;
|
||||
case 'pre':
|
||||
return Attribute.codeBlock;
|
||||
case 'blockquote':
|
||||
return Attribute.blockQuote;
|
||||
case 'h1':
|
||||
return Attribute.h1;
|
||||
case 'h2':
|
||||
return Attribute.h2;
|
||||
case 'h3':
|
||||
return Attribute.h3;
|
||||
case 'a':
|
||||
final href = el.attributes['href'];
|
||||
return LinkAttribute(href);
|
||||
case 'img':
|
||||
final href = el.attributes['src'];
|
||||
return ImageAttribute(href);
|
||||
case 'hr':
|
||||
return DividerAttribute();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
class ImageAttribute extends Attribute<String?> {
|
||||
ImageAttribute(String? val) : super('image', AttributeScope.EMBEDS, val);
|
||||
}
|
||||
|
||||
class DividerAttribute extends Attribute<String?> {
|
||||
DividerAttribute() : super('divider', AttributeScope.EMBEDS, 'hr');
|
||||
}
|
@ -0,0 +1,272 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:collection/collection.dart' show IterableExtension;
|
||||
import 'package:flutter_quill/models/documents/attribute.dart';
|
||||
import 'package:flutter_quill/models/documents/nodes/embed.dart';
|
||||
import 'package:flutter_quill/models/documents/style.dart';
|
||||
import 'package:flutter_quill/models/quill_delta.dart';
|
||||
|
||||
class DeltaMarkdownEncoder extends Converter<String, String> {
|
||||
static const _lineFeedAsciiCode = 0x0A;
|
||||
|
||||
late StringBuffer markdownBuffer;
|
||||
late StringBuffer lineBuffer;
|
||||
|
||||
Attribute? currentBlockStyle;
|
||||
late Style currentInlineStyle;
|
||||
|
||||
late List<String> currentBlockLines;
|
||||
|
||||
/// Converts the [input] delta to Markdown.
|
||||
@override
|
||||
String convert(String input) {
|
||||
markdownBuffer = StringBuffer();
|
||||
lineBuffer = StringBuffer();
|
||||
currentInlineStyle = Style();
|
||||
currentBlockLines = <String>[];
|
||||
|
||||
final inputJson = jsonDecode(input) as List<dynamic>?;
|
||||
if (inputJson is! List<dynamic>) {
|
||||
throw ArgumentError('Unexpected formatting of the input delta string.');
|
||||
}
|
||||
final delta = Delta.fromJson(inputJson);
|
||||
final iterator = DeltaIterator(delta);
|
||||
|
||||
while (iterator.hasNext) {
|
||||
final operation = iterator.next();
|
||||
|
||||
if (operation.data is String) {
|
||||
final operationData = operation.data as String;
|
||||
|
||||
if (!operationData.contains('\n')) {
|
||||
_handleInline(lineBuffer, operationData, operation.attributes);
|
||||
} else {
|
||||
_handleLine(operationData, operation.attributes);
|
||||
}
|
||||
} else if (operation.data is Map<String, dynamic>) {
|
||||
_handleEmbed(operation.data as Map<String, dynamic>);
|
||||
} else {
|
||||
throw ArgumentError('Unexpected formatting of the input delta string.');
|
||||
}
|
||||
}
|
||||
|
||||
_handleBlock(currentBlockStyle); // Close the last block
|
||||
|
||||
return markdownBuffer.toString();
|
||||
}
|
||||
|
||||
void _handleInline(
|
||||
StringBuffer buffer,
|
||||
String text,
|
||||
Map<String, dynamic>? attributes,
|
||||
) {
|
||||
final style = Style.fromJson(attributes);
|
||||
|
||||
// First close any current styles if needed
|
||||
final markedForRemoval = <Attribute>[];
|
||||
// Close the styles in reverse order, e.g. **_ for _**Test**_.
|
||||
for (final value
|
||||
in currentInlineStyle.attributes.values.toList().reversed) {
|
||||
// TODO(tillf): Is block correct?
|
||||
if (value.scope == AttributeScope.BLOCK) {
|
||||
continue;
|
||||
}
|
||||
if (style.containsKey(value.key)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
final padding = _trimRight(buffer);
|
||||
_writeAttribute(buffer, value, close: true);
|
||||
if (padding.isNotEmpty) {
|
||||
buffer.write(padding);
|
||||
}
|
||||
markedForRemoval.add(value);
|
||||
}
|
||||
|
||||
// Make sure to remove all attributes that are marked for removal.
|
||||
for (final value in markedForRemoval) {
|
||||
currentInlineStyle.attributes.removeWhere((_, v) => v == value);
|
||||
}
|
||||
|
||||
// Now open any new styles.
|
||||
for (final attribute in style.attributes.values) {
|
||||
// TODO(tillf): Is block correct?
|
||||
if (attribute.scope == AttributeScope.BLOCK) {
|
||||
continue;
|
||||
}
|
||||
if (currentInlineStyle.containsKey(attribute.key)) {
|
||||
continue;
|
||||
}
|
||||
final originalText = text;
|
||||
text = text.trimLeft();
|
||||
final padding = ' ' * (originalText.length - text.length);
|
||||
if (padding.isNotEmpty) {
|
||||
buffer.write(padding);
|
||||
}
|
||||
_writeAttribute(buffer, attribute);
|
||||
}
|
||||
|
||||
// Write the text itself
|
||||
buffer.write(text);
|
||||
currentInlineStyle = style;
|
||||
}
|
||||
|
||||
void _handleLine(String data, Map<String, dynamic>? attributes) {
|
||||
final span = StringBuffer();
|
||||
|
||||
for (var i = 0; i < data.length; i++) {
|
||||
if (data.codeUnitAt(i) == _lineFeedAsciiCode) {
|
||||
if (span.isNotEmpty) {
|
||||
// Write the span if it's not empty.
|
||||
_handleInline(lineBuffer, span.toString(), attributes);
|
||||
}
|
||||
// Close any open inline styles.
|
||||
_handleInline(lineBuffer, '', null);
|
||||
|
||||
final lineBlock = Style.fromJson(attributes)
|
||||
.attributes
|
||||
.values
|
||||
.singleWhereOrNull((a) => a.scope == AttributeScope.BLOCK);
|
||||
|
||||
if (lineBlock == currentBlockStyle) {
|
||||
currentBlockLines.add(lineBuffer.toString());
|
||||
} else {
|
||||
_handleBlock(currentBlockStyle);
|
||||
currentBlockLines
|
||||
..clear()
|
||||
..add(lineBuffer.toString());
|
||||
|
||||
currentBlockStyle = lineBlock;
|
||||
}
|
||||
lineBuffer.clear();
|
||||
|
||||
span.clear();
|
||||
} else {
|
||||
span.writeCharCode(data.codeUnitAt(i));
|
||||
}
|
||||
}
|
||||
|
||||
// Remaining span
|
||||
if (span.isNotEmpty) {
|
||||
_handleInline(lineBuffer, span.toString(), attributes);
|
||||
}
|
||||
}
|
||||
|
||||
void _handleEmbed(Map<String, dynamic> data) {
|
||||
final embed = BlockEmbed(data.keys.first, data.values.first as String);
|
||||
|
||||
if (embed.type == 'image') {
|
||||
_writeEmbedTag(lineBuffer, embed);
|
||||
_writeEmbedTag(lineBuffer, embed, close: true);
|
||||
} else if (embed.type == 'divider') {
|
||||
_writeEmbedTag(lineBuffer, embed);
|
||||
_writeEmbedTag(lineBuffer, embed, close: true);
|
||||
}
|
||||
}
|
||||
|
||||
void _handleBlock(Attribute? blockStyle) {
|
||||
if (currentBlockLines.isEmpty) {
|
||||
return; // Empty block
|
||||
}
|
||||
|
||||
// If there was a block before this one, add empty line between the blocks
|
||||
if (markdownBuffer.isNotEmpty) {
|
||||
markdownBuffer.writeln();
|
||||
}
|
||||
|
||||
if (blockStyle == null) {
|
||||
markdownBuffer
|
||||
..write(currentBlockLines.join('\n'))
|
||||
..writeln();
|
||||
} else if (blockStyle == Attribute.codeBlock) {
|
||||
_writeAttribute(markdownBuffer, blockStyle);
|
||||
markdownBuffer.write(currentBlockLines.join('\n'));
|
||||
_writeAttribute(markdownBuffer, blockStyle, close: true);
|
||||
markdownBuffer.writeln();
|
||||
} else {
|
||||
// Dealing with lists or a quote.
|
||||
for (final line in currentBlockLines) {
|
||||
_writeBlockTag(markdownBuffer, blockStyle);
|
||||
markdownBuffer
|
||||
..write(line)
|
||||
..writeln();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
String _trimRight(StringBuffer buffer) {
|
||||
final text = buffer.toString();
|
||||
if (!text.endsWith(' ')) {
|
||||
return '';
|
||||
}
|
||||
|
||||
final result = text.trimRight();
|
||||
buffer
|
||||
..clear()
|
||||
..write(result);
|
||||
return ' ' * (text.length - result.length);
|
||||
}
|
||||
|
||||
void _writeAttribute(
|
||||
StringBuffer buffer,
|
||||
Attribute attribute, {
|
||||
bool close = false,
|
||||
}) {
|
||||
if (attribute.key == Attribute.bold.key) {
|
||||
buffer.write('**');
|
||||
} else if (attribute.key == Attribute.italic.key) {
|
||||
buffer.write('_');
|
||||
} else if (attribute.key == Attribute.link.key) {
|
||||
buffer.write(!close ? '[' : '](${attribute.value})');
|
||||
} else if (attribute == Attribute.codeBlock) {
|
||||
buffer.write(!close ? '```\n' : '\n```');
|
||||
} else {
|
||||
throw ArgumentError('Cannot handle $attribute');
|
||||
}
|
||||
}
|
||||
|
||||
void _writeBlockTag(
|
||||
StringBuffer buffer,
|
||||
Attribute block, {
|
||||
bool close = false,
|
||||
}) {
|
||||
if (close) {
|
||||
return; // no close tag needed for simple blocks.
|
||||
}
|
||||
|
||||
if (block == Attribute.blockQuote) {
|
||||
buffer.write('> ');
|
||||
} else if (block == Attribute.ul) {
|
||||
buffer.write('* ');
|
||||
} else if (block == Attribute.ol) {
|
||||
buffer.write('1. ');
|
||||
} else if (block.key == Attribute.h1.key && block.value == 1) {
|
||||
buffer.write('# ');
|
||||
} else if (block.key == Attribute.h2.key && block.value == 2) {
|
||||
buffer.write('## ');
|
||||
} else if (block.key == Attribute.h3.key && block.value == 3) {
|
||||
buffer.write('### ');
|
||||
} else {
|
||||
throw ArgumentError('Cannot handle block $block');
|
||||
}
|
||||
}
|
||||
|
||||
void _writeEmbedTag(
|
||||
StringBuffer buffer,
|
||||
BlockEmbed embed, {
|
||||
bool close = false,
|
||||
}) {
|
||||
const kImageType = 'image';
|
||||
const kDividerType = 'divider';
|
||||
|
||||
if (embed.type == kImageType) {
|
||||
if (close) {
|
||||
buffer.write('](${embed.data})');
|
||||
} else {
|
||||
buffer.write('![');
|
||||
}
|
||||
} else if (embed.type == kDividerType && close) {
|
||||
buffer.write('\n---\n\n');
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,88 @@
|
||||
// Copyright (c) 2017, the Dart project authors. Please see the AUTHORS file
|
||||
// for details. All rights reserved. Use of this source code is governed by a
|
||||
// BSD-style license that can be found in the LICENSE file.
|
||||
|
||||
import 'ast.dart';
|
||||
import 'block_parser.dart';
|
||||
import 'extension_set.dart';
|
||||
import 'inline_parser.dart';
|
||||
|
||||
/// Maintains the context needed to parse a Markdown document.
|
||||
class Document {
|
||||
Document({
|
||||
Iterable<BlockSyntax>? blockSyntaxes,
|
||||
Iterable<InlineSyntax>? inlineSyntaxes,
|
||||
ExtensionSet? extensionSet,
|
||||
this.linkResolver,
|
||||
this.imageLinkResolver,
|
||||
}) : extensionSet = extensionSet ?? ExtensionSet.commonMark {
|
||||
_blockSyntaxes
|
||||
..addAll(blockSyntaxes ?? [])
|
||||
..addAll(this.extensionSet.blockSyntaxes);
|
||||
_inlineSyntaxes
|
||||
..addAll(inlineSyntaxes ?? [])
|
||||
..addAll(this.extensionSet.inlineSyntaxes);
|
||||
}
|
||||
|
||||
final Map<String, LinkReference> linkReferences = <String, LinkReference>{};
|
||||
final ExtensionSet extensionSet;
|
||||
final Resolver? linkResolver;
|
||||
final Resolver? imageLinkResolver;
|
||||
final _blockSyntaxes = <BlockSyntax>{};
|
||||
final _inlineSyntaxes = <InlineSyntax>{};
|
||||
|
||||
Iterable<BlockSyntax> get blockSyntaxes => _blockSyntaxes;
|
||||
Iterable<InlineSyntax> get inlineSyntaxes => _inlineSyntaxes;
|
||||
|
||||
/// Parses the given [lines] of Markdown to a series of AST nodes.
|
||||
List<Node> parseLines(List<String> lines) {
|
||||
final nodes = BlockParser(lines, this).parseLines();
|
||||
// Make sure to mark the top level nodes as such.
|
||||
for (final n in nodes) {
|
||||
n.isToplevel = true;
|
||||
}
|
||||
_parseInlineContent(nodes);
|
||||
return nodes;
|
||||
}
|
||||
|
||||
/// Parses the given inline Markdown [text] to a series of AST nodes.
|
||||
List<Node>? parseInline(String text) => InlineParser(text, this).parse();
|
||||
|
||||
void _parseInlineContent(List<Node> nodes) {
|
||||
for (var i = 0; i < nodes.length; i++) {
|
||||
final node = nodes[i];
|
||||
if (node is UnparsedContent) {
|
||||
final inlineNodes = parseInline(node.textContent)!;
|
||||
nodes
|
||||
..removeAt(i)
|
||||
..insertAll(i, inlineNodes);
|
||||
i += inlineNodes.length - 1;
|
||||
} else if (node is Element && node.children != null) {
|
||||
_parseInlineContent(node.children!);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A [link reference
|
||||
/// definition](http://spec.commonmark.org/0.28/#link-reference-definitions).
|
||||
class LinkReference {
|
||||
/// Construct a [LinkReference], with all necessary fields.
|
||||
///
|
||||
/// If the parsed link reference definition does not include a title, use
|
||||
/// `null` for the [title] parameter.
|
||||
LinkReference(this.label, this.destination, this.title);
|
||||
|
||||
/// The [link label](http://spec.commonmark.org/0.28/#link-label).
|
||||
///
|
||||
/// Temporarily, this class is also being used to represent the link data for
|
||||
/// an inline link (the destination and title), but this should change before
|
||||
/// the package is released.
|
||||
final String label;
|
||||
|
||||
/// The [link destination](http://spec.commonmark.org/0.28/#link-destination).
|
||||
final String destination;
|
||||
|
||||
/// The [link title](http://spec.commonmark.org/0.28/#link-title).
|
||||
final String title;
|
||||
}
|
1510
app_flowy/lib/workspace/infrastructure/markdown/src/emojis.dart
Normal file
1510
app_flowy/lib/workspace/infrastructure/markdown/src/emojis.dart
Normal file
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,64 @@
|
||||
import 'block_parser.dart';
|
||||
import 'inline_parser.dart';
|
||||
|
||||
/// ExtensionSets provide a simple grouping mechanism for common Markdown
|
||||
/// flavors.
|
||||
///
|
||||
/// For example, the [gitHubFlavored] set of syntax extensions allows users to
|
||||
/// output HTML from their Markdown in a similar fashion to GitHub's parsing.
|
||||
class ExtensionSet {
|
||||
ExtensionSet(this.blockSyntaxes, this.inlineSyntaxes);
|
||||
|
||||
/// The [ExtensionSet.none] extension set renders Markdown similar to
|
||||
/// [Markdown.pl].
|
||||
///
|
||||
/// However, this set does not render _exactly_ the same as Markdown.pl;
|
||||
/// rather it is more-or-less the CommonMark standard of Markdown, without
|
||||
/// fenced code blocks, or inline HTML.
|
||||
///
|
||||
/// [Markdown.pl]: http://daringfireball.net/projects/markdown/syntax
|
||||
static final ExtensionSet none = ExtensionSet([], []);
|
||||
|
||||
/// The [commonMark] extension set is close to compliance with [CommonMark].
|
||||
///
|
||||
/// [CommonMark]: http://commonmark.org/
|
||||
static final ExtensionSet commonMark =
|
||||
ExtensionSet([const FencedCodeBlockSyntax()], [InlineHtmlSyntax()]);
|
||||
|
||||
/// The [gitHubWeb] extension set renders Markdown similarly to GitHub.
|
||||
///
|
||||
/// This is different from the [gitHubFlavored] extension set in that GitHub
|
||||
/// actually renders HTML different from straight [GitHub flavored Markdown].
|
||||
///
|
||||
/// (The only difference currently is that [gitHubWeb] renders headers with
|
||||
/// linkable IDs.)
|
||||
///
|
||||
/// [GitHub flavored Markdown]: https://github.github.com/gfm/
|
||||
static final ExtensionSet gitHubWeb = ExtensionSet([
|
||||
const FencedCodeBlockSyntax(),
|
||||
const HeaderWithIdSyntax(),
|
||||
const SetextHeaderWithIdSyntax(),
|
||||
const TableSyntax()
|
||||
], [
|
||||
InlineHtmlSyntax(),
|
||||
StrikethroughSyntax(),
|
||||
EmojiSyntax(),
|
||||
AutolinkExtensionSyntax(),
|
||||
]);
|
||||
|
||||
/// The [gitHubFlavored] extension set is close to compliance with the [GitHub
|
||||
/// flavored Markdown spec].
|
||||
///
|
||||
/// [GitHub flavored Markdown]: https://github.github.com/gfm/
|
||||
static final ExtensionSet gitHubFlavored = ExtensionSet([
|
||||
const FencedCodeBlockSyntax(),
|
||||
const TableSyntax()
|
||||
], [
|
||||
InlineHtmlSyntax(),
|
||||
StrikethroughSyntax(),
|
||||
AutolinkExtensionSyntax(),
|
||||
]);
|
||||
|
||||
final List<BlockSyntax> blockSyntaxes;
|
||||
final List<InlineSyntax> inlineSyntaxes;
|
||||
}
|
@ -0,0 +1,121 @@
|
||||
// Copyright (c) 2012, the Dart project authors. Please see the AUTHORS file
|
||||
// for details. All rights reserved. Use of this source code is governed by a
|
||||
// BSD-style license that can be found in the LICENSE file.
|
||||
|
||||
import 'ast.dart';
|
||||
import 'block_parser.dart';
|
||||
import 'document.dart';
|
||||
import 'extension_set.dart';
|
||||
import 'inline_parser.dart';
|
||||
|
||||
/// Converts the given string of Markdown to HTML.
|
||||
String markdownToHtml(String markdown,
|
||||
{Iterable<BlockSyntax>? blockSyntaxes,
|
||||
Iterable<InlineSyntax>? inlineSyntaxes,
|
||||
ExtensionSet? extensionSet,
|
||||
Resolver? linkResolver,
|
||||
Resolver? imageLinkResolver,
|
||||
bool inlineOnly = false}) {
|
||||
final document = Document(
|
||||
blockSyntaxes: blockSyntaxes,
|
||||
inlineSyntaxes: inlineSyntaxes,
|
||||
extensionSet: extensionSet,
|
||||
linkResolver: linkResolver,
|
||||
imageLinkResolver: imageLinkResolver);
|
||||
|
||||
if (inlineOnly) {
|
||||
return renderToHtml(document.parseInline(markdown)!);
|
||||
}
|
||||
|
||||
// Replace windows line endings with unix line endings, and split.
|
||||
final lines = markdown.replaceAll('\r\n', '\n').split('\n');
|
||||
|
||||
return '${renderToHtml(document.parseLines(lines))}\n';
|
||||
}
|
||||
|
||||
/// Renders [nodes] to HTML.
|
||||
String renderToHtml(List<Node> nodes) => HtmlRenderer().render(nodes);
|
||||
|
||||
/// Translates a parsed AST to HTML.
|
||||
class HtmlRenderer implements NodeVisitor {
|
||||
HtmlRenderer();
|
||||
|
||||
static final _blockTags = RegExp('blockquote|h1|h2|h3|h4|h5|h6|hr|p|pre');
|
||||
|
||||
late StringBuffer buffer;
|
||||
late Set<String> uniqueIds;
|
||||
|
||||
String render(List<Node> nodes) {
|
||||
buffer = StringBuffer();
|
||||
uniqueIds = <String>{};
|
||||
|
||||
for (final node in nodes) {
|
||||
node.accept(this);
|
||||
}
|
||||
|
||||
return buffer.toString();
|
||||
}
|
||||
|
||||
@override
|
||||
void visitText(Text text) {
|
||||
buffer.write(text.text);
|
||||
}
|
||||
|
||||
@override
|
||||
bool visitElementBefore(Element element) {
|
||||
// Hackish. Separate block-level elements with newlines.
|
||||
if (buffer.isNotEmpty && _blockTags.firstMatch(element.tag) != null) {
|
||||
buffer.write('\n');
|
||||
}
|
||||
|
||||
buffer.write('<${element.tag}');
|
||||
|
||||
// Sort the keys so that we generate stable output.
|
||||
final attributeNames = element.attributes.keys.toList()
|
||||
..sort((a, b) => a.compareTo(b));
|
||||
|
||||
for (final name in attributeNames) {
|
||||
buffer.write(' $name="${element.attributes[name]}"');
|
||||
}
|
||||
|
||||
// attach header anchor ids generated from text
|
||||
if (element.generatedId != null) {
|
||||
buffer.write(' id="${uniquifyId(element.generatedId!)}"');
|
||||
}
|
||||
|
||||
if (element.isEmpty) {
|
||||
// Empty element like <hr/>.
|
||||
buffer.write(' />');
|
||||
|
||||
if (element.tag == 'br') {
|
||||
buffer.write('\n');
|
||||
}
|
||||
|
||||
return false;
|
||||
} else {
|
||||
buffer.write('>');
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void visitElementAfter(Element element) {
|
||||
buffer.write('</${element.tag}>');
|
||||
}
|
||||
|
||||
/// Uniquifies an id generated from text.
|
||||
String uniquifyId(String id) {
|
||||
if (!uniqueIds.contains(id)) {
|
||||
uniqueIds.add(id);
|
||||
return id;
|
||||
}
|
||||
|
||||
var suffix = 2;
|
||||
var suffixedId = '$id-$suffix';
|
||||
while (uniqueIds.contains(suffixedId)) {
|
||||
suffixedId = '$id-${suffix++}';
|
||||
}
|
||||
uniqueIds.add(suffixedId);
|
||||
return suffixedId;
|
||||
}
|
||||
}
|
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,71 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:charcode/charcode.dart';
|
||||
|
||||
String escapeHtml(String html) =>
|
||||
const HtmlEscape(HtmlEscapeMode.element).convert(html);
|
||||
|
||||
// Escape the contents of [value], so that it may be used as an HTML attribute.
|
||||
|
||||
// Based on http://spec.commonmark.org/0.28/#backslash-escapes.
|
||||
String escapeAttribute(String value) {
|
||||
final result = StringBuffer();
|
||||
int ch;
|
||||
for (var i = 0; i < value.codeUnits.length; i++) {
|
||||
ch = value.codeUnitAt(i);
|
||||
if (ch == $backslash) {
|
||||
i++;
|
||||
if (i == value.codeUnits.length) {
|
||||
result.writeCharCode(ch);
|
||||
break;
|
||||
}
|
||||
ch = value.codeUnitAt(i);
|
||||
switch (ch) {
|
||||
case $quote:
|
||||
result.write('"');
|
||||
break;
|
||||
case $exclamation:
|
||||
case $hash:
|
||||
case $dollar:
|
||||
case $percent:
|
||||
case $ampersand:
|
||||
case $apostrophe:
|
||||
case $lparen:
|
||||
case $rparen:
|
||||
case $asterisk:
|
||||
case $plus:
|
||||
case $comma:
|
||||
case $dash:
|
||||
case $dot:
|
||||
case $slash:
|
||||
case $colon:
|
||||
case $semicolon:
|
||||
case $lt:
|
||||
case $equal:
|
||||
case $gt:
|
||||
case $question:
|
||||
case $at:
|
||||
case $lbracket:
|
||||
case $backslash:
|
||||
case $rbracket:
|
||||
case $caret:
|
||||
case $underscore:
|
||||
case $backquote:
|
||||
case $lbrace:
|
||||
case $bar:
|
||||
case $rbrace:
|
||||
case $tilde:
|
||||
result.writeCharCode(ch);
|
||||
break;
|
||||
default:
|
||||
result.write('%5C');
|
||||
result.writeCharCode(ch);
|
||||
}
|
||||
} else if (ch == $quote) {
|
||||
result.write('%22');
|
||||
} else {
|
||||
result.writeCharCode(ch);
|
||||
}
|
||||
}
|
||||
return result.toString();
|
||||
}
|
@ -0,0 +1,2 @@
|
||||
// Generated code. Do not modify.
|
||||
const packageVersion = '0.0.2';
|
@ -8,6 +8,7 @@ import 'package:flowy_infra/size.dart';
|
||||
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
|
||||
import 'package:flowy_infra_ui/style_widget/text.dart';
|
||||
import 'package:flowy_infra_ui/widget/rounded_button.dart';
|
||||
import 'package:flowy_log/flowy_log.dart';
|
||||
import 'package:flowy_sdk/protobuf/flowy-workspace-infra/export.pb.dart';
|
||||
import 'package:flowy_sdk/protobuf/flowy-workspace-infra/view_create.pb.dart';
|
||||
import 'package:flowy_sdk/protobuf/flowy-workspace/errors.pb.dart';
|
||||
@ -117,9 +118,7 @@ class DocShareButton extends StatelessWidget {
|
||||
// TODO: Handle this case.
|
||||
break;
|
||||
case ExportType.Markdown:
|
||||
FlutterClipboard.copy(exportData.data).then(
|
||||
(value) => print('copied'),
|
||||
);
|
||||
FlutterClipboard.copy(exportData.data).then((value) => Log.info('copied to clipboard'));
|
||||
break;
|
||||
case ExportType.Text:
|
||||
// TODO: Handle this case.
|
||||
|
@ -253,13 +253,6 @@ packages:
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "6.1.2"
|
||||
file_picker:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: file_picker
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "4.2.1"
|
||||
fixnum:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
@ -66,7 +66,6 @@ dependencies:
|
||||
url_launcher: ^6.0.2
|
||||
# file_picker: ^4.2.1
|
||||
clipboard: ^0.1.3
|
||||
delta_markdown: '>=0.3.0'
|
||||
|
||||
# The following adds the Cupertino Icons font to your application.
|
||||
# Use with the CupertinoIcons class for iOS style icons.
|
||||
|
Loading…
Reference in New Issue
Block a user