diff --git a/super_editor_markdown/lib/src/image_syntax.dart b/super_editor_markdown/lib/src/image_syntax.dart index 3e4f4e1d6d..1ef9b2a735 100644 --- a/super_editor_markdown/lib/src/image_syntax.dart +++ b/super_editor_markdown/lib/src/image_syntax.dart @@ -24,7 +24,7 @@ class SuperEditorImageSyntax extends md.LinkSyntax { ); @override - md.Node? close( + Iterable? close( md.InlineParser parser, covariant md.SimpleDelimiter opener, md.Delimiter? closer, { @@ -51,7 +51,7 @@ class SuperEditorImageSyntax extends md.LinkSyntax { var leftParenIndex = parser.pos; var inlineLink = _parseInlineLink(parser); if (inlineLink != null) { - return _tryCreateInlineLink(parser, inlineLink, getChildren: getChildren); + return [ _tryCreateInlineLink(parser, inlineLink, getChildren: getChildren) ]; } // At this point, we've matched `[...](`, but that `(` did not pan out to // be an inline link. We must now check if `[...]` is simply a shortcut @@ -140,7 +140,7 @@ class SuperEditorImageSyntax extends md.LinkSyntax { /// Tries to create a reference link node. /// /// Returns the link if it was successfully created, `null` otherwise. - md.Node? _tryCreateReferenceLink(md.InlineParser parser, String label, + List? _tryCreateReferenceLink(md.InlineParser parser, String label, {required List Function() getChildren}) { return _resolveReferenceLink(label, parser.document.linkReferences, getChildren: getChildren); } @@ -236,19 +236,21 @@ class SuperEditorImageSyntax extends md.LinkSyntax { /// Otherwise, returns `null`. /// /// [label] does not need to be normalized. - md.Node? _resolveReferenceLink( + List? _resolveReferenceLink( String label, Map linkReferences, { required List Function() getChildren, }) { final linkReference = linkReferences[_normalizeLinkLabel(label)]; if (linkReference != null) { - return createNode( - linkReference.destination, - linkReference.title, - //size: linkReference.size, - getChildren: getChildren, - ); + return [ + createNode( + linkReference.destination, + linkReference.title, + //size: linkReference.size, + getChildren: getChildren, + ) + ]; } else { // This link has no reference definition. But we allow users of the // library to specify a custom resolver function ([linkResolver]) that @@ -262,7 +264,7 @@ class SuperEditorImageSyntax extends md.LinkSyntax { if (resolved != null) { getChildren(); } - return resolved; + return resolved != null ? [resolved] : null; } } @@ -480,6 +482,7 @@ class SuperEditorImageSyntax extends md.LinkSyntax { } } + @override md.Element createNode( String destination, String? title, { diff --git a/super_editor_markdown/lib/src/markdown_to_document_parsing.dart b/super_editor_markdown/lib/src/markdown_to_document_parsing.dart index d128acd624..544b56c61d 100644 --- a/super_editor_markdown/lib/src/markdown_to_document_parsing.dart +++ b/super_editor_markdown/lib/src/markdown_to_document_parsing.dart @@ -23,9 +23,12 @@ MutableDocument deserializeMarkdownToDocument( List customElementToNodeConverters = const [], bool encodeHtml = false, }) { - final markdownLines = const LineSplitter().convert(markdown); + final markdownLines = const LineSplitter().convert(markdown).map((String l) { + return md.Line(l); + }).toList(); final markdownDoc = md.Document( + encodeHtml: encodeHtml, blockSyntaxes: [ ...customBlockSyntax, if (syntax == MarkdownSyntax.superEditor) ...[ @@ -58,6 +61,14 @@ MutableDocument deserializeMarkdownToDocument( ); } + // Add 1 hanging line for every 2 blank lines at the end, need this to preserve behavior pre markdown 7.2.1 + final hangingEmptyLines = markdownLines.reversed.takeWhile((md.Line l) => l.isBlankLine); + if(hangingEmptyLines.isNotEmpty && documentNodes.lastOrNull is ListItemNode) { + for(var i = 0; i < hangingEmptyLines.length ~/ 2; i++) { + documentNodes.add(ParagraphNode(id: Editor.createNodeId(), text: AttributedText())); + } + } + return MutableDocument(nodes: documentNodes); } @@ -391,6 +402,7 @@ class _MarkdownToDocument implements md.NodeVisitor { text, md.Document( inlineSyntaxes: [ + SingleStrikethroughSyntax(), // this needs to be before md.StrikethroughSyntax to be recognized md.StrikethroughSyntax(), UnderlineSyntax(), if (syntax == MarkdownSyntax.superEditor) // @@ -519,24 +531,56 @@ abstract class ElementToNodeConverter { DocumentNode? handleElement(md.Element element); } -/// A Markdown [TagSyntax] that matches underline spans of text, which are represented in +/// A Markdown [DelimiterSyntax] that matches underline spans of text, which are represented in /// Markdown with surrounding `¬` tags, e.g., "this is ¬underline¬ text". /// -/// This [TagSyntax] produces `Element`s with a `u` tag. -class UnderlineSyntax extends md.TagSyntax { - UnderlineSyntax() : super('¬', requiresDelimiterRun: true, allowIntraWord: true); +/// This [DelimiterSyntax] produces `Element`s with a `u` tag. +class UnderlineSyntax extends md.DelimiterSyntax { + + /// According to the docs: + /// + /// https://pub.dev/documentation/markdown/latest/markdown/DelimiterSyntax-class.html + /// + /// The DelimiterSyntax constructor takes a nullable. However, the problem is there is a bug in the underlying dart + /// library if you don't pass it. Due to these two lines, one sets it to const [] if not passed, then the next tries + /// to sort. So we have to pass something at the moment or it blows up. + /// + /// https://github.com/dart-lang/markdown/blob/d53feae0760a4f0aae5ffdfb12d8e6acccf14b40/lib/src/inline_syntaxes/delimiter_syntax.dart#L67 + /// https://github.com/dart-lang/markdown/blob/d53feae0760a4f0aae5ffdfb12d8e6acccf14b40/lib/src/inline_syntaxes/delimiter_syntax.dart#L319 + static final _tags = [ md.DelimiterTag("u", 1) ]; + + UnderlineSyntax() : super('¬', requiresDelimiterRun: true, allowIntraWord: true, tags: _tags); @override - md.Node? close( + Iterable? close( md.InlineParser parser, md.Delimiter opener, md.Delimiter closer, { required List Function() getChildren, + required String tag, }) { - return md.Element('u', getChildren()); + final element = md.Element('u', getChildren()); + return [ element ]; } } +/// A Markdown [DelimiterSyntax] that matches strikethrough spans of text, which are represented in +/// Markdown with surrounding `~` tags, e.g., "this is ~strikethrough~ text". +/// +/// Markdown in library in 7.2.1 seems to not be matching single strikethroughs +/// +/// This [DelimiterSyntax] produces `Element`s with a `del` tag. +class SingleStrikethroughSyntax extends md.DelimiterSyntax { + SingleStrikethroughSyntax() + : super( + '~', + requiresDelimiterRun: true, + allowIntraWord: true, + tags: [md.DelimiterTag('del', 1)], + ); +} + + /// Parses a paragraph preceded by an alignment token. class _ParagraphWithAlignmentSyntax extends _EmptyLinePreservingParagraphSyntax { /// This pattern matches the text aligment notation. @@ -548,7 +592,7 @@ class _ParagraphWithAlignmentSyntax extends _EmptyLinePreservingParagraphSyntax @override bool canParse(md.BlockParser parser) { - if (!_alignmentNotationPattern.hasMatch(parser.current)) { + if (!_alignmentNotationPattern.hasMatch(parser.current.content)) { return false; } @@ -564,7 +608,7 @@ class _ParagraphWithAlignmentSyntax extends _EmptyLinePreservingParagraphSyntax /// We found a paragraph alignment token, but the block after the alignment token isn't a paragraph. /// Therefore, the paragraph alignment token is actually regular content. This parser doesn't need to /// take any action. - if (_standardNonParagraphBlockSyntaxes.any((syntax) => syntax.pattern.hasMatch(nextLine))) { + if (_standardNonParagraphBlockSyntaxes.any((syntax) => syntax.pattern.hasMatch(nextLine.content))) { return false; } @@ -575,7 +619,7 @@ class _ParagraphWithAlignmentSyntax extends _EmptyLinePreservingParagraphSyntax @override md.Node? parse(md.BlockParser parser) { - final match = _alignmentNotationPattern.firstMatch(parser.current); + final match = _alignmentNotationPattern.firstMatch(parser.current.content); // We've parsed the alignment token on the current line. We know a paragraph starts on the // next line. Move the parser to the next line so that we can parse the paragraph. @@ -630,13 +674,13 @@ class _EmptyLinePreservingParagraphSyntax extends md.BlockSyntax { return false; } - if (parser.current.isEmpty) { + if (parser.current.content.isEmpty) { // We consider this input to be a separator between blocks because // it started with an empty line. We want to parse this input. return true; } - if (_isAtParagraphEnd(parser, ignoreEmptyBlocks: _endsWithHardLineBreak(parser.current))) { + if (_isAtParagraphEnd(parser, ignoreEmptyBlocks: _endsWithHardLineBreak(parser.current.content))) { // Another parser wants to parse this input. Let the other parser run. return false; } @@ -648,12 +692,12 @@ class _EmptyLinePreservingParagraphSyntax extends md.BlockSyntax { @override md.Node? parse(md.BlockParser parser) { final childLines = []; - final startsWithEmptyLine = parser.current.isEmpty; + final startsWithEmptyLine = parser.current.content.isEmpty; // A hard line break causes the next line to be treated // as part of the same paragraph, except if the next line is // the beginning of another block element. - bool hasHardLineBreak = _endsWithHardLineBreak(parser.current); + bool hasHardLineBreak = _endsWithHardLineBreak(parser.current.content); if (startsWithEmptyLine) { // The parser started at an empty line. @@ -669,7 +713,7 @@ class _EmptyLinePreservingParagraphSyntax extends md.BlockSyntax { return null; } - if (!_blankLinePattern.hasMatch(parser.current)) { + if (!_blankLinePattern.hasMatch(parser.current.content)) { // We found an empty line, but the following line isn't blank. // As there is no hard line break, the first line is consumed // as a separator between blocks. @@ -682,7 +726,7 @@ class _EmptyLinePreservingParagraphSyntax extends md.BlockSyntax { childLines.add(''); // Check for a hard line break, so we consume the next line if we found one. - hasHardLineBreak = _endsWithHardLineBreak(parser.current); + hasHardLineBreak = _endsWithHardLineBreak(parser.current.content); parser.advance(); } @@ -691,9 +735,9 @@ class _EmptyLinePreservingParagraphSyntax extends md.BlockSyntax { // ends with a hard line break. while (!_isAtParagraphEnd(parser, ignoreEmptyBlocks: hasHardLineBreak)) { final currentLine = parser.current; - childLines.add(currentLine); + childLines.add(currentLine.content); - hasHardLineBreak = _endsWithHardLineBreak(currentLine); + hasHardLineBreak = _endsWithHardLineBreak(currentLine.content); parser.advance(); } @@ -777,7 +821,7 @@ class _TaskSyntax extends md.BlockSyntax { @override md.Node? parse(md.BlockParser parser) { - final match = pattern.firstMatch(parser.current); + final match = pattern.firstMatch(parser.current.content); if (match == null) { return null; } @@ -795,10 +839,10 @@ class _TaskSyntax extends md.BlockSyntax { // - find a blank line OR // - find the start of another block element (including another task) while (!parser.isDone && - !_blankLinePattern.hasMatch(parser.current) && - !_standardNonParagraphBlockSyntaxes.any((syntax) => syntax.pattern.hasMatch(parser.current))) { + !_blankLinePattern.hasMatch(parser.current.content) && + !_standardNonParagraphBlockSyntaxes.any((syntax) => syntax.pattern.hasMatch(parser.current.content))) { buffer.write('\n'); - buffer.write(parser.current); + buffer.write(parser.current.content); parser.advance(); } @@ -832,7 +876,7 @@ class _HeaderWithAlignmentSyntax extends md.BlockSyntax { @override bool canParse(md.BlockParser parser) { - if (!_alignmentNotationPattern.hasMatch(parser.current)) { + if (!_alignmentNotationPattern.hasMatch(parser.current.content)) { return false; } @@ -846,7 +890,7 @@ class _HeaderWithAlignmentSyntax extends md.BlockSyntax { } // Only parse if the next line is header. - if (!_headerSyntax.pattern.hasMatch(nextLine)) { + if (!_headerSyntax.pattern.hasMatch(nextLine.content)) { return false; } @@ -855,7 +899,7 @@ class _HeaderWithAlignmentSyntax extends md.BlockSyntax { @override md.Node? parse(md.BlockParser parser) { - final match = _alignmentNotationPattern.firstMatch(parser.current); + final match = _alignmentNotationPattern.firstMatch(parser.current.content); // We've parsed the alignment token on the current line. We know a header starts on the // next line. Move the parser to the next line so that we can parse the header. diff --git a/super_editor_markdown/pubspec.yaml b/super_editor_markdown/pubspec.yaml index 12e20772e3..66b812f4e2 100644 --- a/super_editor_markdown/pubspec.yaml +++ b/super_editor_markdown/pubspec.yaml @@ -21,7 +21,7 @@ dependencies: super_editor: ^0.3.0-dev.3 logging: ^1.0.1 - markdown: ^5.0.0 + markdown: ^7.2.1 #dependency_overrides: # Override to local mono-repo path so devs can test this repo diff --git a/super_editor_markdown/test/custom_parsers/callout_block.dart b/super_editor_markdown/test/custom_parsers/callout_block.dart index 59d4fe30bb..382cfc9c14 100644 --- a/super_editor_markdown/test/custom_parsers/callout_block.dart +++ b/super_editor_markdown/test/custom_parsers/callout_block.dart @@ -22,26 +22,26 @@ class CalloutBlockSyntax extends md.BlockSyntax { // This method was adapted from the standard Blockquote parser, and // the standard code fence block parser. @override - List parseChildLines(md.BlockParser parser) { + List parseChildLines(md.BlockParser parser) { // Grab all of the lines that form the custom block, stripping off the // first line, e.g., "@@@ customBlock", and the last line, e.g., "@@@". var childLines = []; while (!parser.isDone) { - final openingLine = pattern.firstMatch(parser.current); + final openingLine = pattern.firstMatch(parser.current.content); if (openingLine != null) { // This is the first line. Ignore it. parser.advance(); continue; } - final closingLine = _endLinePattern.firstMatch(parser.current); + final closingLine = _endLinePattern.firstMatch(parser.current.content); if (closingLine != null) { // This is the closing line. Ignore it. parser.advance(); // If we're followed by a blank line, skip it, so that we don't end // up with an extra paragraph for that blank line. - if (parser.current.trim().isEmpty) { + if (parser.current.content.trim().isEmpty) { parser.advance(); } @@ -49,11 +49,11 @@ class CalloutBlockSyntax extends md.BlockSyntax { break; } - childLines.add(parser.current); + childLines.add(parser.current.content); parser.advance(); } - return childLines; + return childLines.map((l) => md.Line(l)).toList(); } // This method was adapted from the standard Blockquote parser, and @@ -95,6 +95,7 @@ _InlineMarkdownToDocument _parseInline(md.Element element) { element.textContent, md.Document( inlineSyntaxes: [ + SingleStrikethroughSyntax(), // this needs to be before md.StrikethroughSyntax to be recognized md.StrikethroughSyntax(), UnderlineSyntax(), ], diff --git a/super_editor_markdown/test/super_editor_markdown_test.dart b/super_editor_markdown/test/super_editor_markdown_test.dart index f6fe8fc3cd..e3fe5279fc 100644 --- a/super_editor_markdown/test/super_editor_markdown_test.dart +++ b/super_editor_markdown/test/super_editor_markdown_test.dart @@ -1129,17 +1129,20 @@ This is some code expect((document.first as ListItemNode).text.text, isEmpty); }); - test('unordered list followd by empty list item', () { + test('unordered list followed by empty list item', () { const markdown = """- list item 1 - - """; +- """; final document = deserializeMarkdownToDocument(markdown); - expect(document.nodeCount, 1); + expect(document.nodeCount, 2); expect(document.getNodeAt(0)!, isA()); expect((document.getNodeAt(0)! as ListItemNode).type, ListItemType.unordered); expect((document.getNodeAt(0)! as ListItemNode).text.text, 'list item 1'); + expect(document.getNodeAt(1)!, isA()); + expect((document.getNodeAt(1)! as ListItemNode).type, ListItemType.unordered); + expect((document.getNodeAt(1)! as ListItemNode).text.text, ''); }); test('parses mixed unordered and ordered items', () { @@ -1433,7 +1436,7 @@ with multiple lines expect(document.getNodeAt(25)!, isA()); }); - test('paragraph with strikethrough', () { + test('paragraph with single strikethrough', () { final doc = deserializeMarkdownToDocument('~This is~ a paragraph.'); final styledText = (doc.getNodeAt(0)! as ParagraphNode).text; @@ -1445,6 +1448,18 @@ with multiple lines expect(styledText.getAllAttributionsAt(7).contains(strikethroughAttribution), false); }); + test('paragraph with double strikethrough', () { + final doc = deserializeMarkdownToDocument('~~This is~~ a paragraph.'); + final styledText = (doc.getNodeAt(0)! as ParagraphNode).text; + + // Ensure text within the range is attributed. + expect(styledText.getAllAttributionsAt(0).contains(strikethroughAttribution), true); + expect(styledText.getAllAttributionsAt(6).contains(strikethroughAttribution), true); + + // Ensure text outside the range isn't attributed. + expect(styledText.getAllAttributionsAt(7).contains(strikethroughAttribution), false); + }); + test('paragraph with underline', () { final doc = deserializeMarkdownToDocument('¬This is¬ a paragraph.'); final styledText = (doc.getNodeAt(0)! as ParagraphNode).text; @@ -1541,6 +1556,27 @@ Paragraph3"""; expect((doc.getNodeAt(2)! as ParagraphNode).text.text, 'Paragraph3'); }); + test('every 2 newlines after a list are a paragraph', () { + const input =''' +1. First item +2. Second item +3. Third item + + + + +'''; + final doc = deserializeMarkdownToDocument(input); + + expect(doc.nodeCount, 5); + expect((doc.getNodeAt(0)! as ListItemNode).text.text, 'First item'); + expect((doc.getNodeAt(1)! as ListItemNode).text.text, 'Second item'); + expect((doc.getNodeAt(2)! as ListItemNode).text.text, 'Third item'); + // super_editor tests expect empty newlines after a list to be retained + expect((doc.getNodeAt(3)! as ParagraphNode).text.text, ''); + expect((doc.getNodeAt(4)! as ParagraphNode).text.text, ''); + }); + test('multiple empty paragraph between paragraphs', () { const input = """Paragraph1