diff --git a/packages/notus/lib/src/convert/markdown.dart b/packages/notus/lib/src/convert/markdown.dart index 6e8f00a19..a1d3a99e8 100644 --- a/packages/notus/lib/src/convert/markdown.dart +++ b/packages/notus/lib/src/convert/markdown.dart @@ -4,20 +4,296 @@ import 'dart:convert'; -import 'package:notus/notus.dart'; import 'package:quill_delta/quill_delta.dart'; +import 'package:notus/notus.dart'; class NotusMarkdownCodec extends Codec { const NotusMarkdownCodec(); @override - Converter get decoder => - throw UnimplementedError('Decoding is not implemented yet.'); + Converter get decoder => _NotusMarkdownDecoder(); @override Converter get encoder => _NotusMarkdownEncoder(); } +class _NotusMarkdownDecoder extends Converter { + final List> _attributesByStyleLength = [ + null, + {'i': true}, // _ + {'b': true}, // ** + {'i': true, 'b': true} // **_ + ]; + final RegExp _headingRegExp = RegExp(r'(#+) *(.+)'); + final RegExp _styleRegExp = RegExp(r'((?:\*|_){1,3})(.*?[^\1 ])\1'); + final RegExp _linkRegExp = RegExp(r'\[([^\]]+)\]\(([^\)]+)\)'); + final RegExp _ulRegExp = RegExp(r'^( *)\* +(.*)'); + final RegExp _olRegExp = RegExp(r'^( *)\d+[\.)] +(.*)'); + final RegExp _bqRegExp = RegExp(r'^> *(.*)'); + final RegExp _codeRegExp = RegExp(r'^( *)```'); // TODO: inline code + bool _inBlockStack = false; +// final List _blockStack = []; +// int _olDepth = 0; + + @override + Delta convert(String input) { + final lines = input.split('\n'); + final delta = Delta(); + + if(_allLinesEmpty(lines)) { + Map style; + _handleSpan(lines[0], delta, true, style); + } else { + for (var line in lines) { + _handleLine(line, delta); + } + } + + return delta; + } + + bool _allLinesEmpty(List lines) { + for (var line in lines) { + if (line != '') { + return false; + } + } + + return true; + } + + void _handleLine(String line, Delta delta, [Map attributes, bool isBlock]) { + if (_handleBlockQuote(line, delta, attributes)) { + return; + } + if (_handleBlock(line, delta, attributes)) { + return; + } + if (_handleHeading(line, delta, attributes)) { + return; + } + + if (line.isNotEmpty) { + _handleSpan(line, delta, true, attributes, isBlock); + } + } + + /// Markdown supports headings and blocks within blocks (except for within code) + /// but not blocks within headers, or ul within + bool _handleBlock(String line, Delta delta, + [Map attributes]) { + var match; + + match = _codeRegExp.matchAsPrefix(line); + if (match != null) { + _inBlockStack = !_inBlockStack; + return true; + } + if (_inBlockStack) { + delta.insert( + line + '\n', + NotusAttribute.code + .toJson()); // TODO: replace with?: {'quote': true}) + // Don't bother testing for code blocks within block stacks + return true; + } + + if (_handleOrderedList(line, delta, attributes) || + _handleUnorderedList(line, delta, attributes)) { + return true; + } + + return false; + } + + /// all blocks are supported within bq + bool _handleBlockQuote(String line, Delta delta, + [Map attributes]) { + var match = _bqRegExp.matchAsPrefix(line); + if (match != null) { + var span = match.group(1); + var newAttributes = NotusAttribute.bq.toJson(); // NotusAttribute.bq.toJson(); + if (attributes != null) { + newAttributes.addAll(attributes); + } + // all blocks are supported within bq + _handleLine(span, delta, newAttributes, true); + return true; + } + return false; + } + + /// ol is supported within ol and bq, but not supported within ul + bool _handleOrderedList(String line, Delta delta, + [Map attributes]) { + var match = _olRegExp.matchAsPrefix(line); + if (match != null) { +// TODO: support nesting +// var depth = match.group(1).length / 3; + var span = match.group(2); + var newAttributes = NotusAttribute.ol.toJson(); + if (attributes != null) { + newAttributes.addAll(attributes); + } + // There's probably no reason why you would have other block types on the same line + _handleSpan(span, delta, true, newAttributes, true); + return true; + } + return false; + } + + bool _handleUnorderedList(String line, Delta delta, + [Map attributes]) { + var match = _ulRegExp.matchAsPrefix(line); + if (match != null) { +// var depth = match.group(1).length / 3; + var span = match.group(2); + var newAttributes = NotusAttribute.ul.toJson(); + if (attributes != null) { + newAttributes.addAll(attributes); + } + // There's probably no reason why you would have other block types on the same line + _handleSpan(span, delta, true, newAttributes, true); + return true; + } + return false; + } + + bool _handleHeading(String line, Delta delta, [Map attributes]) { + var match = _headingRegExp.matchAsPrefix(line); + if (match != null) { + var level = match.group(1).length; + var newAttributes = { + 'heading': level + }; // NotusAttribute.heading.withValue(level).toJson(); + if (attributes != null) { + newAttributes.addAll(attributes); + } + + var span = match.group(2); + // TODO: true or false? + _handleSpan(span, delta, true, newAttributes, true); +// delta.insert('\n', attribute.toJson()); + return true; + } + + return false; + } + + void _handleSpan(String span, Delta delta, bool addNewLine, + Map outerStyle, [bool isBlock]) { + var start = _handleStyles(span, delta, outerStyle); + span = span.substring(start); + + if (span.isNotEmpty) { + start = _handleLinks(span, delta, outerStyle); + span = span.substring(start); + } + + if (span.isNotEmpty) { + if (addNewLine) { + if(isBlock != null && isBlock){ + delta.insert(span); + delta.insert('\n', outerStyle); + } else { + delta.insert('$span\n', outerStyle); + } + } else { + delta.insert(span, outerStyle); + } + } else if (addNewLine) { + delta.insert('\n', outerStyle); + } + } + + int _handleStyles(String span, Delta delta, Map outerStyle) { + var start = 0; + + var matches = _styleRegExp.allMatches(span); + matches.forEach((match) { + if (match.start > start) { + var validInlineStyles = _getValidInlineStyles(outerStyle); + if (span.substring(match.start - 1, match.start) == '[') { + var text = span.substring(start, match.start - 1); + validInlineStyles != null ? delta.insert(text, validInlineStyles) : delta.insert(text); + start = match.start - + 1 + + _handleLinks(span.substring(match.start - 1), delta, validInlineStyles); + return; + } else { + var text = span.substring(start, match.start); + + validInlineStyles != null ? delta.insert(text, validInlineStyles) : delta.insert(text); + } + } + + var text = match.group(2); + var newStyle = Map.from( + _attributesByStyleLength[match.group(1).length]); + + var validInlineStyles = _getValidInlineStyles(outerStyle); + if (validInlineStyles != null) { + newStyle.addAll(validInlineStyles); + } + + _handleSpan(text, delta, false, newStyle); + start = match.end; + }); + + return start; + } + + Map _getValidInlineStyles(Map outerStyle) { + Map leafStyles; + + if(outerStyle == null) { + return null; + } + + if(outerStyle.containsKey(NotusAttribute.bold.key)){ + leafStyles = {'b': true}; + } + + if(outerStyle.containsKey(NotusAttribute.italic.key)){ + leafStyles = {'i': true}; + } + + if(outerStyle.containsKey(NotusAttribute.link.key)){ + leafStyles = {NotusAttribute.link.key: outerStyle[NotusAttribute.link.key]}; + } + + return leafStyles; + } + + int _handleLinks(String span, Delta delta, Map outerStyle) { + var start = 0; + + var matches = _linkRegExp.allMatches(span); + matches.forEach((match) { + if (match.start > start) { + var text = span.substring(start, match.start); + delta.insert(text); //, outerStyle); + } + + var text = match.group(1); + var href = match.group(2); + var newAttributes = { + 'a': href + }; // NotusAttribute.link.fromString(href).toJson(); + + var validInlineStyles = _getValidInlineStyles(outerStyle); + if (validInlineStyles != null) { + newAttributes.addAll(validInlineStyles); + } + + _handleSpan(text, delta, false, newAttributes); + start = match.end; + }); + + return start; + } +} + class _NotusMarkdownEncoder extends Converter { static const kBold = '**'; static const kItalic = '_'; @@ -34,13 +310,27 @@ class _NotusMarkdownEncoder extends Converter { final lineBuffer = StringBuffer(); NotusAttribute currentBlockStyle; var currentInlineStyle = NotusStyle(); - var currentBlockLines = []; + var currentBlockLines = []; + + bool _allLinesEmpty(List lines) { + for (var line in lines) { + if (line != '') { + return false; + } + } + + return true; + } void _handleBlock(NotusAttribute blockStyle) { if (currentBlockLines.isEmpty) { return; // Empty block } + if(_allLinesEmpty(currentBlockLines)){ + return; + } + if (blockStyle == null) { buffer.write(currentBlockLines.join('\n\n')); buffer.writeln(); @@ -142,7 +432,7 @@ class _NotusMarkdownEncoder extends Converter { if (padding.isNotEmpty) buffer.write(padding); } // Now open any new styles. - for (var value in style.values) { + for (var value in style.values.toList().reversed) { if (value.scope == NotusAttributeScope.line) continue; if (currentStyle.containsSame(value)) continue; final originalText = text; @@ -210,4 +500,4 @@ class _NotusMarkdownEncoder extends Converter { buffer.write(tag); } } -} +} \ No newline at end of file diff --git a/packages/notus/test/convert/markdown_test.dart b/packages/notus/test/convert/markdown_test.dart index 9c02a62d3..9db191185 100644 --- a/packages/notus/test/convert/markdown_test.dart +++ b/packages/notus/test/convert/markdown_test.dart @@ -1,23 +1,385 @@ + // Copyright (c) 2018, the Zefyr 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 'dart:convert'; -import 'package:notus/convert.dart'; -import 'package:notus/notus.dart'; -import 'package:quill_delta/quill_delta.dart'; +import 'dart:convert'; import 'package:test/test.dart'; +import 'package:quill_delta/quill_delta.dart'; +import 'package:notus/notus.dart'; +import 'package:notus/convert.dart'; void main() { - group('$NotusMarkdownCodec.encode', () { - test('unimplemented', () { - expect(() { - notusMarkdown.decode('test'); - }, throwsUnimplementedError); + group('$NotusMarkdownCodec.decode', () { + test('should convert empty markdown to valid empty notus document', () { + final markdown = ''; + final newNotusDoc = NotusDocument(); + final delta = notusMarkdown.decode(markdown); + expect(delta.elementAt(0).data, '\n'); + expect(delta, newNotusDoc.toDelta()); + }); + + test('should convert invalid markdown with only line breaks to valid empty notus document', () { + final markdown = '\n\n\n'; + final delta = notusMarkdown.decode(markdown); + expect(delta.elementAt(0).data, '\n'); + final newNotusDoc = NotusDocument(); + expect(delta, newNotusDoc.toDelta()); + }); + + test('paragraphs', () { + final markdown = 'First line\n\nSecond line\n\n'; + final delta = notusMarkdown.decode(markdown); + expect(delta.elementAt(0).data, 'First line\nSecond line\n'); + final andBack = notusMarkdown.encode(delta); + expect(andBack, markdown); + }); + + test('italics', () { + void runFor(String markdown, bool testEncode) { + final delta = notusMarkdown.decode(markdown); + expect(delta.elementAt(0).data, 'italics'); + expect(delta.elementAt(0).attributes['i'], true); + expect(delta.elementAt(0).attributes['b'], null); + if (testEncode) { + final andBack = notusMarkdown.encode(delta); + expect(andBack, markdown); + } + } + + runFor('_italics_\n\n', true); + runFor('*italics*\n\n', false); + }); + + test('multi-word italics', () { + void runFor(String markdown, bool testEncode) { + final delta = notusMarkdown.decode(markdown); + expect(delta.elementAt(0).data, 'Okay, '); + expect(delta.elementAt(0).attributes, null); + + expect(delta.elementAt(1).data, 'this is in italics'); + expect(delta.elementAt(1).attributes['i'], true); + expect(delta.elementAt(1).attributes['b'], null); + + expect(delta.elementAt(3).data, 'so is all of _ this'); + expect(delta.elementAt(3).attributes['i'], true); + + expect(delta.elementAt(4).data, ' but this is not\n'); + expect(delta.elementAt(4).attributes, null); + if (testEncode) { + final andBack = notusMarkdown.encode(delta); + expect(andBack, markdown); + } + } + + runFor( + 'Okay, _this is in italics_ and _so is all of _ this_ but this is not\n\n', + true); + runFor( + 'Okay, *this is in italics* and *so is all of _ this* but this is not\n\n', + false); + }); + + test('bold', () { + void runFor(String markdown, bool testEncode) { + final delta = notusMarkdown.decode(markdown); + expect(delta.elementAt(0).data, 'bold'); + expect(delta.elementAt(0).attributes['b'], true); + expect(delta.elementAt(0).attributes['i'], null); + if (testEncode) { + final andBack = notusMarkdown.encode(delta); + expect(andBack, markdown); + } + } + + runFor('**bold**\n\n', true); + runFor('__bold__\n\n', false); + }); + + test('multi-word bold', () { + void runFor(String markdown, bool testEncode) { + final delta = notusMarkdown.decode(markdown); + expect(delta.elementAt(0).data, 'Okay, '); + expect(delta.elementAt(0).attributes, null); + + expect(delta.elementAt(1).data, 'this is bold'); + expect(delta.elementAt(1).attributes['b'], true); + expect(delta.elementAt(1).attributes['i'], null); + + expect(delta.elementAt(3).data, 'so is all of __ this'); + expect(delta.elementAt(3).attributes['b'], true); + + expect(delta.elementAt(4).data, ' but this is not\n'); + expect(delta.elementAt(4).attributes, null); + if (testEncode) { + final andBack = notusMarkdown.encode(delta); + expect(andBack, markdown); + } + } + + runFor( + 'Okay, **this is bold** and **so is all of __ this** but this is not\n\n', + true); + runFor( + 'Okay, __this is bold__ and __so is all of __ this__ but this is not\n\n', + false); + }); + + test('intersecting inline styles', () { + var markdown = 'This **house _is a_ circus**\n\n'; + final delta = notusMarkdown.decode(markdown); + expect(delta.elementAt(1).data, 'house '); + expect(delta.elementAt(1).attributes['b'], true); + expect(delta.elementAt(1).attributes['i'], null); + + expect(delta.elementAt(2).data, 'is a'); + expect(delta.elementAt(2).attributes['b'], true); + expect(delta.elementAt(2).attributes['i'], true); + + expect(delta.elementAt(3).data, ' circus'); + expect(delta.elementAt(3).attributes['b'], true); + expect(delta.elementAt(3).attributes['i'], null); + + final andBack = notusMarkdown.encode(delta); + expect(andBack, markdown); + }); + + test('bold and italics', () { + void runFor(String markdown, bool testEncode) { + final delta = notusMarkdown.decode(markdown); + expect(delta.elementAt(0).data, 'this is bold and italic'); + expect(delta.elementAt(0).attributes['b'], true); + expect(delta.elementAt(0).attributes['i'], true); + + expect(delta.elementAt(1).data, '\n'); + expect(delta.length, 2); + + if (testEncode) { + final andBack = notusMarkdown.encode(delta); + expect(andBack, markdown); + } + } + + runFor('**_this is bold and italic_**\n\n', true); + runFor('_**this is bold and italic**_\n\n', true); + runFor('***this is bold and italic***\n\n', false); + runFor('___this is bold and italic___\n\n', false); + }); + + test('bold and italics combinations', () { + void runFor(String markdown, bool testEncode) { + final delta = notusMarkdown.decode(markdown); + expect(delta.elementAt(0).data, 'this is bold'); + expect(delta.elementAt(0).attributes['b'], true); + expect(delta.elementAt(0).attributes['i'], null); + + expect(delta.elementAt(2).data, 'this is in italics'); + expect(delta.elementAt(2).attributes['b'], null); + expect(delta.elementAt(2).attributes['i'], true); + + expect(delta.elementAt(4).data, 'this is both'); + expect(delta.elementAt(4).attributes['b'], true); + expect(delta.elementAt(4).attributes['i'], true); + + if (testEncode) { + final andBack = notusMarkdown.encode(delta); + expect(andBack, markdown); + } + } + + runFor('**this is bold** _this is in italics_ and **_this is both_**\n\n', + true); + runFor('**this is bold** *this is in italics* and ***this is both***\n\n', + false); + runFor('__this is bold__ _this is in italics_ and ___this is both___\n\n', + false); + }); + + test('link', () { + var markdown = 'This **house** is a [circus](https://github.com)\n\n'; + final delta = notusMarkdown.decode(markdown); + + expect(delta.elementAt(1).data, 'house'); + expect(delta.elementAt(1).attributes['b'], true); + expect(delta.elementAt(1).attributes['a'], null); + + expect(delta.elementAt(3).data, 'circus'); + expect(delta.elementAt(3).attributes['b'], null); + expect(delta.elementAt(3).attributes['a'], 'https://github.com'); + + final andBack = notusMarkdown.encode(delta); + expect(andBack, markdown); + }); + + test('style around link', () { + var markdown = 'This **house** is a **[circus](https://github.com)**\n\n'; + final delta = notusMarkdown.decode(markdown); + + expect(delta.elementAt(1).data, 'house'); + expect(delta.elementAt(1).attributes['b'], true); + expect(delta.elementAt(1).attributes['a'], null); + + expect(delta.elementAt(3).data, 'circus'); + expect(delta.elementAt(3).attributes['b'], true); + expect(delta.elementAt(3).attributes['a'], 'https://github.com'); + + final andBack = notusMarkdown.encode(delta); + expect(andBack, markdown); + }); + + test('style within link', () { + var markdown = 'This **house** is a [**circus**](https://github.com)\n\n'; + final delta = notusMarkdown.decode(markdown); + + expect(delta.elementAt(1).data, 'house'); + expect(delta.elementAt(1).attributes['b'], true); + expect(delta.elementAt(1).attributes['a'], null); + + expect(delta.elementAt(2).data, ' is a '); + expect(delta.elementAt(2).attributes, null); + + expect(delta.elementAt(3).data, 'circus'); + expect(delta.elementAt(3).attributes['b'], true); + expect(delta.elementAt(3).attributes['a'], 'https://github.com'); + + expect(delta.elementAt(4).data, '\n'); + expect(delta.length, 5); + + final andBack = notusMarkdown.encode(delta); + expect(andBack, markdown); + }); + + test('heading styles', () { + void runFor(String markdown, int level) { + final delta = notusMarkdown.decode(markdown); + expect(delta.elementAt(0).data, 'This is an H$level'); + + expect(delta.elementAt(1).data, '\n'); + expect(delta.elementAt(1).attributes['heading'], level); + final andBack = notusMarkdown.encode(delta); + expect(andBack, markdown); + } + + runFor('# This is an H1\n\n', 1); + runFor('## This is an H2\n\n', 2); + runFor('### This is an H3\n\n', 3); + }); + + test('ul', () { + var markdown = '* a bullet point\n* another bullet point\n\n'; + final delta = notusMarkdown.decode(markdown); + print(delta); + + final andBack = notusMarkdown.encode(delta); + expect(andBack, markdown); + }); + + test('ol', () { + var markdown = '1. 1st point\n1. 2nd point\n\n'; + final delta = notusMarkdown.decode(markdown); + + final andBack = notusMarkdown.encode(delta); + expect(andBack, markdown); + }); + + test('simple bq', () { +// var markdown = '> quote\n> > nested\n>#Heading\n>**bold**\n>_italics_\n>* bullet\n>1. 1st point\n>1. 2nd point\n\n'; + var markdown = + '> quote\n> # Heading in Quote\n> # **Styled** heading in _block quote_\n> **bold text**\n> _text in italics_\n\n'; + final delta = notusMarkdown.decode(markdown); + + expect(delta.elementAt(0).data, 'quote'); + expect(delta.elementAt(0).attributes, null); + + expect(delta.elementAt(1).data, '\n'); + expect(delta.elementAt(1).attributes['block'], 'quote'); + expect(delta.elementAt(1).attributes.length, 1); + + expect(delta.elementAt(2).data, 'Heading in Quote'); + expect(delta.elementAt(2).attributes, null); + + expect(delta.elementAt(3).data, '\n'); + expect(delta.elementAt(3).attributes['block'], 'quote'); + expect(delta.elementAt(3).attributes['heading'], 1); + expect(delta.elementAt(3).attributes.length, 2); + + expect(delta.elementAt(4).data, 'Styled'); + expect(delta.elementAt(4).attributes['b'], true); + expect(delta.elementAt(4).attributes.length, 1); + + expect(delta.elementAt(5).data, ' heading in '); + expect(delta.elementAt(5).attributes, null); + + expect(delta.elementAt(6).data, 'block quote'); + expect(delta.elementAt(6).attributes['i'], true); + expect(delta.elementAt(6).attributes.length, 1); + + expect(delta.elementAt(7).data, '\n'); + expect(delta.elementAt(7).attributes['block'], 'quote'); + expect(delta.elementAt(7).attributes['heading'], 1); + expect(delta.elementAt(7).attributes.length, 2); + + expect(delta.elementAt(8).data, 'bold text'); + expect(delta.elementAt(8).attributes['b'], true); + expect(delta.elementAt(8).attributes.length, 1); + + expect(delta.elementAt(9).data, '\n'); + expect(delta.elementAt(9).attributes['block'], 'quote'); + expect(delta.elementAt(9).attributes.length, 1); + + expect(delta.elementAt(10).data, 'text in italics'); + expect(delta.elementAt(10).attributes['i'], true); + expect(delta.elementAt(10).attributes.length, 1); + + expect(delta.elementAt(11).data, '\n'); + expect(delta.elementAt(11).attributes['block'], 'quote'); + expect(delta.elementAt(11).attributes.length, 1); + + final andBack = notusMarkdown.encode(delta); + expect(andBack, markdown); + }); + +// test('nested bq', () { +// var markdown = '> > nested\n>* bullet\n>1. 1st point\n>1. 2nd point\n\n'; +// final delta = notusMarkdown.decode(markdown); +// final andBack = notusMarkdown.encode(delta); +// expect(andBack, markdown); +// }); + + +// test('code in bq', () { +// var markdown = '> ```\n> print("Hello world!")\n> ```\n\n'; +// final delta = notusMarkdown.decode(markdown); +// final andBack = notusMarkdown.encode(delta); +// expect(andBack, markdown); +// }); + + test('multiple styles', () { + final delta = notusMarkdown.decode(expectedMarkdown); +// expect(delta, doc); + final andBack = notusMarkdown.encode(delta); + expect(andBack, expectedMarkdown); }); }); group('$NotusMarkdownCodec.encode', () { + test('should convert empty valid notus document to empty markdown', () { + final delta = NotusDocument().toDelta(); + final result = notusMarkdown.encode(delta); + expect(result, ''); + }); + + test('should convert delta with only line breaks to empty markdown', () { + final delta = Delta() + ..insert('\n') + ..insert('\n') + ..insert('\n') + ..insert('\n'); + + final result = notusMarkdown.encode(delta); + expect(result, ''); + }); + test('split adjacent paragraphs', () { final delta = Delta()..insert('First line\nSecond line\n'); final result = notusMarkdown.encode(delta); @@ -87,8 +449,7 @@ void main() { }); test('heading styles', () { - void runFor( - NotusAttribute attribute, String source, String expected) { + void runFor(NotusAttribute attribute, String source, String expected) { final delta = Delta()..insert(source)..insert('\n', attribute.toJson()); final result = notusMarkdown.encode(delta); expect(result, expected); @@ -100,8 +461,7 @@ void main() { }); test('block styles', () { - void runFor( - NotusAttribute attribute, String source, String expected) { + void runFor(NotusAttribute attribute, String source, String expected) { final delta = Delta()..insert(source)..insert('\n', attribute.toJson()); final result = notusMarkdown.encode(delta); expect(result, expected); @@ -114,8 +474,7 @@ void main() { }); test('multiline blocks', () { - void runFor( - NotusAttribute attribute, String source, String expected) { + void runFor(NotusAttribute attribute, String source, String expected) { final delta = Delta() ..insert(source) ..insert('\n', attribute.toJson()) diff --git a/packages/zefyr/lib/src/widgets/attr_delegate.dart b/packages/zefyr/lib/src/widgets/attr_delegate.dart new file mode 100644 index 000000000..d36c86614 --- /dev/null +++ b/packages/zefyr/lib/src/widgets/attr_delegate.dart @@ -0,0 +1,3 @@ +abstract class ZefyrAttrDelegate { + void onLinkTap(String link); +} diff --git a/packages/zefyr/lib/src/widgets/common.dart b/packages/zefyr/lib/src/widgets/common.dart index 2e5cbc4f9..660c47175 100644 --- a/packages/zefyr/lib/src/widgets/common.dart +++ b/packages/zefyr/lib/src/widgets/common.dart @@ -1,11 +1,13 @@ // Copyright (c) 2018, the Zefyr 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 'package:flutter/gestures.dart'; import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; import 'package:flutter/widgets.dart'; import 'package:notus/notus.dart'; +import 'package:zefyr/src/widgets/attr_delegate.dart'; import 'editable_box.dart'; import 'horizontal_rule.dart'; @@ -52,7 +54,7 @@ class _ZefyrLineState extends State { assert(widget.style != null); content = ZefyrRichText( node: widget.node, - text: buildText(context), + text: buildText(context, scope), ); } @@ -119,20 +121,35 @@ class _ZefyrLineState extends State { } } - TextSpan buildText(BuildContext context) { + TextSpan buildText(BuildContext context, ZefyrScope scope) { final theme = ZefyrTheme.of(context); - final children = widget.node.children - .map((node) => _segmentToTextSpan(node, theme)) + final List children = widget.node.children + .map((node) => _segmentToTextSpan(node, theme, scope)) .toList(growable: false); return TextSpan(style: widget.style, children: children); } - TextSpan _segmentToTextSpan(Node node, ZefyrThemeData theme) { + TextSpan _segmentToTextSpan( + Node node, ZefyrThemeData theme, ZefyrScope scope) { final TextNode segment = node; final attrs = segment.style; + GestureRecognizer recognizer; + + if (attrs.contains(NotusAttribute.link)) { + final tapGestureRecognizer = TapGestureRecognizer(); + tapGestureRecognizer.onTap = () { + print("delegate: ${scope.attrDelegate}"); + if (scope.attrDelegate?.onLinkTap != null) { + scope.attrDelegate.onLinkTap(attrs.get(NotusAttribute.link).value); + } + }; + recognizer = tapGestureRecognizer; + } + return TextSpan( text: segment.value, + recognizer: recognizer, style: _getTextStyle(attrs, theme), ); } diff --git a/packages/zefyr/lib/src/widgets/editor.dart b/packages/zefyr/lib/src/widgets/editor.dart index 75d2345aa..bd6b4927e 100644 --- a/packages/zefyr/lib/src/widgets/editor.dart +++ b/packages/zefyr/lib/src/widgets/editor.dart @@ -4,6 +4,7 @@ import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:flutter/widgets.dart'; +import 'package:zefyr/src/widgets/attr_delegate.dart'; import 'controller.dart'; import 'editable_text.dart'; @@ -26,6 +27,7 @@ class ZefyrEditor extends StatefulWidget { this.toolbarDelegate, this.imageDelegate, this.selectionControls, + this.attrDelegate, this.physics, this.keyboardAppearance, }) : assert(mode != null), @@ -54,6 +56,8 @@ class ZefyrEditor extends StatefulWidget { /// Optional delegate for customizing this editor's toolbar. final ZefyrToolbarDelegate toolbarDelegate; + final ZefyrAttrDelegate attrDelegate; + /// Delegate for resolving embedded images. /// /// This delegate is required if embedding images is allowed. @@ -83,6 +87,7 @@ class ZefyrEditor extends StatefulWidget { class _ZefyrEditorState extends State { ZefyrImageDelegate _imageDelegate; + ZefyrAttrDelegate _attrDelegate; ZefyrScope _scope; ZefyrThemeData _themeData; GlobalKey _toolbarKey; @@ -130,6 +135,7 @@ class _ZefyrEditorState extends State { void initState() { super.initState(); _imageDelegate = widget.imageDelegate; + _attrDelegate = widget.attrDelegate; } @override @@ -142,6 +148,10 @@ class _ZefyrEditorState extends State { _imageDelegate = widget.imageDelegate; _scope.imageDelegate = _imageDelegate; } + if (widget.attrDelegate != oldWidget.attrDelegate) { + _attrDelegate = widget.attrDelegate; + _scope.attrDelegate = _attrDelegate; + } } @override @@ -157,6 +167,7 @@ class _ZefyrEditorState extends State { _scope = ZefyrScope.editable( mode: widget.mode, imageDelegate: _imageDelegate, + attrDelegate: _attrDelegate, controller: widget.controller, focusNode: widget.focusNode, focusScope: FocusScope.of(context), diff --git a/packages/zefyr/lib/src/widgets/paragraph.dart b/packages/zefyr/lib/src/widgets/paragraph.dart index ed3af54ec..712a5ae5b 100644 --- a/packages/zefyr/lib/src/widgets/paragraph.dart +++ b/packages/zefyr/lib/src/widgets/paragraph.dart @@ -3,6 +3,7 @@ // BSD-style license that can be found in the LICENSE file. import 'package:flutter/material.dart'; import 'package:notus/notus.dart'; +import 'package:zefyr/zefyr.dart'; import 'common.dart'; import 'theme.dart'; diff --git a/packages/zefyr/lib/src/widgets/scope.dart b/packages/zefyr/lib/src/widgets/scope.dart index a045b4582..6f00794eb 100644 --- a/packages/zefyr/lib/src/widgets/scope.dart +++ b/packages/zefyr/lib/src/widgets/scope.dart @@ -1,6 +1,7 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:notus/notus.dart'; +import 'package:zefyr/zefyr.dart'; import 'controller.dart'; import 'cursor_timer.dart'; @@ -25,10 +26,13 @@ class ZefyrScope extends ChangeNotifier { /// Creates a view-only scope. /// /// Normally used in [ZefyrView]. - ZefyrScope.view({ZefyrImageDelegate imageDelegate}) - : isEditable = false, + ZefyrScope.view({ + ZefyrImageDelegate imageDelegate, + ZefyrAttrDelegate attrDelegate, + }) : isEditable = false, _mode = ZefyrMode.view, - _imageDelegate = imageDelegate; + _imageDelegate = imageDelegate, + _attrDelegate = attrDelegate; /// Creates editable scope. /// @@ -39,6 +43,7 @@ class ZefyrScope extends ChangeNotifier { @required FocusNode focusNode, @required FocusScopeNode focusScope, ZefyrImageDelegate imageDelegate, + ZefyrAttrDelegate attrDelegate, }) : assert(mode != null), assert(controller != null), assert(focusNode != null), @@ -47,6 +52,7 @@ class ZefyrScope extends ChangeNotifier { _mode = mode, _controller = controller, _imageDelegate = imageDelegate, + _attrDelegate = attrDelegate, _focusNode = focusNode, _focusScope = focusScope, _cursorTimer = CursorTimer(), @@ -72,6 +78,15 @@ class ZefyrScope extends ChangeNotifier { } } + ZefyrAttrDelegate _attrDelegate; + ZefyrAttrDelegate get attrDelegate => _attrDelegate; + set attrDelegate(ZefyrAttrDelegate value) { + if (_attrDelegate != value) { + _attrDelegate = value; + notifyListeners(); + } + } + ZefyrMode _mode; ZefyrMode get mode => _mode; set mode(ZefyrMode value) { diff --git a/packages/zefyr/lib/src/widgets/view.dart b/packages/zefyr/lib/src/widgets/view.dart index 32abedbb8..293ed47e2 100644 --- a/packages/zefyr/lib/src/widgets/view.dart +++ b/packages/zefyr/lib/src/widgets/view.dart @@ -4,6 +4,7 @@ import 'package:flutter/material.dart'; import 'package:meta/meta.dart'; import 'package:notus/notus.dart'; +import 'package:zefyr/zefyr.dart'; import 'code.dart'; import 'common.dart'; @@ -19,8 +20,10 @@ import 'theme.dart'; class ZefyrView extends StatefulWidget { final NotusDocument document; final ZefyrImageDelegate imageDelegate; + final ZefyrAttrDelegate attrDelegate; - const ZefyrView({Key key, @required this.document, this.imageDelegate}) + const ZefyrView( + {Key key, @required this.document, this.imageDelegate, this.attrDelegate}) : super(key: key); @override @@ -36,13 +39,15 @@ class ZefyrViewState extends State { @override void initState() { super.initState(); - _scope = ZefyrScope.view(imageDelegate: widget.imageDelegate); + _scope = ZefyrScope.view( + imageDelegate: widget.imageDelegate, attrDelegate: widget.attrDelegate); } @override void didUpdateWidget(ZefyrView oldWidget) { super.didUpdateWidget(oldWidget); _scope.imageDelegate = widget.imageDelegate; + _scope.attrDelegate = widget.attrDelegate; } @override diff --git a/packages/zefyr/lib/zefyr.dart b/packages/zefyr/lib/zefyr.dart index e125dc2a5..1b093f3e1 100644 --- a/packages/zefyr/lib/zefyr.dart +++ b/packages/zefyr/lib/zefyr.dart @@ -28,3 +28,4 @@ export 'src/widgets/selection.dart' hide SelectionHandleDriver; export 'src/widgets/theme.dart'; export 'src/widgets/toolbar.dart'; export 'src/widgets/view.dart'; +export 'src/widgets/attr_delegate.dart';