From 44f7975a7165e630f38428c114e754d2112701a3 Mon Sep 17 00:00:00 2001 From: ArjanAswal Date: Mon, 8 Feb 2021 23:59:15 +0530 Subject: [PATCH] Initial Commit --- .gitignore | 74 +++ .metadata | 10 + CHANGELOG.md | 3 + LICENSE | 21 + README.md | 25 + example/main.dart | 26 + lib/notus/convert.dart | 13 + lib/notus/notus.dart | 17 + lib/notus/src/convert/markdown.dart | 514 ++++++++++++++++ lib/notus/src/document.dart | 297 ++++++++++ lib/notus/src/document/attributes.dart | 462 +++++++++++++++ lib/notus/src/document/block.dart | 102 ++++ lib/notus/src/document/leaf.dart | 255 ++++++++ lib/notus/src/document/line.dart | 337 +++++++++++ lib/notus/src/document/node.dart | 311 ++++++++++ lib/notus/src/heuristics.dart | 90 +++ lib/notus/src/heuristics/delete_rules.dart | 133 +++++ lib/notus/src/heuristics/format_rules.dart | 218 +++++++ lib/notus/src/heuristics/insert_rules.dart | 329 ++++++++++ lib/quill_delta/quill_delta.dart | 660 +++++++++++++++++++++ lib/quill_markdown.dart | 65 ++ pubspec.lock | 154 +++++ pubspec.yaml | 23 + 23 files changed, 4139 insertions(+) create mode 100644 .gitignore create mode 100644 .metadata create mode 100644 CHANGELOG.md create mode 100644 LICENSE create mode 100644 README.md create mode 100644 example/main.dart create mode 100644 lib/notus/convert.dart create mode 100644 lib/notus/notus.dart create mode 100644 lib/notus/src/convert/markdown.dart create mode 100644 lib/notus/src/document.dart create mode 100644 lib/notus/src/document/attributes.dart create mode 100644 lib/notus/src/document/block.dart create mode 100644 lib/notus/src/document/leaf.dart create mode 100644 lib/notus/src/document/line.dart create mode 100644 lib/notus/src/document/node.dart create mode 100644 lib/notus/src/heuristics.dart create mode 100644 lib/notus/src/heuristics/delete_rules.dart create mode 100644 lib/notus/src/heuristics/format_rules.dart create mode 100644 lib/notus/src/heuristics/insert_rules.dart create mode 100644 lib/quill_delta/quill_delta.dart create mode 100644 lib/quill_markdown.dart create mode 100644 pubspec.lock create mode 100644 pubspec.yaml diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1985397 --- /dev/null +++ b/.gitignore @@ -0,0 +1,74 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.buildlog/ +.history +.svn/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +**/doc/api/ +.dart_tool/ +.flutter-plugins +.flutter-plugins-dependencies +.packages +.pub-cache/ +.pub/ +build/ + +# Android related +**/android/**/gradle-wrapper.jar +**/android/.gradle +**/android/captures/ +**/android/gradlew +**/android/gradlew.bat +**/android/local.properties +**/android/**/GeneratedPluginRegistrant.java + +# iOS/XCode related +**/ios/**/*.mode1v3 +**/ios/**/*.mode2v3 +**/ios/**/*.moved-aside +**/ios/**/*.pbxuser +**/ios/**/*.perspectivev3 +**/ios/**/*sync/ +**/ios/**/.sconsign.dblite +**/ios/**/.tags* +**/ios/**/.vagrant/ +**/ios/**/DerivedData/ +**/ios/**/Icon? +**/ios/**/Pods/ +**/ios/**/.symlinks/ +**/ios/**/profile +**/ios/**/xcuserdata +**/ios/.generated/ +**/ios/Flutter/App.framework +**/ios/Flutter/Flutter.framework +**/ios/Flutter/Flutter.podspec +**/ios/Flutter/Generated.xcconfig +**/ios/Flutter/app.flx +**/ios/Flutter/app.zip +**/ios/Flutter/flutter_assets/ +**/ios/Flutter/flutter_export_environment.sh +**/ios/ServiceDefinitions.json +**/ios/Runner/GeneratedPluginRegistrant.* + +# Exceptions to above rules. +!**/ios/**/default.mode1v3 +!**/ios/**/default.mode2v3 +!**/ios/**/default.pbxuser +!**/ios/**/default.perspectivev3 diff --git a/.metadata b/.metadata new file mode 100644 index 0000000..5eb5034 --- /dev/null +++ b/.metadata @@ -0,0 +1,10 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: 78910062997c3a836feee883712c241a5fd22983 + channel: stable + +project_type: package diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..ff50309 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,3 @@ +## [0.0.1] - 9 February 2021 + +* Initial release. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..3ee599b --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2020 Arjan Aswal + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..faf3eb1 --- /dev/null +++ b/README.md @@ -0,0 +1,25 @@ + + +## Quill Markdown + +This package converts quill delta to markdown (.md) and vice versa for the package [flutter_quill](https://pub.dev/packages/flutter_quill). + + String content = '[{"insert":"Heading"},{"insert":"\\n","attributes":{"header":1}},{"insert":"bold","attributes":{"bold":true}},{"insert":"\\n"},{"insert":"bold and italic","attributes":{"bold":true,"italic":true}},{"insert":"\\nsome code"},{"insert":"\\n","attributes":{"code-block":true}},{"insert":"A quote"},{"insert":"\\n","attributes":{"blockquote":true}},{"insert":"ordered list"},{"insert":"\\n","attributes":{"list":"ordered"}},{"insert":"unordered list"},{"insert":"\\n","attributes":{"list":"bullet"}},{"insert":"link","attributes":{"link":"pub.dev/packages/quill_to_markdown"}},{"insert":"\\n"}]'; + content = quillToMarkdown(content); + print(content); + content = markdownToQuill(content); + print(content); + + +## Known Limitations: + +[See why](https://github.com/singerdmx/flutter-quill/issues/15#issuecomment-775349564) + + - Doesn't convert image, leaves that attribute. + - Doesn't convert strike, leaves that attribute. + - Doesn't convert color, leaves that attribute. + - Doesn't convert background, leaves that attribute. + - Doesn't convert underline, leaves that attribute. + - Doesn't convert indent, leaves that attribute. + - Doesn't convert checkbox, leaves that attribute. + - Markdown to quill converter is very buggy. \ No newline at end of file diff --git a/example/main.dart b/example/main.dart new file mode 100644 index 0000000..0b1fd5a --- /dev/null +++ b/example/main.dart @@ -0,0 +1,26 @@ +import 'package:flutter/material.dart'; +import 'package:quill_markdown/quill_markdown.dart'; + +class HomePage extends StatefulWidget { + @override + _HomePageState createState() => _HomePageState(); +} + +class _HomePageState extends State { + @override + Widget build(BuildContext context) { + return Scaffold( + body: Center( + child: FlatButton( + onPressed: () { + String content = + '[{"insert":"Heading"},{"insert":"\\n","attributes":{"header":1}},{"insert":"bold","attributes":{"bold":true}},{"insert":"\\n"},{"insert":"bold and italic","attributes":{"bold":true,"italic":true}},{"insert":"\\nsome code"},{"insert":"\\n","attributes":{"code-block":true}},{"insert":"A quote"},{"insert":"\\n","attributes":{"blockquote":true}},{"insert":"ordered list"},{"insert":"\\n","attributes":{"list":"ordered"}},{"insert":"unordered list"},{"insert":"\\n","attributes":{"list":"bullet"}},{"insert":"link","attributes":{"link":"pub.dev/packages/quill_to_markdown"}},{"insert":"\\n"}]'; + content = quillToMarkdown(content); + print(content); + content = markdownToQuill(content); + print(content); + }, + child: Text('Convert')), + )); + } +} diff --git a/lib/notus/convert.dart b/lib/notus/convert.dart new file mode 100644 index 0000000..f2fd672 --- /dev/null +++ b/lib/notus/convert.dart @@ -0,0 +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. + +/// Provides codecs to convert Notus documents to other formats. +library notus.convert; + +import 'src/convert/markdown.dart'; + +export 'src/convert/markdown.dart'; + +/// Markdown codec for Notus documents. +const NotusMarkdownCodec notusMarkdown = NotusMarkdownCodec(); diff --git a/lib/notus/notus.dart b/lib/notus/notus.dart new file mode 100644 index 0000000..d61bcee --- /dev/null +++ b/lib/notus/notus.dart @@ -0,0 +1,17 @@ +// 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. + +/// Rich text document model for Zefyr editor. +library notus; + +export 'src/document.dart'; +export 'src/document/attributes.dart'; +export 'src/document/block.dart'; +export 'src/document/leaf.dart'; +export 'src/document/line.dart'; +export 'src/document/node.dart'; +export 'src/heuristics.dart'; +export 'src/heuristics/delete_rules.dart'; +export 'src/heuristics/format_rules.dart'; +export 'src/heuristics/insert_rules.dart'; diff --git a/lib/notus/src/convert/markdown.dart b/lib/notus/src/convert/markdown.dart new file mode 100644 index 0000000..a8870e7 --- /dev/null +++ b/lib/notus/src/convert/markdown.dart @@ -0,0 +1,514 @@ +// 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:quill_markdown/notus/notus.dart'; +import 'package:quill_markdown/quill_delta/quill_delta.dart'; + +class NotusMarkdownCodec extends Codec { + const NotusMarkdownCodec(); + + @override + 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 = '_'; + static final kSimpleBlocks = { + NotusAttribute.bq: '> ', + NotusAttribute.ul: '* ', + NotusAttribute.ol: '1. ', + }; + + @override + String convert(Delta input) { + final iterator = DeltaIterator(input); + final buffer = StringBuffer(); + final lineBuffer = StringBuffer(); + NotusAttribute currentBlockStyle; + var currentInlineStyle = NotusStyle(); + 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(); + } else if (blockStyle == NotusAttribute.code) { + _writeAttribute(buffer, blockStyle); + buffer.write(currentBlockLines.join('\n')); + _writeAttribute(buffer, blockStyle, close: true); + buffer.writeln(); + } else { + for (var line in currentBlockLines) { + _writeBlockTag(buffer, blockStyle); + buffer.write(line); + buffer.writeln(); + } + } + buffer.writeln(); + } + + void _handleSpan(String text, Map attributes) { + final style = NotusStyle.fromJson(attributes); + currentInlineStyle = + _writeInline(lineBuffer, text, style, currentInlineStyle); + } + + void _handleLine(Map attributes) { + final style = NotusStyle.fromJson(attributes); + final lineBlock = style.get(NotusAttribute.block); + if (lineBlock == currentBlockStyle) { + currentBlockLines.add(_writeLine(lineBuffer.toString(), style)); + } else { + _handleBlock(currentBlockStyle); + currentBlockLines.clear(); + currentBlockLines.add(_writeLine(lineBuffer.toString(), style)); + + currentBlockStyle = lineBlock; + } + lineBuffer.clear(); + } + + while (iterator.hasNext) { + final op = iterator.next(); + final lf = op.data.indexOf('\n'); + if (lf == -1) { + _handleSpan(op.data, op.attributes); + } else { + var span = StringBuffer(); + for (var i = 0; i < op.data.length; i++) { + if (op.data.codeUnitAt(i) == 0x0A) { + if (span.isNotEmpty) { + // Write the span if it's not empty. + _handleSpan(span.toString(), op.attributes); + } + // Close any open inline styles. + _handleSpan('', null); + _handleLine(op.attributes); + span.clear(); + } else { + span.writeCharCode(op.data.codeUnitAt(i)); + } + } + // Remaining span + if (span.isNotEmpty) { + _handleSpan(span.toString(), op.attributes); + } + } + } + _handleBlock(currentBlockStyle); // Close the last block + return buffer.toString(); + } + + String _writeLine(String text, NotusStyle style) { + var buffer = StringBuffer(); + if (style.contains(NotusAttribute.heading)) { + _writeAttribute(buffer, style.get(NotusAttribute.heading)); + } + + // Write the text itself + buffer.write(text); + return buffer.toString(); + } + + String _trimRight(StringBuffer buffer) { + var text = buffer.toString(); + if (!text.endsWith(' ')) return ''; + final result = text.trimRight(); + buffer.clear(); + buffer.write(result); + return ' ' * (text.length - result.length); + } + + NotusStyle _writeInline(StringBuffer buffer, String text, NotusStyle style, + NotusStyle currentStyle) { + // First close any current styles if needed + for (var value in currentStyle.values) { + if (value.scope == NotusAttributeScope.line) continue; + if (style.containsSame(value)) continue; + final padding = _trimRight(buffer); + _writeAttribute(buffer, value, close: true); + if (padding.isNotEmpty) buffer.write(padding); + } + // Now open any new styles. + for (var value in style.values.toList().reversed) { + if (value.scope == NotusAttributeScope.line) continue; + if (currentStyle.containsSame(value)) continue; + final originalText = text; + text = text.trimLeft(); + final padding = ' ' * (originalText.length - text.length); + if (padding.isNotEmpty) buffer.write(padding); + _writeAttribute(buffer, value); + } + // Write the text itself + buffer.write(text); + return style; + } + + void _writeAttribute(StringBuffer buffer, NotusAttribute attribute, + {bool close = false}) { + if (attribute == NotusAttribute.bold) { + _writeBoldTag(buffer); + } else if (attribute == NotusAttribute.italic) { + _writeItalicTag(buffer); + } else if (attribute.key == NotusAttribute.link.key) { + _writeLinkTag(buffer, attribute as NotusAttribute, close: close); + } else if (attribute.key == NotusAttribute.heading.key) { + _writeHeadingTag(buffer, attribute as NotusAttribute); + } else if (attribute.key == NotusAttribute.block.key) { + _writeBlockTag(buffer, attribute as NotusAttribute, close: close); + } else { + throw ArgumentError('Cannot handle $attribute'); + } + } + + void _writeBoldTag(StringBuffer buffer) { + buffer.write(kBold); + } + + void _writeItalicTag(StringBuffer buffer) { + buffer.write(kItalic); + } + + void _writeLinkTag(StringBuffer buffer, NotusAttribute link, + {bool close = false}) { + if (close) { + buffer.write('](${link.value})'); + } else { + buffer.write('['); + } + } + + void _writeHeadingTag(StringBuffer buffer, NotusAttribute heading) { + var level = heading.value; + buffer.write('#' * level + ' '); + } + + void _writeBlockTag(StringBuffer buffer, NotusAttribute block, + {bool close = false}) { + if (block == NotusAttribute.code) { + if (close) { + buffer.write('\n```'); + } else { + buffer.write('```\n'); + } + } else { + if (close) return; // no close tag needed for simple blocks. + + final tag = kSimpleBlocks[block]; + buffer.write(tag); + } + } +} diff --git a/lib/notus/src/document.dart b/lib/notus/src/document.dart new file mode 100644 index 0000000..a7ac97d --- /dev/null +++ b/lib/notus/src/document.dart @@ -0,0 +1,297 @@ +// 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:async'; + +import 'package:quill_markdown/notus/notus.dart'; +import 'package:quill_markdown/quill_delta/quill_delta.dart'; + +import 'document/attributes.dart'; +import 'document/block.dart'; +import 'document/leaf.dart'; +import 'document/line.dart'; +import 'document/node.dart'; +import 'heuristics.dart'; + +/// Source of a [NotusChange]. +enum ChangeSource { + /// Change originated from a local action. Typically triggered by user. + local, + + /// Change originated from a remote action. + remote, +} + +/// Represents a change in a [NotusDocument]. +class NotusChange { + NotusChange(this.before, this.change, this.source); + + /// Document state before [change]. + final Delta before; + + /// Change delta applied to the document. + final Delta change; + + /// The source of this change. + final ChangeSource source; +} + +/// A rich text document. +class NotusDocument { + /// Creates new empty Notus document. + NotusDocument() + : _heuristics = NotusHeuristics.fallback, + _delta = Delta()..insert('\n') { + _loadDocument(_delta); + } + + NotusDocument.fromJson(List data) + : _heuristics = NotusHeuristics.fallback, + _delta = Delta.fromJson(data) { + _loadDocument(_delta); + } + + NotusDocument.fromDelta(Delta delta) + : assert(delta != null), + _heuristics = NotusHeuristics.fallback, + _delta = delta { + _loadDocument(_delta); + } + + final NotusHeuristics _heuristics; + + /// The root node of this document tree. + RootNode get root => _root; + final RootNode _root = RootNode(); + + /// Length of this document. + int get length => _root.length; + + /// Stream of [NotusChange]s applied to this document. + Stream get changes => _controller.stream; + + final StreamController _controller = + StreamController.broadcast(); + + /// Returns contents of this document as [Delta]. + Delta toDelta() => Delta.from(_delta); + Delta _delta; + + /// Returns plain text representation of this document. + String toPlainText() => _delta.toList().map((op) => op.data).join(); + + dynamic toJson() { + return _delta.toJson(); + } + + /// Returns `true` if this document and associated stream of [changes] + /// is closed. + /// + /// Modifying a closed document is not allowed. + bool get isClosed => _controller.isClosed; + + /// Closes [changes] stream. + void close() { + _controller.close(); + } + + /// Inserts [text] in this document at specified [index]. + /// + /// This method applies heuristic rules before modifying this document and + /// produces a [NotusChange] with source set to [ChangeSource.local]. + /// + /// Returns an instance of [Delta] actually composed into this document. + Delta insert(int index, String text) { + assert(index >= 0); + assert(text.isNotEmpty); + text = _sanitizeString(text); + if (text.isEmpty) return Delta(); + final change = _heuristics.applyInsertRules(this, index, text); + compose(change, ChangeSource.local); + return change; + } + + /// Deletes [length] of characters from this document starting at [index]. + /// + /// This method applies heuristic rules before modifying this document and + /// produces a [NotusChange] with source set to [ChangeSource.local]. + /// + /// Returns an instance of [Delta] actually composed into this document. + Delta delete(int index, int length) { + assert(index >= 0 && length > 0); + // TODO: need a heuristic rule to ensure last line-break. + final change = _heuristics.applyDeleteRules(this, index, length); + if (change.isNotEmpty) { + // Delete rules are allowed to prevent the edit so it may be empty. + compose(change, ChangeSource.local); + } + return change; + } + + /// Replaces [length] of characters starting at [index] [text]. + /// + /// This method applies heuristic rules before modifying this document and + /// produces a [NotusChange] with source set to [ChangeSource.local]. + /// + /// Returns an instance of [Delta] actually composed into this document. + Delta replace(int index, int length, String text) { + assert(index >= 0 && (text.isNotEmpty || length > 0), + 'With index $index, length $length and text "$text"'); + var delta = Delta(); + // We have to compose before applying delete rules + // Otherwise delete would be operating on stale document snapshot. + if (text.isNotEmpty) { + delta = insert(index + length, text); + } + + if (length > 0) { + final deleteDelta = delete(index, length); + delta = delta.compose(deleteDelta); + } + return delta; + } + + /// Formats segment of this document with specified [attribute]. + /// + /// Applies heuristic rules before modifying this document and + /// produces a [NotusChange] with source set to [ChangeSource.local]. + /// + /// Returns an instance of [Delta] actually composed into this document. + /// The returned [Delta] may be empty in which case this document remains + /// unchanged and no [NotusChange] is published to [changes] stream. + Delta format(int index, int length, NotusAttribute attribute) { + assert(index >= 0 && length >= 0 && attribute != null); + + var change = Delta(); + + if (attribute is EmbedAttribute && length > 0) { + // Must delete selected length of text before applying embed attribute + // since inserting an embed in non-empty selection is essentially a + // replace operation. + change = delete(index, length); + length = 0; + } + + final formatChange = + _heuristics.applyFormatRules(this, index, length, attribute); + if (formatChange.isNotEmpty) { + compose(formatChange, ChangeSource.local); + change = change.compose(formatChange); + } + + return change; + } + + /// Returns style of specified text range. + /// + /// Only attributes applied to all characters within this range are + /// included in the result. Inline and block level attributes are + /// handled separately, e.g.: + /// + /// - block attribute X is included in the result only if it exists for + /// every line within this range (partially included lines are counted). + /// - inline attribute X is included in the result only if it exists + /// for every character within this range (line-break characters excluded). + NotusStyle collectStyle(int index, int length) { + var result = lookupLine(index); + LineNode line = result.node; + return line.collectStyle(result.offset, length); + } + + /// Returns [LineNode] located at specified character [offset]. + LookupResult lookupLine(int offset) { + // TODO: prevent user from moving caret after last line-break. + var result = _root.lookup(offset, inclusive: true); + if (result.node is LineNode) return result; + BlockNode block = result.node; + return block.lookup(result.offset, inclusive: true); + } + + /// Composes [change] into this document. + /// + /// Use this method with caution as it does not apply heuristic rules to the + /// [change]. + /// + /// It is callers responsibility to ensure that the [change] conforms to + /// the document model semantics and can be composed with the current state + /// of this document. + /// + /// In case the [change] is invalid, behavior of this method is unspecified. + void compose(Delta change, ChangeSource source) { + _checkMutable(); + change.trim(); + assert(change.isNotEmpty); + + var offset = 0; + final before = toDelta(); + for (final op in change.toList()) { + final attributes = + op.attributes != null ? NotusStyle.fromJson(op.attributes) : null; + if (op.isInsert) { + _root.insert(offset, op.data, attributes); + } else if (op.isDelete) { + _root.delete(offset, op.length); + } else if (op.attributes != null) { + _root.retain(offset, op.length, attributes); + } + if (!op.isDelete) offset += op.length; + } + _delta = _delta.compose(change); + + if (_delta != _root.toDelta()) { + throw StateError('Compose produced inconsistent results. ' + 'This is likely due to a bug in the library. Tried to compose change $change from $source.'); + } + _controller.add(NotusChange(before, change, source)); + } + + // + // Overridden members + // + @override + String toString() => _root.toString(); + + // + // Private members + // + + void _checkMutable() { + assert(!_controller.isClosed, + 'Cannot modify Notus document after it was closed.'); + } + + String _sanitizeString(String value) { + if (value.contains(EmbedNode.kPlainTextPlaceholder)) { + return value.replaceAll(EmbedNode.kPlainTextPlaceholder, ''); + } else { + return value; + } + } + + /// Loads [document] delta into this document. + void _loadDocument(Delta doc) { + assert(doc.last.data.endsWith('\n'), + 'Invalid document delta. Document delta must always end with a line-break.'); + var offset = 0; + for (final op in doc.toList()) { + final style = + op.attributes != null ? NotusStyle.fromJson(op.attributes) : null; + if (op.isInsert) { + _root.insert(offset, op.data, style); + } else { + throw ArgumentError.value(doc, + 'Document Delta can only contain insert operations but ${op.key} found.'); + } + offset += op.length; + } + // Must remove last line if it's empty and with no styles. + // TODO: find a way for DocumentRoot to not create extra line when composing initial delta. + final node = _root.last; + if (node is LineNode && + node.parent is! BlockNode && + node.style.isEmpty && + _root.childCount > 1) { + _root.remove(node); + } + } +} diff --git a/lib/notus/src/document/attributes.dart b/lib/notus/src/document/attributes.dart new file mode 100644 index 0000000..f2f2220 --- /dev/null +++ b/lib/notus/src/document/attributes.dart @@ -0,0 +1,462 @@ +// 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:collection/collection.dart'; +import 'package:quiver_hashcode/hashcode.dart'; + +/// Scope of a style attribute, defines context in which an attribute can be +/// applied. +enum NotusAttributeScope { + /// Inline-scoped attributes are applicable to all characters within a line. + /// + /// Inline attributes cannot be applied to the line itself. + inline, + + /// Line-scoped attributes are only applicable to a line of text as a whole. + /// + /// Line attributes do not have any effect on any character within the line. + line, +} + +/// Interface for objects which provide access to an attribute key. +/// +/// Implemented by [NotusAttribute] and [NotusAttributeBuilder]. +abstract class NotusAttributeKey { + /// Unique key of this attribute. + String get key; +} + +/// Builder for style attributes. +/// +/// Useful in scenarios when an attribute value is not known upfront, for +/// instance, link attribute. +/// +/// See also: +/// * [LinkAttributeBuilder] +/// * [BlockAttributeBuilder] +/// * [HeadingAttributeBuilder] +abstract class NotusAttributeBuilder implements NotusAttributeKey { + const NotusAttributeBuilder._(this.key, this.scope); + + @override + final String key; + final NotusAttributeScope scope; + NotusAttribute get unset => NotusAttribute._(key, scope, null); + NotusAttribute withValue(T value) => + NotusAttribute._(key, scope, value); +} + +/// Style attribute applicable to a segment of a Notus document. +/// +/// All supported attributes are available via static fields on this class. +/// Here is an example of applying styles to a document: +/// +/// void makeItPretty(Notus document) { +/// // Format 5 characters at position 0 as bold +/// document.format(0, 5, NotusAttribute.bold); +/// // Similarly for italic +/// document.format(0, 5, NotusAttribute.italic); +/// // Format first line as a heading (h1) +/// // Note that there is no need to specify character range of the whole +/// // line. Simply set index position to anywhere within the line and +/// // length to 0. +/// document.format(0, 0, NotusAttribute.h1); +/// } +/// +/// List of supported attributes: +/// +/// * [NotusAttribute.bold] +/// * [NotusAttribute.italic] +/// * [NotusAttribute.link] +/// * [NotusAttribute.heading] +/// * [NotusAttribute.block] +class NotusAttribute implements NotusAttributeBuilder { + static final Map _registry = { + NotusAttribute.bold.key: NotusAttribute.bold, + NotusAttribute.italic.key: NotusAttribute.italic, + NotusAttribute.link.key: NotusAttribute.link, + NotusAttribute.heading.key: NotusAttribute.heading, + NotusAttribute.block.key: NotusAttribute.block, + NotusAttribute.embed.key: NotusAttribute.embed, + }; + + // Inline attributes + + /// Bold style attribute. + static const bold = _BoldAttribute(); + + /// Italic style attribute. + static const italic = _ItalicAttribute(); + + /// Link style attribute. + // ignore: const_eval_throws_exception + static const link = LinkAttributeBuilder._(); + + // Line attributes + + /// Heading style attribute. + // ignore: const_eval_throws_exception + static const heading = HeadingAttributeBuilder._(); + + /// Alias for [NotusAttribute.heading.level1]. + static NotusAttribute get h1 => heading.level1; + + /// Alias for [NotusAttribute.heading.level2]. + static NotusAttribute get h2 => heading.level2; + + /// Alias for [NotusAttribute.heading.level3]. + static NotusAttribute get h3 => heading.level3; + + /// Block attribute + // ignore: const_eval_throws_exception + static const block = BlockAttributeBuilder._(); + + /// Alias for [NotusAttribute.block.bulletList]. + static NotusAttribute get ul => block.bulletList; + + /// Alias for [NotusAttribute.block.numberList]. + static NotusAttribute get ol => block.numberList; + + /// Alias for [NotusAttribute.block.quote]. + static NotusAttribute get bq => block.quote; + + /// Alias for [NotusAttribute.block.code]. + static NotusAttribute get code => block.code; + + /// Embed style attribute. + // ignore: const_eval_throws_exception + static const embed = EmbedAttributeBuilder._(); + + static NotusAttribute _fromKeyValue(String key, dynamic value) { + if (!_registry.containsKey(key)) { + throw ArgumentError.value( + key, 'No attribute with key "$key" registered.'); + } + final builder = _registry[key]; + return builder.withValue(value); + } + + const NotusAttribute._(this.key, this.scope, this.value); + + /// Unique key of this attribute. + @override + final String key; + + /// Scope of this attribute. + @override + final NotusAttributeScope scope; + + /// Value of this attribute. + /// + /// If value is `null` then this attribute represents a transient action + /// of removing associated style and is never persisted in a resulting + /// document. + /// + /// See also [unset], [NotusStyle.merge] and [NotusStyle.put] + /// for details. + final T value; + + /// Returns special "unset" version of this attribute. + /// + /// Unset attribute's [value] is always `null`. + /// + /// When composed into a rich text document, unset attributes remove + /// associated style. + @override + NotusAttribute get unset => NotusAttribute._(key, scope, null); + + /// Returns `true` if this attribute is an unset attribute. + bool get isUnset => value == null; + + /// Returns `true` if this is an inline-scoped attribute. + bool get isInline => scope == NotusAttributeScope.inline; + + @override + NotusAttribute withValue(T value) => + NotusAttribute._(key, scope, value); + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + if (other is! NotusAttribute) return false; + NotusAttribute typedOther = other; + return key == typedOther.key && + scope == typedOther.scope && + value == typedOther.value; + } + + @override + int get hashCode => hash3(key, scope, value); + + @override + String toString() => '$key: $value'; + + Map toJson() => {key: value}; +} + +/// Collection of style attributes. +class NotusStyle { + NotusStyle._(this._data); + + final Map _data; + + static NotusStyle fromJson(Map data) { + if (data == null) return NotusStyle(); + + final result = data.map((String key, dynamic value) { + var attr = NotusAttribute._fromKeyValue(key, value); + return MapEntry(key, attr); + }); + return NotusStyle._(result); + } + + NotusStyle() : _data = {}; + + /// Returns `true` if this attribute set is empty. + bool get isEmpty => _data.isEmpty; + + /// Returns `true` if this attribute set is note empty. + bool get isNotEmpty => _data.isNotEmpty; + + /// Returns `true` if this style is not empty and contains only inline-scoped + /// attributes and is not empty. + bool get isInline => isNotEmpty && values.every((item) => item.isInline); + + /// Checks that this style has only one attribute, and returns that attribute. + NotusAttribute get single => _data.values.single; + + /// Returns `true` if attribute with [key] is present in this set. + /// + /// Only checks for presence of specified [key] regardless of the associated + /// value. + /// + /// To test if this set contains an attribute with specific value consider + /// using [containsSame]. + bool contains(NotusAttributeKey key) => _data.containsKey(key.key); + + /// Returns `true` if this set contains attribute with the same value as + /// [attribute]. + bool containsSame(NotusAttribute attribute) { + assert(attribute != null); + return get(attribute) == attribute; + } + + /// Returns value of specified attribute [key] in this set. + T value(NotusAttributeKey key) => get(key).value; + + /// Returns [NotusAttribute] from this set by specified [key]. + NotusAttribute get(NotusAttributeKey key) => + _data[key.key] as NotusAttribute; + + /// Returns collection of all attribute keys in this set. + Iterable get keys => _data.keys; + + /// Returns collection of all attributes in this set. + Iterable get values => _data.values; + + /// Puts [attribute] into this attribute set and returns result as a new set. + NotusStyle put(NotusAttribute attribute) { + final result = Map.from(_data); + result[attribute.key] = attribute; + return NotusStyle._(result); + } + + /// Merges this attribute set with [attribute] and returns result as a new + /// attribute set. + /// + /// Performs compaction if [attribute] is an "unset" value, e.g. removes + /// corresponding attribute from this set completely. + /// + /// See also [put] method which does not perform compaction and allows + /// constructing styles with "unset" values. + NotusStyle merge(NotusAttribute attribute) { + final merged = Map.from(_data); + if (attribute.isUnset) { + merged.remove(attribute.key); + } else { + merged[attribute.key] = attribute; + } + return NotusStyle._(merged); + } + + /// Merges all attributes from [other] into this style and returns result + /// as a new instance of [NotusStyle]. + NotusStyle mergeAll(NotusStyle other) { + var result = NotusStyle._(_data); + for (var value in other.values) { + result = result.merge(value); + } + return result; + } + + /// Removes [attributes] from this style and returns new instance of + /// [NotusStyle] containing result. + NotusStyle removeAll(Iterable attributes) { + final merged = Map.from(_data); + attributes.map((item) => item.key).forEach(merged.remove); + return NotusStyle._(merged); + } + + /// Returns JSON-serializable representation of this style. + Map toJson() => _data.isEmpty + ? null + : _data.map((String _, NotusAttribute value) => + MapEntry(value.key, value.value)); + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + if (other is! NotusStyle) return false; + NotusStyle typedOther = other; + final eq = const MapEquality(); + return eq.equals(_data, typedOther._data); + } + + @override + int get hashCode { + final hashes = _data.entries.map((entry) => hash2(entry.key, entry.value)); + return hashObjects(hashes); + } + + @override + String toString() => "{${_data.values.join(', ')}}"; +} + +/// Applies bold style to a text segment. +class _BoldAttribute extends NotusAttribute { + const _BoldAttribute() : super._('b', NotusAttributeScope.inline, true); +} + +/// Applies italic style to a text segment. +class _ItalicAttribute extends NotusAttribute { + const _ItalicAttribute() : super._('i', NotusAttributeScope.inline, true); +} + +/// Builder for link attribute values. +/// +/// There is no need to use this class directly, consider using +/// [NotusAttribute.link] instead. +class LinkAttributeBuilder extends NotusAttributeBuilder { + static const _kLink = 'a'; + const LinkAttributeBuilder._() : super._(_kLink, NotusAttributeScope.inline); + + /// Creates a link attribute with specified link [value]. + NotusAttribute fromString(String value) => + NotusAttribute._(key, scope, value); +} + +/// Builder for heading attribute styles. +/// +/// There is no need to use this class directly, consider using +/// [NotusAttribute.heading] instead. +class HeadingAttributeBuilder extends NotusAttributeBuilder { + static const _kHeading = 'heading'; + const HeadingAttributeBuilder._() + : super._(_kHeading, NotusAttributeScope.line); + + /// Level 1 heading, equivalent of `H1` in HTML. + NotusAttribute get level1 => NotusAttribute._(key, scope, 1); + + /// Level 2 heading, equivalent of `H2` in HTML. + NotusAttribute get level2 => NotusAttribute._(key, scope, 2); + + /// Level 3 heading, equivalent of `H3` in HTML. + NotusAttribute get level3 => NotusAttribute._(key, scope, 3); +} + +/// Builder for block attribute styles (number/bullet lists, code and quote). +/// +/// There is no need to use this class directly, consider using +/// [NotusAttribute.block] instead. +class BlockAttributeBuilder extends NotusAttributeBuilder { + static const _kBlock = 'block'; + const BlockAttributeBuilder._() : super._(_kBlock, NotusAttributeScope.line); + + /// Formats a block of lines as a bullet list. + NotusAttribute get bulletList => + NotusAttribute._(key, scope, 'ul'); + + /// Formats a block of lines as a number list. + NotusAttribute get numberList => + NotusAttribute._(key, scope, 'ol'); + + /// Formats a block of lines as a code snippet, using monospace font. + NotusAttribute get code => + NotusAttribute._(key, scope, 'code'); + + /// Formats a block of lines as a quote. + NotusAttribute get quote => + NotusAttribute._(key, scope, 'quote'); +} + +class EmbedAttributeBuilder + extends NotusAttributeBuilder> { + const EmbedAttributeBuilder._() + : super._(EmbedAttribute._kEmbed, NotusAttributeScope.inline); + + NotusAttribute> get horizontalRule => + EmbedAttribute.horizontalRule(); + + NotusAttribute> image(String source) => + EmbedAttribute.image(source); + + @override + NotusAttribute> get unset => EmbedAttribute._(null); + + @override + NotusAttribute> withValue(Map value) => + EmbedAttribute._(value); +} + +/// Type of embedded content. +enum EmbedType { horizontalRule, image } + +class EmbedAttribute extends NotusAttribute> { + static const _kValueEquality = MapEquality(); + static const _kEmbed = 'embed'; + static const _kHorizontalRuleEmbed = 'hr'; + static const _kImageEmbed = 'image'; + + EmbedAttribute._(Map value) + : super._(_kEmbed, NotusAttributeScope.inline, value); + + EmbedAttribute.horizontalRule() + : this._({'type': _kHorizontalRuleEmbed}); + + EmbedAttribute.image(String source) + : this._({'type': _kImageEmbed, 'source': source}); + + /// Type of this embed. + EmbedType get type { + if (value['type'] == _kHorizontalRuleEmbed) return EmbedType.horizontalRule; + if (value['type'] == _kImageEmbed) return EmbedType.image; + assert(false, 'Unknown embed attribute value $value.'); + return null; + } + + @override + NotusAttribute> get unset => EmbedAttribute._(null); + + @override + bool operator ==(other) { + if (identical(this, other)) return true; + if (other is! EmbedAttribute) return false; + EmbedAttribute typedOther = other; + return key == typedOther.key && + scope == typedOther.scope && + _kValueEquality.equals(value, typedOther.value); + } + + @override + int get hashCode { + final objects = [key, scope]; + if (value != null) { + final valueHashes = + value.entries.map((entry) => hash2(entry.key, entry.value)); + objects.addAll(valueHashes); + } else { + objects.add(value); + } + return hashObjects(objects); + } +} diff --git a/lib/notus/src/document/block.dart b/lib/notus/src/document/block.dart new file mode 100644 index 0000000..bf138de --- /dev/null +++ b/lib/notus/src/document/block.dart @@ -0,0 +1,102 @@ +// 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:quill_markdown/notus/notus.dart'; +import 'package:quill_markdown/quill_delta/quill_delta.dart'; + +import 'attributes.dart'; +import 'line.dart'; +import 'node.dart'; + +/// A block represents a group of adjacent [LineNode]s with the same block +/// style. +/// +/// Block examples: lists, quotes, code snippets. +class BlockNode extends ContainerNode + with StyledNodeMixin + implements StyledNode { + /// Creates new unmounted [BlockNode] with the same attributes. + BlockNode clone() { + final node = BlockNode(); + node.applyStyle(style); + return node; + } + + /// Unwraps [line] from this block. + void unwrapLine(LineNode line) { + assert(children.contains(line)); + + if (line.isFirst) { + line.unlink(); + insertBefore(line); + } else if (line.isLast) { + line.unlink(); + insertAfter(line); + } else { + /// need to split this block into two as [line] is in the middle. + final before = clone(); + insertBefore(before); + + LineNode child = first; + while (child != line) { + child.unlink(); + before.add(child); + child = first as LineNode; + } + line.unlink(); + insertBefore(line); + } + optimize(); + } + + @override + LineNode get defaultChild => LineNode(); + + @override + Delta toDelta() { + // Line nodes take care of incorporating block style into their delta. + return children + .map((child) => child.toDelta()) + .fold(Delta(), (a, b) => a.concat(b)); + } + + @override + String toString() { + final block = style.value(NotusAttribute.block); + final buffer = StringBuffer('§ {$block}\n'); + for (var child in children) { + final tree = child.isLast ? '└' : '├'; + buffer.write(' $tree $child'); + if (!child.isLast) buffer.writeln(); + } + return buffer.toString(); + } + + @override + void optimize() { + if (isEmpty) { + final sibling = previous; + unlink(); + if (sibling != null) sibling.optimize(); + return; + } + + var block = this; + if (!block.isFirst && block.previous is BlockNode) { + BlockNode prev = block.previous; + if (prev.style == block.style) { + block.moveChildren(prev); + block.unlink(); + block = prev; + } + } + if (!block.isLast && block.next is BlockNode) { + BlockNode nextBlock = block.next; + if (nextBlock.style == block.style) { + nextBlock.moveChildren(block); + nextBlock.unlink(); + } + } + } +} diff --git a/lib/notus/src/document/leaf.dart b/lib/notus/src/document/leaf.dart new file mode 100644 index 0000000..564a4ec --- /dev/null +++ b/lib/notus/src/document/leaf.dart @@ -0,0 +1,255 @@ +// 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:math' as math; + +import 'package:quill_markdown/notus/notus.dart'; +import 'package:quill_markdown/quill_delta/quill_delta.dart'; + +import 'attributes.dart'; +import 'line.dart'; +import 'node.dart'; + +/// A leaf node in Notus document tree. +abstract class LeafNode extends Node + with StyledNodeMixin + implements StyledNode { + /// Creates a new [LeafNode] with specified [value]. + LeafNode._([String value = '']) + : assert(value != null && !value.contains('\n')), + _value = value; + + factory LeafNode([String value = '']) { + LeafNode node; + if (value == kZeroWidthSpace) { + // Zero-width space is reserved for embed nodes. + node = EmbedNode(); + } else { + assert( + !value.contains(kZeroWidthSpace), + 'Zero-width space is reserved for embed leaf nodes and cannot be used ' + 'inside regular text nodes.'); + node = TextNode(value); + } + return node; + } + + /// Plain-text value of this node. + String get value => _value; + String _value; + + /// Splits this leaf node at [index] and returns new node. + /// + /// If this is the last node in its list and [index] equals this node's + /// length then this method returns `null` as there is nothing left to split. + /// If there is another leaf node after this one and [index] equals this + /// node's length then the next leaf node is returned. + /// + /// If [index] equals to `0` then this node itself is returned unchanged. + /// + /// In case a new node is actually split from this one, it inherits this + /// node's style. + LeafNode splitAt(int index) { + assert(index >= 0 && index <= length); + if (index == 0) return this; + if (index == length && isLast) return null; + if (index == length && !isLast) return next as LeafNode; + + final text = _value; + _value = text.substring(0, index); + final split = LeafNode(text.substring(index)); + split.applyStyle(style); + insertAfter(split); + return split; + } + + /// Cuts a leaf node from [index] to the end of this node and returns new node + /// in detached state (e.g. [mounted] returns `false`). + /// + /// Splitting logic is identical to one described in [splitAt], meaning this + /// method may return `null`. + LeafNode cutAt(int index) { + assert(index >= 0 && index <= length); + final cut = splitAt(index); + cut?.unlink(); + return cut; + } + + /// Isolates a new leaf node starting at [index] with specified [length]. + /// + /// Splitting logic is identical to one described in [splitAt], with one + /// exception that it is required for [index] to always be less than this + /// node's length. As a result this method always returns a [LeafNode] + /// instance. Note that returned node may still be the same as this node + /// if provided [index] is `0`. + LeafNode isolate(int index, int length) { + assert( + index >= 0 && index < this.length && (index + length <= this.length), + 'Index or length is out of bounds. Index: $index, length: $length. ' + 'Actual node length: ${this.length}.'); + // Since `index < this.length` (guarded by assert) below line + // always returns a new node. + final target = splitAt(index); + target.splitAt(length); + return target; + } + + /// Formats this node and optimizes it with adjacent leaf nodes if needed. + void formatAndOptimize(NotusStyle style) { + if (style != null && style.isNotEmpty) { + applyStyle(style); + } + optimize(); + } + + @override + void applyStyle(NotusStyle value) { + assert(value != null && (value.isInline || value.isEmpty), + 'Style cannot be applied to this leaf node: $value'); + assert(() { + if (value.contains(NotusAttribute.embed)) { + if (value.get(NotusAttribute.embed) == NotusAttribute.embed.unset) { + throw 'Unsetting embed attribute is not allowed. ' + 'This operation means that the embed itself must be deleted from the document. ' + 'Make sure there is FormatEmbedsRule in your heuristics registry, ' + 'which is responsible for handling this scenario.'; + } + if (this is! EmbedNode) { + throw 'Embed style can only be applied to an EmbedNode.'; + } + } + return true; + }()); + + super.applyStyle(value); + } + + @override + LineNode get parent => super.parent as LineNode; + + @override + int get length => _value.length; + + @override + Delta toDelta() { + return Delta()..insert(_value, style.toJson()); + } + + @override + String toPlainText() => _value; + + @override + void insert(int index, String value, NotusStyle style) { + assert(index >= 0 && (index <= length), 'Index: $index, Length: $length.'); + assert(value.isNotEmpty); + final node = LeafNode(value); + if (index == length) { + insertAfter(node); + } else { + splitAt(index).insertBefore(node); + } + node.formatAndOptimize(style); + } + + @override + void retain(int index, int length, NotusStyle style) { + if (style == null) return; + + final local = math.min(this.length - index, length); + final node = isolate(index, local); + + final remaining = length - local; + if (remaining > 0) { + assert(node.next != null); + node.next.retain(0, remaining, style); + } + // Optimize at the very end + node.formatAndOptimize(style); + } + + @override + void delete(int index, int length) { + assert(index < this.length); + + final local = math.min(this.length - index, length); + final target = isolate(index, local); + // Memorize siblings before un-linking. + final needsOptimize = target.previous; + final actualNext = target.next; + target.unlink(); + + final remaining = length - local; + if (remaining > 0) { + assert(actualNext != null); + actualNext.delete(0, remaining); + } + + if (needsOptimize != null) needsOptimize.optimize(); + } + + @override + String toString() { + final keys = style.keys.toList(growable: false)..sort(); + final styleKeys = keys.join(); + return '⟨$value⟩$styleKeys'; + } + + /// Optimizes this text node by merging it with adjacent nodes if they share + /// the same style. + @override + void optimize() { + var node = this; + if (!node.isFirst) { + LeafNode mergeWith = node.previous; + if (mergeWith.style == node.style) { + mergeWith._value += node.value; + node.unlink(); + node = mergeWith; + } + } + if (!node.isLast) { + LeafNode mergeWith = node.next; + if (mergeWith.style == node.style) { + node._value += mergeWith._value; + mergeWith.unlink(); + } + } + } +} + +/// A span of formatted text within a line in a Notus document. +/// +/// TextNode is a leaf node of a document tree. +/// +/// Parent of a text node is always a [LineNode], and as a consequence text +/// node's [value] cannot contain any line-break characters. +/// +/// See also: +/// +/// * [LineNode], a node representing a line of text. +/// * [BlockNode], a node representing a group of lines. +class TextNode extends LeafNode { + TextNode([String content = '']) : super._(content); +} + +final kZeroWidthSpace = String.fromCharCode(0x200b); + +/// An embed node inside of a line in a Notus document. +/// +/// Embed node is a leaf node similar to [TextNode]. It represents an +/// arbitrary piece of non-text content embedded into a document, such as, +/// image, horizontal rule, video, or any other object with defined structure, +/// like tweet, for instance. +/// +/// Embed node's length is always `1` character and it is represented with +/// zero-width space in the document text. +/// +/// Any inline style can be applied to an embed, however this does not +/// necessarily mean the embed will look according to that style. For instance, +/// applying "bold" style to an image gives no effect, while adding a "link" to +/// an image actually makes the image react to user's action. +class EmbedNode extends LeafNode { + static final kPlainTextPlaceholder = String.fromCharCode(0x200b); + + EmbedNode() : super._(kPlainTextPlaceholder); +} diff --git a/lib/notus/src/document/line.dart b/lib/notus/src/document/line.dart new file mode 100644 index 0000000..615b66d --- /dev/null +++ b/lib/notus/src/document/line.dart @@ -0,0 +1,337 @@ +// 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:math' as math; + +import 'package:quill_markdown/notus/notus.dart'; +import 'package:quill_markdown/quill_delta/quill_delta.dart'; + +import 'attributes.dart'; +import 'block.dart'; +import 'leaf.dart'; +import 'node.dart'; + +/// A line of rich text in a Notus document. +/// +/// LineNode serves as a container for [LeafNode]s, like [TextNode] and +/// [EmbedNode]. +/// +/// When a line contains an embed, it fully occupies the line, no other embeds +/// or text nodes are allowed. +class LineNode extends ContainerNode + with StyledNodeMixin + implements StyledNode { + /// Returns `true` if this line contains an embed. + bool get hasEmbed { + if (childCount == 1) { + return children.single is EmbedNode; + } + assert(children.every((child) => child is TextNode)); + return false; + } + + /// Returns next [LineNode] or `null` if this is the last line in the document. + LineNode get nextLine { + if (isLast) { + if (parent is BlockNode) { + if (parent.isLast) return null; + LineNode line = (parent.next is BlockNode) + ? (parent.next as BlockNode).first + : parent.next; + return line; + } else { + return null; + } + } else { + LineNode line = (next is BlockNode) ? (next as BlockNode).first : next; + return line; + } + } + + /// Creates new empty [LineNode] with the same style. + LineNode clone() { + final node = LineNode(); + node.applyStyle(style); + return node; + } + + /// Splits this line into two at specified character [index]. + /// + /// This is an equivalent of inserting a line-break character at [index]. + LineNode splitAt(int index) { + assert(index == 0 || (index > 0 && index < length), + 'Index is out of bounds. Index: $index. Actual node length: ${length}.'); + + final line = clone(); + insertAfter(line); + if (index == length - 1) return line; + + final split = lookup(index); + while (!split.node.isLast) { + LeafNode child = last; + child.unlink(); + line.addFirst(child); + } + LeafNode child = split.node; + line.addFirst(child.cutAt(split.offset)); + return line; + } + + /// Unwraps this line from it's parent [BlockNode]. + /// + /// This method asserts if current [parent] of this line is not a [BlockNode]. + void unwrap() { + assert(parent is BlockNode); + BlockNode block = parent; + block.unwrapLine(this); + } + + /// Wraps this line with new parent [block]. + /// + /// This line can not be in a [BlockNode] when this method is called. + void wrap(BlockNode block) { + assert(parent != null && parent is! BlockNode); + insertAfter(block); + unlink(); + block.add(this); + } + + /// Returns style for specified text range. + /// + /// Only attributes applied to all characters within this range are + /// included in the result. Inline and line level attributes are + /// handled separately, e.g.: + /// + /// - line attribute X is included in the result only if it exists for + /// every line within this range (partially included lines are counted). + /// - inline attribute X is included in the result only if it exists + /// for every character within this range (line-break characters excluded). + NotusStyle collectStyle(int offset, int length) { + final local = math.min(this.length - offset, length); + + var result = NotusStyle(); + final excluded = {}; + + void _handle(NotusStyle style) { + if (result.isEmpty) { + excluded.addAll(style.values); + } else { + for (var attr in result.values) { + if (!style.contains(attr)) { + excluded.add(attr); + } + } + } + final remaining = style.removeAll(excluded); + result = result.removeAll(excluded); + result = result.mergeAll(remaining); + } + + final data = lookup(offset, inclusive: true); + LeafNode node = data.node; + if (node != null) { + result = result.mergeAll(node.style); + var pos = node.length - data.offset; + while (!node.isLast && pos < local) { + node = node.next as LeafNode; + _handle(node.style); + pos += node.length; + } + } + + result = result.mergeAll(style); + if (parent is BlockNode) { + BlockNode block = parent; + result = result.mergeAll(block.style); + } + + final remaining = length - local; + if (remaining > 0) { + final rest = nextLine.collectStyle(0, remaining); + _handle(rest); + } + + return result; + } + + @override + LeafNode get defaultChild => TextNode(); + + // TODO: should be able to cache length and invalidate on any child-related operation + @override + int get length => super.length + 1; + + @override + Delta toDelta() { + final delta = children + .map((text) => text.toDelta()) + .fold(Delta(), (a, b) => a.concat(b)); + var attributes = style; + if (parent is BlockNode) { + BlockNode block = parent; + attributes = attributes.mergeAll(block.style); + } + delta.insert('\n', attributes.toJson()); + return delta; + } + + @override + String toPlainText() => super.toPlainText() + '\n'; + + @override + String toString() { + final body = children.join(' → '); + final styleString = style.isNotEmpty ? ' $style' : ''; + return '¶ $body ⏎$styleString'; + } + + @override + void optimize() { + // No-op, line merging is done in insert/delete operations + } + + @override + void insert(int index, String text, NotusStyle style) { + final lf = text.indexOf('\n'); + if (lf == -1) { + _insertSafe(index, text, style); + // No need to update line or block format since those attributes can only + // be attached to `\n` character and we already know it's not present. + return; + } + + final substring = text.substring(0, lf); + _insertSafe(index, substring, style); + if (substring.isNotEmpty) index += substring.length; + + final nextLine = splitAt(index); // Next line inherits our format. + + // Reset our format and unwrap from a block if needed. + clearStyle(); + if (parent is BlockNode) unwrap(); + + // Now we can apply new format and re-layout. + _formatAndOptimize(style); + + // Continue with remaining part. + final remaining = text.substring(lf + 1); + nextLine.insert(0, remaining, style); + } + + @override + void retain(int index, int length, NotusStyle style) { + if (style == null) return; + final thisLength = this.length; + + final local = math.min(thisLength - index, length); + // If index is at line-break character this is line/block format update. + final isLineFormat = (index + local == thisLength) && local == 1; + + if (isLineFormat) { + assert( + style.values.every((attr) => attr.scope == NotusAttributeScope.line), + 'It is not allowed to apply inline attributes to line itself.'); + _formatAndOptimize(style); + } else { + // otherwise forward to children as it's inline format update. + assert(index + local != thisLength, + 'It is not allowed to apply inline attributes to line itself.'); + assert(style.values + .every((attr) => attr.scope == NotusAttributeScope.inline)); + super.retain(index, local, style); + } + + final remaining = length - local; + if (remaining > 0) { + assert(nextLine != null); + nextLine.retain(0, remaining, style); + } + } + + @override + void delete(int index, int length) { + final local = math.min(this.length - index, length); + final isLFDeleted = (index + local == this.length); + if (isLFDeleted) { + // Our line-break deleted with all style information. + clearStyle(); + if (local > 1) { + // Exclude line-break from delete range for children. + super.delete(index, local - 1); + } + } else { + super.delete(index, local); + } + + final remaining = length - local; + if (remaining > 0) { + assert(nextLine != null); + nextLine.delete(0, remaining); + } + if (isLFDeleted && isNotEmpty) { + // Since we lost our line-break and still have child text nodes those must + // migrate to the next line. + + // nextLine might have been unmounted since last assert so we need to + // check again we still have a line after us. + assert(nextLine != null); + + // Move remaining stuff in this line to next line so that all attributes + // of nextLine are preserved. + nextLine.moveChildren(this); // TODO: avoid double move + moveChildren(nextLine); + } + + if (isLFDeleted) { + // Now we can remove this line. + final block = parent; // remember reference before un-linking. + unlink(); + block.optimize(); + } + } + + /// Formats this line and optimizes layout afterwards. + void _formatAndOptimize(NotusStyle newStyle) { + if (newStyle == null || newStyle.isEmpty) return; + + applyStyle(newStyle); + if (!newStyle.contains(NotusAttribute.block)) { + return; + } // no block-level changes + + final blockStyle = newStyle.get(NotusAttribute.block); + if (parent is BlockNode) { + final parentStyle = (parent as BlockNode).style.get(NotusAttribute.block); + if (blockStyle == NotusAttribute.block.unset) { + unwrap(); + } else if (blockStyle != parentStyle) { + unwrap(); + final block = BlockNode(); + block.applyAttribute(blockStyle); + wrap(block); + block.optimize(); + } // else the same style, no-op. + } else if (blockStyle != NotusAttribute.block.unset) { + // Only wrap with a new block if this is not an unset + final block = BlockNode(); + block.applyAttribute(blockStyle); + wrap(block); + block.optimize(); + } + } + + void _insertSafe(int index, String text, NotusStyle style) { + assert(index == 0 || (index > 0 && index < length)); + assert(text.contains('\n') == false); + if (text.isEmpty) return; + + if (isEmpty) { + final child = LeafNode(text); + add(child); + child.formatAndOptimize(style); + } else { + final result = lookup(index, inclusive: true); + result.node.insert(result.offset, text, style); + } + } +} diff --git a/lib/notus/src/document/node.dart b/lib/notus/src/document/node.dart new file mode 100644 index 0000000..2662f7a --- /dev/null +++ b/lib/notus/src/document/node.dart @@ -0,0 +1,311 @@ +// 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:collection'; + +import 'package:meta/meta.dart'; +import 'package:quill_markdown/notus/notus.dart'; +import 'package:quill_markdown/quill_delta/quill_delta.dart'; + +import 'attributes.dart'; +import 'line.dart'; + +/// An abstract node in a document tree. +/// +/// Represents a segment of a Notus document with specified [offset] +/// and [length]. +/// +/// The [offset] property is relative to [parent]. See also [documentOffset] +/// which provides absolute offset of this node within the document. +/// +/// The current parent node is exposed by the [parent] property. A node is +/// considered [mounted] when the [parent] property is not `null`. +abstract class Node extends LinkedListEntry { + /// Current parent of this node. May be null if this node is not mounted. + ContainerNode get parent => _parent; + ContainerNode _parent; + + /// Returns `true` if this node is the first node in the [parent] list. + bool get isFirst => list.first == this; + + /// Returns `true` if this node is the last node in the [parent] list. + bool get isLast => list.last == this; + + /// Length of this node in characters. + int get length; + + /// Returns `true` if this node is currently mounted, e.g. [parent] is not + /// `null`. + bool get mounted => _parent != null; + + /// Offset in characters of this node relative to [parent] node. + /// + /// To get offset of this node in the document see [documentOffset]. + int get offset { + if (isFirst) return 0; + var offset = 0; + var node = this; + do { + node = node.previous; + offset += node.length; + } while (!node.isFirst); + return offset; + } + + /// Offset in characters of this node in the document. + int get documentOffset { + final parentOffset = (_parent is! RootNode) ? _parent.documentOffset : 0; + return parentOffset + offset; + } + + /// Returns `true` if this node contains character at specified [offset] in + /// the document. + bool containsOffset(int offset) { + final o = documentOffset; + return o <= offset && offset < o + length; + } + + /// Optimize this node within [parent]. + /// + /// Subclasses should override this method to perform necessary optimizations. + @protected + void optimize(); + + /// Returns [Delta] representation of this node. + Delta toDelta(); + + /// Returns plain-text representation of this node. + String toPlainText(); + + /// Insert [text] at specified character [index] with style [style]. + void insert(int index, String text, NotusStyle style); + + /// Format [length] characters of this node starting from [index] with + /// specified style [style]. + void retain(int index, int length, NotusStyle style); + + /// Delete [length] characters of this node starting from [index]. + void delete(int index, int length); + + @override + void insertBefore(Node entry) { + assert(entry._parent == null && _parent != null); + entry._parent = _parent; + super.insertBefore(entry); + } + + @override + void insertAfter(Node entry) { + assert(entry._parent == null && _parent != null); + entry._parent = _parent; + super.insertAfter(entry); + } + + @override + void unlink() { + assert(_parent != null); + _parent = null; + super.unlink(); + } +} + +/// Result of a child lookup in a [ContainerNode]. +class LookupResult { + /// The child node if found, otherwise `null`. + final Node node; + + /// Starting offset within the child [node] which points at the same + /// character in the document as the original offset passed to + /// [ContainerNode.lookup] method. + final int offset; + + LookupResult(this.node, this.offset); + + /// Returns `true` if there is no child node found, e.g. [node] is `null`. + bool get isEmpty => node == null; + + /// Returns `true` [node] is not `null`. + bool get isNotEmpty => node != null; +} + +/// Container node can accommodate other nodes. +/// +/// Delegates insert, retain and delete operations to children nodes. For each +/// operation container looks for a child at specified index position and +/// forwards operation to that child. +/// +/// Most of the operation handling logic is implemented by [LineNode] and +/// [TextNode]. +abstract class ContainerNode extends Node { + final LinkedList _children = LinkedList(); + + /// List of children. + LinkedList get children => _children; + + /// Returns total number of child nodes in this container. + /// + /// To get text length of this container see [length]. + int get childCount => _children.length; + + /// Returns the first child [Node]. + Node get first => _children.first; + + /// Returns the last child [Node]. + Node get last => _children.last; + + /// Returns an instance of default child for this container node. + /// + /// Always returns fresh instance. + T get defaultChild; + + /// Returns `true` if this container has no child nodes. + bool get isEmpty => _children.isEmpty; + + /// Returns `true` if this container has at least 1 child. + bool get isNotEmpty => _children.isNotEmpty; + + /// Adds [node] to the end of this container children list. + void add(T node) { + assert(node._parent == null); + node._parent = this; + _children.add(node); + } + + /// Adds [node] to the beginning of this container children list. + void addFirst(T node) { + assert(node._parent == null); + node._parent = this; + _children.addFirst(node); + } + + /// Removes [node] from this container. + void remove(T node) { + assert(node._parent == this); + node._parent = null; + _children.remove(node); + } + + /// Moves children of this node to [newParent]. + void moveChildren(ContainerNode newParent) { + if (isEmpty) return; + T toBeOptimized = newParent.isEmpty ? null : newParent.last; + while (isNotEmpty) { + T child = first; + child.unlink(); + newParent.add(child); + } + + /// In case [newParent] already had children we need to make sure + /// combined list is optimized. + if (toBeOptimized != null) toBeOptimized.optimize(); + } + + /// Looks up a child [Node] at specified character [offset] in this container. + /// + /// Returns [LookupResult]. The result may contain found node or `null` if + /// no node is found at specified offset. + /// + /// [LookupResult.offset] is set to relative offset within returned child node + /// which points at the same character position in the document as the + /// original [offset]. + LookupResult lookup(int offset, {bool inclusive = false}) { + assert(offset >= 0 && offset <= length); + + for (final node in children) { + final length = node.length; + if (offset < length || (inclusive && offset == length && (node.isLast))) { + return LookupResult(node, offset); + } + offset -= length; + } + return LookupResult(null, 0); + } + + // + // Overridden members + // + + @override + String toPlainText() => children.map((child) => child.toPlainText()).join(); + + /// Content length of this node's children. To get number of children in this + /// node use [childCount]. + @override + int get length => _children.fold(0, (current, node) => current + node.length); + + @override + void insert(int index, String value, NotusStyle style) { + assert(index == 0 || (index > 0 && index < length)); + + if (isEmpty) { + assert(index == 0); + final node = defaultChild; + add(node); + node.insert(index, value, style); + } else { + final result = lookup(index); + result.node.insert(result.offset, value, style); + } + } + + @override + void retain(int index, int length, NotusStyle attributes) { + assert(isNotEmpty); + final res = lookup(index); + res.node.retain(res.offset, length, attributes); + } + + @override + void delete(int index, int length) { + assert(isNotEmpty); + final res = lookup(index); + res.node.delete(res.offset, length); + } + + @override + String toString() => _children.join('\n'); +} + +/// An interface for document nodes with style. +abstract class StyledNode implements Node { + /// Style of this node. + NotusStyle get style; +} + +/// Mixin used by nodes that wish to implement [StyledNode] interface. +abstract class StyledNodeMixin implements StyledNode { + @override + NotusStyle get style => _style; + NotusStyle _style = NotusStyle(); + + /// Applies style [attribute] to this node. + void applyAttribute(NotusAttribute attribute) { + _style = _style.merge(attribute); + } + + /// Applies new style [value] to this node. Provided [value] is merged + /// into current style. + void applyStyle(NotusStyle value) { + assert(value != null); + _style = _style.mergeAll(value); + } + + /// Clears style of this node. + void clearStyle() { + _style = NotusStyle(); + } +} + +/// Root node of document tree. +class RootNode extends ContainerNode> { + @override + ContainerNode get defaultChild => LineNode(); + + @override + void optimize() {/* no-op */} + + @override + Delta toDelta() => children + .map((child) => child.toDelta()) + .fold(Delta(), (a, b) => a.concat(b)); +} diff --git a/lib/notus/src/heuristics.dart b/lib/notus/src/heuristics.dart new file mode 100644 index 0000000..498e6bf --- /dev/null +++ b/lib/notus/src/heuristics.dart @@ -0,0 +1,90 @@ +// 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:quill_markdown/notus/notus.dart'; +import 'package:quill_markdown/quill_delta/quill_delta.dart'; + +import 'heuristics/delete_rules.dart'; +import 'heuristics/format_rules.dart'; +import 'heuristics/insert_rules.dart'; + +/// Registry for insert, format and delete heuristic rules used by +/// [NotusDocument] documents. +class NotusHeuristics { + /// Default set of heuristic rules. + static const NotusHeuristics fallback = NotusHeuristics( + formatRules: [ + FormatEmbedsRule(), + FormatLinkAtCaretPositionRule(), + ResolveLineFormatRule(), + ResolveInlineFormatRule(), + // No need in catch-all rule here since the above rules cover all + // attributes. + ], + insertRules: [ + PreserveBlockStyleOnPasteRule(), + ForceNewlineForInsertsAroundEmbedRule(), + PreserveLineStyleOnSplitRule(), + AutoExitBlockRule(), + ResetLineFormatOnNewLineRule(), + AutoFormatLinksRule(), + PreserveInlineStylesRule(), + CatchAllInsertRule(), + ], + deleteRules: [ + EnsureEmbedLineRule(), + PreserveLineStyleOnMergeRule(), + CatchAllDeleteRule(), + ], + ); + + const NotusHeuristics({ + this.formatRules, + this.insertRules, + this.deleteRules, + }); + + /// List of format rules in this registry. + final List formatRules; + + /// List of insert rules in this registry. + final List insertRules; + + /// List of delete rules in this registry. + final List deleteRules; + + /// Applies heuristic rules to specified insert operation based on current + /// state of Notus [document]. + Delta applyInsertRules(NotusDocument document, int index, String insert) { + final delta = document.toDelta(); + for (var rule in insertRules) { + final result = rule.apply(delta, index, insert); + if (result != null) return result..trim(); + } + throw StateError('Failed to apply insert heuristic rules: none applied.'); + } + + /// Applies heuristic rules to specified format operation based on current + /// state of Notus [document]. + Delta applyFormatRules( + NotusDocument document, int index, int length, NotusAttribute value) { + final delta = document.toDelta(); + for (var rule in formatRules) { + final result = rule.apply(delta, index, length, value); + if (result != null) return result..trim(); + } + throw StateError('Failed to apply format heuristic rules: none applied.'); + } + + /// Applies heuristic rules to specified delete operation based on current + /// state of Notus [document]. + Delta applyDeleteRules(NotusDocument document, int index, int length) { + final delta = document.toDelta(); + for (var rule in deleteRules) { + final result = rule.apply(delta, index, length); + if (result != null) return result..trim(); + } + throw StateError('Failed to apply delete heuristic rules: none applied.'); + } +} diff --git a/lib/notus/src/heuristics/delete_rules.dart b/lib/notus/src/heuristics/delete_rules.dart new file mode 100644 index 0000000..27307fc --- /dev/null +++ b/lib/notus/src/heuristics/delete_rules.dart @@ -0,0 +1,133 @@ +// 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:quill_markdown/notus/notus.dart'; +import 'package:quill_markdown/quill_delta/quill_delta.dart'; + +/// A heuristic rule for delete operations. +abstract class DeleteRule { + /// Constant constructor allows subclasses to declare constant constructors. + const DeleteRule(); + + /// Applies heuristic rule to a delete operation on a [document] and returns + /// resulting [Delta]. + Delta apply(Delta document, int index, int length); +} + +/// Fallback rule for delete operations which simply deletes specified text +/// range without any special handling. +class CatchAllDeleteRule extends DeleteRule { + const CatchAllDeleteRule(); + + @override + Delta apply(Delta document, int index, int length) { + return Delta() + ..retain(index) + ..delete(length); + } +} + +/// Preserves line format when user deletes the line's line-break character +/// effectively merging it with the next line. +/// +/// This rule makes sure to apply all style attributes of deleted line-break +/// to the next available line-break, which may reset any style attributes +/// already present there. +class PreserveLineStyleOnMergeRule extends DeleteRule { + const PreserveLineStyleOnMergeRule(); + + @override + Delta apply(Delta document, int index, int length) { + final iter = DeltaIterator(document); + iter.skip(index); + final target = iter.next(1); + if (target.data != '\n') return null; + iter.skip(length - 1); + final result = Delta() + ..retain(index) + ..delete(length); + + // Look for next line-break to apply the attributes + while (iter.hasNext) { + final op = iter.next(); + final lf = op.data.indexOf('\n'); + if (lf == -1) { + result..retain(op.length); + continue; + } + var attributes = _unsetAttributes(op.attributes); + if (target.isNotPlain) { + attributes ??= {}; + attributes.addAll(target.attributes); + } + result..retain(lf)..retain(1, attributes); + break; + } + return result; + } + + Map _unsetAttributes(Map attributes) { + if (attributes == null) return null; + return attributes.map( + (String key, dynamic value) => MapEntry(key, null)); + } +} + +/// Prevents user from merging line containing an embed with other lines. +class EnsureEmbedLineRule extends DeleteRule { + const EnsureEmbedLineRule(); + + @override + Delta apply(Delta document, int index, int length) { + final iter = DeltaIterator(document); + + // First, check if line-break deleted after an embed. + var op = iter.skip(index); + var indexDelta = 0; + var lengthDelta = 0; + var remaining = length; + var foundEmbed = false; + var hasLineBreakBefore = false; + if (op != null && op.data.endsWith(kZeroWidthSpace)) { + foundEmbed = true; + var candidate = iter.next(1); + remaining--; + if (candidate.data == '\n') { + indexDelta += 1; + lengthDelta -= 1; + + /// Check if it's an empty line + candidate = iter.next(1); + remaining--; + if (candidate.data == '\n') { + // Allow deleting empty line after an embed. + lengthDelta += 1; + } + } + } else { + // If op is `null` it's a beginning of the doc, e.g. implicit line break. + hasLineBreakBefore = op == null || op.data.endsWith('\n'); + } + + // Second, check if line-break deleted before an embed. + op = iter.skip(remaining); + if (op != null && op.data.endsWith('\n')) { + final candidate = iter.next(1); + // If there is a line-break before deleted range we allow the operation + // since it results in a correctly formatted line with single embed in it. + if (candidate.data == kZeroWidthSpace && !hasLineBreakBefore) { + foundEmbed = true; + lengthDelta -= 1; + } + } + + if (foundEmbed) { + return Delta() + ..retain(index + indexDelta) + ..delete(length + lengthDelta); + } + + return null; // fallback + } +} diff --git a/lib/notus/src/heuristics/format_rules.dart b/lib/notus/src/heuristics/format_rules.dart new file mode 100644 index 0000000..900a88c --- /dev/null +++ b/lib/notus/src/heuristics/format_rules.dart @@ -0,0 +1,218 @@ +// 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:quill_markdown/notus/notus.dart'; +import 'package:quill_markdown/quill_delta/quill_delta.dart'; + +/// A heuristic rule for format (retain) operations. +abstract class FormatRule { + /// Constant constructor allows subclasses to declare constant constructors. + const FormatRule(); + + /// Applies heuristic rule to a retain (format) operation on a [document] and + /// returns resulting [Delta]. + Delta apply(Delta document, int index, int length, NotusAttribute attribute); +} + +/// Produces Delta with line-level attributes applied strictly to +/// line-break characters. +class ResolveLineFormatRule extends FormatRule { + const ResolveLineFormatRule() : super(); + + @override + Delta apply(Delta document, int index, int length, NotusAttribute attribute) { + if (attribute.scope != NotusAttributeScope.line) return null; + + var result = Delta()..retain(index); + final iter = DeltaIterator(document); + iter.skip(index); + + // Apply line styles to all line-break characters within range of this + // retain operation. + var current = 0; + while (current < length && iter.hasNext) { + final op = iter.next(length - current); + if (op.data.contains('\n')) { + final delta = _applyAttribute(op.data, attribute); + result = result.concat(delta); + } else { + result.retain(op.length); + } + current += op.length; + } + // And include extra line-break after retain + while (iter.hasNext) { + final op = iter.next(); + final lf = op.data.indexOf('\n'); + if (lf == -1) { + result..retain(op.length); + continue; + } + result..retain(lf)..retain(1, attribute.toJson()); + break; + } + return result; + } + + Delta _applyAttribute(String text, NotusAttribute attribute) { + final result = Delta(); + var offset = 0; + var lf = text.indexOf('\n'); + while (lf >= 0) { + result..retain(lf - offset)..retain(1, attribute.toJson()); + offset = lf + 1; + lf = text.indexOf('\n', offset); + } + // Retain any remaining characters in text + result.retain(text.length - offset); + return result; + } +} + +/// Produces Delta with inline-level attributes applied too all characters +/// except line-breaks. +class ResolveInlineFormatRule extends FormatRule { + const ResolveInlineFormatRule(); + + @override + Delta apply(Delta document, int index, int length, NotusAttribute attribute) { + if (attribute.scope != NotusAttributeScope.inline) return null; + + final result = Delta()..retain(index); + final iter = DeltaIterator(document); + iter.skip(index); + + // Apply inline styles to all non-line-break characters within range of this + // retain operation. + var current = 0; + while (current < length && iter.hasNext) { + final op = iter.next(length - current); + var lf = op.data.indexOf('\n'); + if (lf != -1) { + var pos = 0; + while (lf != -1) { + result..retain(lf - pos, attribute.toJson())..retain(1); + pos = lf + 1; + lf = op.data.indexOf('\n', pos); + } + if (pos < op.length) result.retain(op.length - pos, attribute.toJson()); + } else { + result.retain(op.length, attribute.toJson()); + } + current += op.length; + } + + return result; + } +} + +/// Allows updating link format with collapsed selection. +class FormatLinkAtCaretPositionRule extends FormatRule { + const FormatLinkAtCaretPositionRule(); + + @override + Delta apply(Delta document, int index, int length, NotusAttribute attribute) { + if (attribute.key != NotusAttribute.link.key) return null; + // If user selection is not collapsed we let it fallback to default rule + // which simply applies the attribute to selected range. + // This may still not be a bulletproof approach as selection can span + // multiple lines or be a subset of existing link-formatted text. + // So certain improvements can be made in the future to account for such + // edge cases. + if (length != 0) return null; + + final result = Delta(); + final iter = DeltaIterator(document); + final before = iter.skip(index); + final after = iter.next(); + var startIndex = index; + var retain = 0; + if (before != null && before.hasAttribute(attribute.key)) { + startIndex -= before.length; + retain = before.length; + } + if (after != null && after.hasAttribute(attribute.key)) { + retain += after.length; + } + // There is no link-styled text around `index` position so it becomes a + // no-op action. + if (retain == 0) return null; + + result..retain(startIndex)..retain(retain, attribute.toJson()); + + return result; + } +} + +/// Handles all format operations which manipulate embeds. +class FormatEmbedsRule extends FormatRule { + const FormatEmbedsRule(); + + @override + Delta apply(Delta document, int index, int length, NotusAttribute attribute) { + // We are only interested in embed attributes + if (attribute is! EmbedAttribute) return null; + EmbedAttribute embed = attribute; + + if (length == 1 && embed.isUnset) { + // Remove the embed. + return Delta() + ..retain(index) + ..delete(length); + } else { + // If length is 0 we treat it as an insert at specified [index]. + // If length is non-zero we treat it as a replace of selected range + // with the embed. + assert(!embed.isUnset); + return _insertEmbed(document, index, length, embed); + } + } + + Delta _insertEmbed( + Delta document, int index, int length, EmbedAttribute embed) { + final result = Delta()..retain(index); + final iter = DeltaIterator(document); + final previous = iter.skip(index); + iter.skip(length); // ignore deleted part. + final target = iter.next(); + + // Check if [index] is on an empty line already. + final isNewlineBefore = previous == null || previous.data.endsWith('\n'); + final isNewlineAfter = target.data.startsWith('\n'); + final isOnEmptyLine = isNewlineBefore && isNewlineAfter; + if (isOnEmptyLine) { + return result..insert(EmbedNode.kPlainTextPlaceholder, embed.toJson()); + } + // We are on a non-empty line, split it (preserving style if needed) + // and insert our embed. + final lineStyle = _getLineStyle(iter, target); + if (!isNewlineBefore) { + result..insert('\n', lineStyle); + } + result..insert(EmbedNode.kPlainTextPlaceholder, embed.toJson()); + if (!isNewlineAfter) { + result..insert('\n'); + } + result.delete(length); + return result; + } + + Map _getLineStyle( + DeltaIterator iterator, Operation current) { + if (current.data.contains('\n')) { + return current.attributes; + } + // Continue looking for line-break. + Map attributes; + while (iterator.hasNext) { + final op = iterator.next(); + final lf = op.data.indexOf('\n'); + if (lf >= 0) { + attributes = op.attributes; + break; + } + } + return attributes; + } +} diff --git a/lib/notus/src/heuristics/insert_rules.dart b/lib/notus/src/heuristics/insert_rules.dart new file mode 100644 index 0000000..faf0888 --- /dev/null +++ b/lib/notus/src/heuristics/insert_rules.dart @@ -0,0 +1,329 @@ +// 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:quill_markdown/notus/notus.dart'; +import 'package:quill_markdown/quill_delta/quill_delta.dart'; + +/// A heuristic rule for insert operations. +abstract class InsertRule { + /// Constant constructor allows subclasses to declare constant constructors. + const InsertRule(); + + /// Applies heuristic rule to an insert operation on a [document] and returns + /// resulting [Delta]. + Delta apply(Delta document, int index, String text); +} + +/// Fallback rule which simply inserts text as-is without any special handling. +class CatchAllInsertRule extends InsertRule { + const CatchAllInsertRule(); + + @override + Delta apply(Delta document, int index, String text) { + return Delta() + ..retain(index) + ..insert(text); + } +} + +/// Preserves line format when user splits the line into two. +/// +/// This rule ignores scenarios when the line is split on its edge, meaning +/// a line-break is inserted at the beginning or the end of the line. +class PreserveLineStyleOnSplitRule extends InsertRule { + const PreserveLineStyleOnSplitRule(); + + bool isEdgeLineSplit(Operation before, Operation after) { + if (before == null) return true; // split at the beginning of a doc + return before.data.endsWith('\n') || after.data.startsWith('\n'); + } + + @override + Delta apply(Delta document, int index, String text) { + if (text != '\n') return null; + + final iter = DeltaIterator(document); + final before = iter.skip(index); + final after = iter.next(); + if (isEdgeLineSplit(before, after)) return null; + final result = Delta()..retain(index); + if (after.data.contains('\n')) { + // It is not allowed to combine line and inline styles in insert + // operation containing line-break together with other characters. + // The only scenario we get such operation is when the text is plain. + assert(after.isPlain); + // No attributes to apply so we simply create a new line. + result.insert('\n'); + return result; + } + // Continue looking for line-break. + Map attributes; + while (iter.hasNext) { + final op = iter.next(); + final lf = op.data.indexOf('\n'); + if (lf >= 0) { + attributes = op.attributes; + break; + } + } + result.insert('\n', attributes); + return result; + } +} + +/// of a line (right before a line-break). + +/// Resets format for a newly inserted line when insert occurred at the end +class ResetLineFormatOnNewLineRule extends InsertRule { + const ResetLineFormatOnNewLineRule(); + + @override + Delta apply(Delta document, int index, String text) { + if (text != '\n') return null; + + final iter = DeltaIterator(document); + iter.skip(index); + final target = iter.next(); + + if (target.data.startsWith('\n')) { + Map resetStyle; + if (target.attributes != null && + target.attributes.containsKey(NotusAttribute.heading.key)) { + resetStyle = NotusAttribute.heading.unset.toJson(); + } + return Delta() + ..retain(index) + ..insert('\n', target.attributes) + ..retain(1, resetStyle) + ..trim(); + } + return null; + } +} + +/// Heuristic rule to exit current block when user inserts two consecutive +/// line-breaks. +// TODO: update this rule to handle code blocks differently, at least allow 3 consecutive line-breaks before exiting. +class AutoExitBlockRule extends InsertRule { + const AutoExitBlockRule(); + + bool isEmptyLine(Operation previous, Operation target) { + return (previous == null || previous.data.endsWith('\n')) && + target.data.startsWith('\n'); + } + + @override + Delta apply(Delta document, int index, String text) { + if (text != '\n') return null; + + final iter = DeltaIterator(document); + final previous = iter.skip(index); + final target = iter.next(); + final isInBlock = target.isNotPlain && + target.attributes.containsKey(NotusAttribute.block.key); + if (isEmptyLine(previous, target) && isInBlock) { + // We reset block style even if this line is not the last one in it's + // block which effectively splits the block into two. + // TODO: For code blocks this should not split the block but allow inserting as many lines as needed. + var attributes; + if (target.attributes != null) { + attributes = target.attributes; + } else { + attributes = {}; + } + attributes.addAll(NotusAttribute.block.unset.toJson()); + return Delta()..retain(index)..retain(1, attributes); + } + return null; + } +} + +/// Preserves inline styles when user inserts text inside formatted segment. +class PreserveInlineStylesRule extends InsertRule { + const PreserveInlineStylesRule(); + + @override + Delta apply(Delta document, int index, String text) { + // This rule is only applicable to characters other than line-break. + if (text.contains('\n')) return null; + + final iter = DeltaIterator(document); + final previous = iter.skip(index); + // If there is a line-break in previous chunk, there should be no inline + // styles. Also if there is no previous operation we are at the beginning + // of the document so no styles to inherit from. + if (previous == null || previous.data.contains('\n')) return null; + + final attributes = previous.attributes; + final hasLink = + (attributes != null && attributes.containsKey(NotusAttribute.link.key)); + if (!hasLink) { + return Delta() + ..retain(index) + ..insert(text, attributes); + } + // Special handling needed for inserts inside fragments with link attribute. + // Link style should only be preserved if insert occurs inside the fragment. + // Link style should NOT be preserved on the boundaries. + var noLinkAttributes = previous.attributes; + noLinkAttributes.remove(NotusAttribute.link.key); + final noLinkResult = Delta() + ..retain(index) + ..insert(text, noLinkAttributes.isEmpty ? null : noLinkAttributes); + final next = iter.next(); + if (next == null) { + // Nothing after us, we are not inside link-styled fragment. + return noLinkResult; + } + final nextAttributes = next.attributes ?? {}; + if (!nextAttributes.containsKey(NotusAttribute.link.key)) { + // Next fragment is not styled as link. + return noLinkResult; + } + // We must make sure links are identical in previous and next operations. + if (attributes[NotusAttribute.link.key] == + nextAttributes[NotusAttribute.link.key]) { + return Delta() + ..retain(index) + ..insert(text, attributes); + } else { + return noLinkResult; + } + } +} + +/// Applies link format to text segment (which looks like a link) when user +/// inserts space character after it. +class AutoFormatLinksRule extends InsertRule { + const AutoFormatLinksRule(); + + @override + Delta apply(Delta document, int index, String text) { + // This rule applies to a space inserted after a link, so we can ignore + // everything else. + if (text != ' ') return null; + + final iter = DeltaIterator(document); + final previous = iter.skip(index); + // No previous operation means no link. + if (previous == null) return null; + + // Split text of previous operation in lines and words and take last word to test. + final candidate = previous.data.split('\n').last.split(' ').last; + try { + final link = Uri.parse(candidate); + if (!['https', 'http'].contains(link.scheme)) { + // TODO: might need a more robust way of validating links here. + return null; + } + final attributes = previous.attributes ?? {}; + + // Do nothing if already formatted as link. + if (attributes.containsKey(NotusAttribute.link.key)) return null; + + attributes + .addAll(NotusAttribute.link.fromString(link.toString()).toJson()); + return Delta() + ..retain(index - candidate.length) + ..retain(candidate.length, attributes) + ..insert(text, previous.attributes); + } on FormatException { + return null; // Our candidate is not a link. + } + } +} + +/// Forces text inserted on the same line with an embed (before or after it) +/// to be moved to a new line adjacent to the original line. +/// +/// This rule assumes that a line is only allowed to have single embed child. +class ForceNewlineForInsertsAroundEmbedRule extends InsertRule { + const ForceNewlineForInsertsAroundEmbedRule(); + + @override + Delta apply(Delta document, int index, String text) { + final iter = DeltaIterator(document); + final previous = iter.skip(index); + final target = iter.next(); + final beforeEmbed = target.data == EmbedNode.kPlainTextPlaceholder; + final afterEmbed = previous?.data == EmbedNode.kPlainTextPlaceholder; + if (beforeEmbed || afterEmbed) { + final delta = Delta()..retain(index); + if (beforeEmbed && !text.endsWith('\n')) { + return delta..insert(text)..insert('\n'); + } + if (afterEmbed && !text.startsWith('\n')) { + return delta..insert('\n')..insert(text); + } + return delta..insert(text); + } + return null; + } +} + +/// Preserves block style when user pastes text containing line-breaks. +/// This rule may also be activated for changes triggered by auto-correct. +class PreserveBlockStyleOnPasteRule extends InsertRule { + const PreserveBlockStyleOnPasteRule(); + + bool isEdgeLineSplit(Operation before, Operation after) { + if (before == null) return true; // split at the beginning of a doc + return before.data.endsWith('\n') || after.data.startsWith('\n'); + } + + @override + Delta apply(Delta document, int index, String text) { + if (!text.contains('\n') || text.length == 1) { + // Only interested in text containing at least one line-break and at least + // one more character. + return null; + } + + final iter = DeltaIterator(document); + iter.skip(index); + + // Look for next line-break. + Map lineStyle; + while (iter.hasNext) { + final op = iter.next(); + final lf = op.data.indexOf('\n'); + if (lf >= 0) { + lineStyle = op.attributes; + break; + } + } + + Map resetStyle; + Map blockStyle; + if (lineStyle != null) { + if (lineStyle.containsKey(NotusAttribute.heading.key)) { + resetStyle = NotusAttribute.heading.unset.toJson(); + } + + if (lineStyle.containsKey(NotusAttribute.block.key)) { + blockStyle = { + NotusAttribute.block.key: lineStyle[NotusAttribute.block.key] + }; + } + } + + final lines = text.split('\n'); + final result = Delta()..retain(index); + for (var i = 0; i < lines.length; i++) { + final line = lines[i]; + if (line.isNotEmpty) { + result.insert(line); + } + if (i == 0) { + result.insert('\n', lineStyle); + } else if (i == lines.length - 1) { + if (resetStyle != null) result.retain(1, resetStyle); + } else { + result.insert('\n', blockStyle); + } + } + + return result; + } +} diff --git a/lib/quill_delta/quill_delta.dart b/lib/quill_delta/quill_delta.dart new file mode 100644 index 0000000..6007c9a --- /dev/null +++ b/lib/quill_delta/quill_delta.dart @@ -0,0 +1,660 @@ +// Copyright (c) 2018, Anatoly Pulyaevskiy. All rights reserved. Use of this source code +// is governed by a BSD-style license that can be found in the LICENSE file. + +/// Implementation of Quill Delta format in Dart. +library quill_delta; + +import 'dart:math' as math; + +import 'package:collection/collection.dart'; +import 'package:quiver_hashcode/hashcode.dart'; + +const _attributeEquality = MapEquality( + keys: DefaultEquality(), + values: DefaultEquality(), +); + +/// Operation performed on a rich-text document. +class Operation { + /// Key of insert operations. + static const String insertKey = 'insert'; + + /// Key of delete operations. + static const String deleteKey = 'delete'; + + /// Key of retain operations. + static const String retainKey = 'retain'; + + /// Key of attributes collection. + static const String attributesKey = 'attributes'; + + static const List _validKeys = [insertKey, deleteKey, retainKey]; + + /// Key of this operation, can be "insert", "delete" or "retain". + final String key; + + /// Length of this operation. + final int length; + + /// Payload of "insert" operation, for other types is set to empty string. + final String data; + + /// Rich-text attributes set by this operation, can be `null`. + Map get attributes => + _attributes == null ? null : new Map.from(_attributes); + final Map _attributes; + + Operation._(this.key, this.length, this.data, Map attributes) + : assert(key != null && length != null && data != null), + assert(_validKeys.contains(key), 'Invalid operation key "$key".'), + assert(() { + if (key != Operation.insertKey) return true; + return data.length == length; + }(), 'Length of insert operation must be equal to the text length.'), + _attributes = attributes != null + ? new Map.from(attributes) + : null; + + /// Creates new [Operation] from JSON payload. + static Operation fromJson(data) { + final map = new Map.from(data); + if (map.containsKey(Operation.insertKey)) { + final String text = map[Operation.insertKey]; + return new Operation._( + Operation.insertKey, text.length, text, map[Operation.attributesKey]); + } else if (map.containsKey(Operation.deleteKey)) { + final int length = map[Operation.deleteKey]; + return new Operation._(Operation.deleteKey, length, '', null); + } else if (map.containsKey(Operation.retainKey)) { + final int length = map[Operation.retainKey]; + return new Operation._( + Operation.retainKey, length, '', map[Operation.attributesKey]); + } + throw new ArgumentError.value(data, 'Invalid data for Delta operation.'); + } + + /// Returns JSON-serializable representation of this operation. + Map toJson() { + final Map json = {key: value}; + if (_attributes != null) json[Operation.attributesKey] = attributes; + return json; + } + + /// Creates operation which deletes [length] of characters. + factory Operation.delete(int length) => + new Operation._(Operation.deleteKey, length, '', null); + + /// Creates operation which inserts [text] with optional [attributes]. + factory Operation.insert(String text, [Map attributes]) => + new Operation._(Operation.insertKey, text.length, text, attributes); + + /// Creates operation which retains [length] of characters and optionally + /// applies attributes. + factory Operation.retain(int length, [Map attributes]) => + new Operation._(Operation.retainKey, length, '', attributes); + + /// Returns value of this operation. + /// + /// For insert operations this returns text, for delete and retain - length. + dynamic get value => (key == Operation.insertKey) ? data : length; + + /// Returns `true` if this is a delete operation. + bool get isDelete => key == Operation.deleteKey; + + /// Returns `true` if this is an insert operation. + bool get isInsert => key == Operation.insertKey; + + /// Returns `true` if this is a retain operation. + bool get isRetain => key == Operation.retainKey; + + /// Returns `true` if this operation has no attributes, e.g. is plain text. + bool get isPlain => (_attributes == null || _attributes.isEmpty); + + /// Returns `true` if this operation sets at least one attribute. + bool get isNotPlain => !isPlain; + + /// Returns `true` is this operation is empty. + /// + /// An operation is considered empty if its [length] is equal to `0`. + bool get isEmpty => length == 0; + + /// Returns `true` is this operation is not empty. + bool get isNotEmpty => length > 0; + + @override + bool operator ==(other) { + if (identical(this, other)) return true; + if (other is! Operation) return false; + Operation typedOther = other; + return key == typedOther.key && + length == typedOther.length && + data == typedOther.data && + hasSameAttributes(typedOther); + } + + /// Returns `true` if this operation has attribute specified by [name]. + bool hasAttribute(String name) => isNotPlain && _attributes.containsKey(name); + + /// Returns `true` if [other] operation has the same attributes as this one. + bool hasSameAttributes(Operation other) { + return _attributeEquality.equals(_attributes, other._attributes); + } + + @override + int get hashCode { + if (_attributes != null && _attributes.isNotEmpty) { + int attrsHash = + hashObjects(_attributes.entries.map((e) => hash2(e.key, e.value))); + return hash3(key, value, attrsHash); + } + return hash2(key, value); + } + + @override + String toString() { + String attr = attributes == null ? '' : ' + $attributes'; + String text = isInsert ? data.replaceAll('\n', '⏎') : '$length'; + return '$key⟨ $text ⟩$attr'; + } +} + +/// Delta represents a document or a modification of a document as a sequence of +/// insert, delete and retain operations. +/// +/// Delta consisting of only "insert" operations is usually referred to as +/// "document delta". When delta includes also "retain" or "delete" operations +/// it is a "change delta". +class Delta { + /// Transforms two attribute sets. + static Map transformAttributes( + Map a, Map b, bool priority) { + if (a == null) return b; + if (b == null) return null; + + if (!priority) return b; + + final Map result = + b.keys.fold>({}, (attributes, key) { + if (!a.containsKey(key)) attributes[key] = b[key]; + return attributes; + }); + + return result.isEmpty ? null : result; + } + + /// Composes two attribute sets. + static Map composeAttributes( + Map a, Map b, + {bool keepNull: false}) { + a ??= const {}; + b ??= const {}; + + final Map result = new Map.from(a)..addAll(b); + List keys = result.keys.toList(growable: false); + + if (!keepNull) { + for (final String key in keys) { + if (result[key] == null) result.remove(key); + } + } + + return result.isEmpty ? null : result; + } + + ///get anti-attr result base on base + static Map invertAttributes( + Map attr, Map base) { + attr ??= const {}; + base ??= const {}; + + var baseInverted = base.keys.fold({}, (memo, key) { + if (base[key] != attr[key] && attr.containsKey(key)) { + memo[key] = base[key]; + } + return memo; + }); + + var inverted = + Map.from(attr.keys.fold(baseInverted, (memo, key) { + if (base[key] != attr[key] && !base.containsKey(key)) { + memo[key] = null; + } + return memo; + })); + return inverted; + } + + final List _operations; + + int _modificationCount = 0; + + Delta._(List operations) + : assert(operations != null), + _operations = operations; + + /// Creates new empty [Delta]. + factory Delta() => new Delta._(new List()); + + /// Creates new [Delta] from [other]. + factory Delta.from(Delta other) => + new Delta._(new List.from(other._operations)); + + /// Creates [Delta] from de-serialized JSON representation. + static Delta fromJson(List data) { + return new Delta._(data.map(Operation.fromJson).toList()); + } + + /// Returns list of operations in this delta. + List toList() => new List.from(_operations); + + /// Returns JSON-serializable version of this delta. + List toJson() => toList(); + + /// Returns `true` if this delta is empty. + bool get isEmpty => _operations.isEmpty; + + /// Returns `true` if this delta is not empty. + bool get isNotEmpty => _operations.isNotEmpty; + + /// Returns number of operations in this delta. + int get length => _operations.length; + + /// Returns [Operation] at specified [index] in this delta. + Operation operator [](int index) => _operations[index]; + + /// Returns [Operation] at specified [index] in this delta. + Operation elementAt(int index) => _operations.elementAt(index); + + /// Returns the first [Operation] in this delta. + Operation get first => _operations.first; + + /// Returns the last [Operation] in this delta. + Operation get last => _operations.last; + + @override + operator ==(dynamic other) { + if (identical(this, other)) return true; + if (other is! Delta) return false; + Delta typedOther = other; + final comparator = + new ListEquality(const DefaultEquality()); + return comparator.equals(_operations, typedOther._operations); + } + + @override + int get hashCode => hashObjects(_operations); + + /// Retain [count] of characters from current position. + void retain(int count, [Map attributes]) { + assert(count >= 0); + if (count == 0) return; // no-op + push(Operation.retain(count, attributes)); + } + + /// Insert [text] at current position. + void insert(String text, [Map attributes]) { + assert(text != null); + if (text.isEmpty) return; // no-op + push(Operation.insert(text, attributes)); + } + + /// Delete [count] characters from current position. + void delete(int count) { + assert(count >= 0); + if (count == 0) return; + push(Operation.delete(count)); + } + + void _mergeWithTail(Operation operation) { + assert(isNotEmpty); + assert(operation != null && last.key == operation.key); + + final int length = operation.length + last.length; + final String data = last.data + operation.data; + final int index = _operations.length; + _operations.replaceRange(index - 1, index, [ + Operation._(operation.key, length, data, operation.attributes), + ]); + } + + /// Pushes new operation into this delta. + /// + /// Performs compaction by composing [operation] with current tail operation + /// of this delta, when possible. For instance, if current tail is + /// `insert('abc')` and pushed operation is `insert('123')` then existing + /// tail is replaced with `insert('abc123')` - a compound result of the two + /// operations. + void push(Operation operation) { + if (operation.isEmpty) return; + + int index = _operations.length; + Operation lastOp = _operations.isNotEmpty ? _operations.last : null; + if (lastOp != null) { + if (lastOp.isDelete && operation.isDelete) { + _mergeWithTail(operation); + return; + } + + if (lastOp.isDelete && operation.isInsert) { + index -= 1; // Always insert before deleting + lastOp = (index > 0) ? _operations.elementAt(index - 1) : null; + if (lastOp == null) { + _operations.insert(0, operation); + return; + } + } + + if (lastOp.isInsert && operation.isInsert) { + if (lastOp.hasSameAttributes(operation)) { + _mergeWithTail(operation); + return; + } + } + + if (lastOp.isRetain && operation.isRetain) { + if (lastOp.hasSameAttributes(operation)) { + _mergeWithTail(operation); + return; + } + } + } + if (index == _operations.length) { + _operations.add(operation); + } else { + final opAtIndex = _operations.elementAt(index); + _operations.replaceRange(index, index + 1, [operation, opAtIndex]); + } + _modificationCount++; + } + + /// Composes next operation from [thisIter] and [otherIter]. + /// + /// Returns new operation or `null` if operations from [thisIter] and + /// [otherIter] nullify each other. For instance, for the pair `insert('abc')` + /// and `delete(3)` composition result would be empty string. + Operation _composeOperation(DeltaIterator thisIter, DeltaIterator otherIter) { + if (otherIter.isNextInsert) return otherIter.next(); + if (thisIter.isNextDelete) return thisIter.next(); + + num length = math.min(thisIter.peekLength(), otherIter.peekLength()); + Operation thisOp = thisIter.next(length); + Operation otherOp = otherIter.next(length); + assert(thisOp.length == otherOp.length); + + if (otherOp.isRetain) { + final attributes = composeAttributes( + thisOp.attributes, + otherOp.attributes, + keepNull: thisOp.isRetain, + ); + if (thisOp.isRetain) { + return new Operation.retain(thisOp.length, attributes); + } else if (thisOp.isInsert) { + return new Operation.insert(thisOp.data, attributes); + } else { + throw new StateError('Unreachable'); + } + } else { + // otherOp == delete && thisOp in [retain, insert] + assert(otherOp.isDelete); + if (thisOp.isRetain) return otherOp; + assert(thisOp.isInsert); + // otherOp(delete) + thisOp(insert) => null + } + return null; + } + + /// Composes this delta with [other] and returns new [Delta]. + /// + /// It is not required for this and [other] delta to represent a document + /// delta (consisting only of insert operations). + Delta compose(Delta other) { + final Delta result = new Delta(); + DeltaIterator thisIter = new DeltaIterator(this); + DeltaIterator otherIter = new DeltaIterator(other); + + while (thisIter.hasNext || otherIter.hasNext) { + final Operation newOp = _composeOperation(thisIter, otherIter); + if (newOp != null) result.push(newOp); + } + return result..trim(); + } + + /// Transforms next operation from [otherIter] against next operation in + /// [thisIter]. + /// + /// Returns `null` if both operations nullify each other. + Operation _transformOperation( + DeltaIterator thisIter, DeltaIterator otherIter, bool priority) { + if (thisIter.isNextInsert && (priority || !otherIter.isNextInsert)) { + return new Operation.retain(thisIter.next().length); + } else if (otherIter.isNextInsert) { + return otherIter.next(); + } + + num length = math.min(thisIter.peekLength(), otherIter.peekLength()); + Operation thisOp = thisIter.next(length); + Operation otherOp = otherIter.next(length); + assert(thisOp.length == otherOp.length); + + // At this point only delete and retain operations are possible. + if (thisOp.isDelete) { + // otherOp is either delete or retain, so they nullify each other. + return null; + } else if (otherOp.isDelete) { + return otherOp; + } else { + // Retain otherOp which is either retain or insert. + return new Operation.retain( + length, + transformAttributes(thisOp.attributes, otherOp.attributes, priority), + ); + } + } + + /// Transforms [other] delta against operations in this delta. + Delta transform(Delta other, bool priority) { + final Delta result = new Delta(); + DeltaIterator thisIter = new DeltaIterator(this); + DeltaIterator otherIter = new DeltaIterator(other); + + while (thisIter.hasNext || otherIter.hasNext) { + final Operation newOp = + _transformOperation(thisIter, otherIter, priority); + if (newOp != null) result.push(newOp); + } + return result..trim(); + } + + /// Removes trailing retain operation with empty attributes, if present. + void trim() { + if (isNotEmpty) { + final Operation last = _operations.last; + if (last.isRetain && last.isPlain) _operations.removeLast(); + } + } + + /// Concatenates [other] with this delta and returns the result. + Delta concat(Delta other) { + final Delta result = new Delta.from(this); + if (other.isNotEmpty) { + // In case first operation of other can be merged with last operation in + // our list. + result.push(other._operations.first); + result._operations.addAll(other._operations.sublist(1)); + } + return result; + } + + /// Inverts this delta against [base]. + /// + /// Returns new delta which negates effect of this delta when applied to + /// [base]. This is an equivalent of "undo" operation on deltas. + Delta invert(Delta base) { + final inverted = new Delta(); + if (base.isEmpty) return inverted; + + int baseIndex = 0; + for (final op in _operations) { + if (op.isInsert) { + inverted.delete(op.length); + } else if (op.isRetain && op.isPlain) { + inverted.retain(op.length, null); + baseIndex += op.length; + } else if (op.isDelete || (op.isRetain && op.isNotPlain)) { + final length = op.length; + final sliceDelta = base.slice(baseIndex, baseIndex + length); + sliceDelta.toList().forEach((baseOp) { + if (op.isDelete) { + inverted.push(baseOp); + } else if (op.isRetain && op.isNotPlain) { + var invertAttr = invertAttributes(op.attributes, baseOp.attributes); + inverted.retain( + baseOp.length, invertAttr.isEmpty ? null : invertAttr); + } + }); + baseIndex += length; + } else { + throw StateError("Unreachable"); + } + } + inverted.trim(); + return inverted; + } + + /// Returns slice of this delta from [start] index (inclusive) to [end] + /// (exclusive). + Delta slice(int start, [int end]) { + final delta = new Delta(); + var index = 0; + var opIterator = new DeltaIterator(this); + + num actualEnd = end ?? double.infinity; + + while (index < actualEnd && opIterator.hasNext) { + Operation op; + if (index < start) { + op = opIterator.next(start - index); + } else { + op = opIterator.next(actualEnd - index); + delta.push(op); + } + index += op.length; + } + return delta; + } + + /// Transforms [index] against this delta. + /// + /// Any "delete" operation before specified [index] shifts it backward, as + /// well as any "insert" operation shifts it forward. + /// + /// The [force] argument is used to resolve scenarios when there is an + /// insert operation at the same position as [index]. If [force] is set to + /// `true` (default) then position is forced to shift forward, otherwise + /// position stays at the same index. In other words setting [force] to + /// `false` gives higher priority to the transformed position. + /// + /// Useful to adjust caret or selection positions. + int transformPosition(int index, {bool force: true}) { + final iter = new DeltaIterator(this); + int offset = 0; + while (iter.hasNext && offset <= index) { + final op = iter.next(); + if (op.isDelete) { + index -= math.min(op.length, index - offset); + continue; + } else if (op.isInsert && (offset < index || force)) { + index += op.length; + } + offset += op.length; + } + return index; + } + + @override + String toString() => _operations.join('\n'); +} + +/// Specialized iterator for [Delta]s. +class DeltaIterator { + final Delta delta; + int _index = 0; + num _offset = 0; + int _modificationCount; + + DeltaIterator(this.delta) : _modificationCount = delta._modificationCount; + + bool get isNextInsert => nextOperationKey == Operation.insertKey; + + bool get isNextDelete => nextOperationKey == Operation.deleteKey; + + bool get isNextRetain => nextOperationKey == Operation.retainKey; + + String get nextOperationKey { + if (_index < delta.length) { + return delta.elementAt(_index).key; + } else + return null; + } + + bool get hasNext => peekLength() < double.infinity; + + /// Returns length of next operation without consuming it. + /// + /// Returns [double.infinity] if there is no more operations left to iterate. + num peekLength() { + if (_index < delta.length) { + final Operation operation = delta._operations[_index]; + return operation.length - _offset; + } + return double.infinity; + } + + /// Consumes and returns next operation. + /// + /// Optional [length] specifies maximum length of operation to return. Note + /// that actual length of returned operation may be less than specified value. + Operation next([num length = double.infinity]) { + assert(length != null); + + if (_modificationCount != delta._modificationCount) { + throw new ConcurrentModificationError(delta); + } + + if (_index < delta.length) { + final op = delta.elementAt(_index); + final opKey = op.key; + final opAttributes = op.attributes; + final _currentOffset = _offset; + num actualLength = math.min(op.length - _currentOffset, length); + if (actualLength == op.length - _currentOffset) { + _index++; + _offset = 0; + } else { + _offset += actualLength; + } + final String opData = op.isInsert + ? op.data.substring(_currentOffset, _currentOffset + actualLength) + : ''; + final int opLength = (opData.isNotEmpty) ? opData.length : actualLength; + return Operation._(opKey, opLength, opData, opAttributes); + } + return Operation.retain(length); + } + + /// Skips [length] characters in source delta. + /// + /// Returns last skipped operation, or `null` if there was nothing to skip. + Operation skip(int length) { + int skipped = 0; + Operation op; + while (skipped < length && hasNext) { + int opLength = peekLength(); + int skip = math.min(length - skipped, opLength); + op = next(skip); + skipped += op.length; + } + return op; + } +} diff --git a/lib/quill_markdown.dart b/lib/quill_markdown.dart new file mode 100644 index 0000000..9bad943 --- /dev/null +++ b/lib/quill_markdown.dart @@ -0,0 +1,65 @@ +library quill_to_markdown; + +import 'dart:convert'; +import 'package:quill_markdown/notus/convert.dart'; +import 'notus/notus.dart'; + +String quillToMarkdown(String content) { + try { + return notusMarkdown.encode(NotusDocument.fromJson(jsonDecode(content + .replaceAll('"header":1', '"heading":1') + .replaceAll('"bold":true', '"b":true') + .replaceAll('"italic":true', '"i":true') + .replaceAll('"blockquote":true', '"block":"quote"') + .replaceAll('"blockquote":"quote"', '"block":"quote"') + .replaceAll('"code-block":true', '"block":"code"') + .replaceAll(',"attributes":{"link":"', ',"attributes":{"a":"') + .replaceAll('{"insert":"​","attributes":{"embed":{"type":"hr"}}},', '') + .replaceAll('"underline":true', '') + .replaceAll('"strike":true', '') + .replaceAllMapped( + RegExp( + r'{"insert":{"image":"[A-Za-z0-9:.,?\/\\!@_]{0,100}"}},', + ), + (match) => '') + .replaceAllMapped( + RegExp( + r'{"insert":{"image":"[A-Za-z0-9:.,?\/\\!@_]{0,100}"}}', + ), + (match) => '') + .replaceAllMapped( + RegExp( + r'"indent":[A-Za-z0-9]{0,100}', + ), + (match) => '') + .replaceAll('"list":"ordered"', '"block":"ol"') + .replaceAll('"list":"unordered"', '"block":"ul"') + .replaceAllMapped( + RegExp( + r'"list":"[A-Za-z0-9]{0,100}"', + ), + (match) => '"block":"ul"') + .replaceAllMapped( + RegExp( + r'"color":"#[A-Fa-f0-9]{6}"', + ), + (match) => '') + .replaceAllMapped( + RegExp( + r'"background":"#[A-Fa-f0-9]{6}"', + ), + (match) => ''))).toDelta()); + } catch (error) { + print(error); + return null; + } +} + +String markdownToQuill(String content) { + try { + return jsonEncode(notusMarkdown.decode(content)).toString(); + } catch (error) { + print(error); + return null; + } +} diff --git a/pubspec.lock b/pubspec.lock new file mode 100644 index 0000000..d8bc0c7 --- /dev/null +++ b/pubspec.lock @@ -0,0 +1,154 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + async: + dependency: transitive + description: + name: async + url: "https://pub.dartlang.org" + source: hosted + version: "2.5.0-nullsafety.1" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + url: "https://pub.dartlang.org" + source: hosted + version: "2.1.0-nullsafety.1" + characters: + dependency: transitive + description: + name: characters + url: "https://pub.dartlang.org" + source: hosted + version: "1.1.0-nullsafety.3" + charcode: + dependency: transitive + description: + name: charcode + url: "https://pub.dartlang.org" + source: hosted + version: "1.2.0-nullsafety.1" + clock: + dependency: transitive + description: + name: clock + url: "https://pub.dartlang.org" + source: hosted + version: "1.1.0-nullsafety.1" + collection: + dependency: transitive + description: + name: collection + url: "https://pub.dartlang.org" + source: hosted + version: "1.15.0-nullsafety.3" + fake_async: + dependency: transitive + description: + name: fake_async + url: "https://pub.dartlang.org" + source: hosted + version: "1.2.0-nullsafety.1" + flutter: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" + matcher: + dependency: transitive + description: + name: matcher + url: "https://pub.dartlang.org" + source: hosted + version: "0.12.10-nullsafety.1" + meta: + dependency: transitive + description: + name: meta + url: "https://pub.dartlang.org" + source: hosted + version: "1.3.0-nullsafety.3" + path: + dependency: transitive + description: + name: path + url: "https://pub.dartlang.org" + source: hosted + version: "1.8.0-nullsafety.1" + quiver_hashcode: + dependency: "direct main" + description: + name: quiver_hashcode + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.0" + sky_engine: + dependency: transitive + description: flutter + source: sdk + version: "0.0.99" + source_span: + dependency: transitive + description: + name: source_span + url: "https://pub.dartlang.org" + source: hosted + version: "1.8.0-nullsafety.2" + stack_trace: + dependency: transitive + description: + name: stack_trace + url: "https://pub.dartlang.org" + source: hosted + version: "1.10.0-nullsafety.1" + stream_channel: + dependency: transitive + description: + name: stream_channel + url: "https://pub.dartlang.org" + source: hosted + version: "2.1.0-nullsafety.1" + string_scanner: + dependency: transitive + description: + name: string_scanner + url: "https://pub.dartlang.org" + source: hosted + version: "1.1.0-nullsafety.1" + term_glyph: + dependency: transitive + description: + name: term_glyph + url: "https://pub.dartlang.org" + source: hosted + version: "1.2.0-nullsafety.1" + test_api: + dependency: transitive + description: + name: test_api + url: "https://pub.dartlang.org" + source: hosted + version: "0.2.19-nullsafety.2" + typed_data: + dependency: transitive + description: + name: typed_data + url: "https://pub.dartlang.org" + source: hosted + version: "1.3.0-nullsafety.3" + vector_math: + dependency: transitive + description: + name: vector_math + url: "https://pub.dartlang.org" + source: hosted + version: "2.1.0-nullsafety.3" +sdks: + dart: ">=2.10.0-110 <2.11.0" + flutter: ">=1.17.0" diff --git a/pubspec.yaml b/pubspec.yaml new file mode 100644 index 0000000..2d30f7b --- /dev/null +++ b/pubspec.yaml @@ -0,0 +1,23 @@ +name: quill_markdown +description: A quill to markdown converter and vice versa +version: 0.0.1 +author: Arjan Aswal +homepage: github.com/ArjanAswal/quill_markdown.git +repository: github.com/ArjanAswal/quill_markdown.git + +environment: + sdk: ">=2.7.0 <3.0.0" + flutter: ">=1.17.0" + +dependencies: + flutter: + sdk: flutter + + quiver_hashcode: ^2.0.0 + +dev_dependencies: + flutter_test: + sdk: flutter + +flutter: +