Przeglądaj źródła

Initial import of notus package

Anatoly Pulyaevskiy 6 lat temu
commit
8f04186962
35 zmienionych plików z 4298 dodań i 0 usunięć
  1. 12
    0
      packages/notus/.gitignore
  2. 15
    0
      packages/notus/.travis.yml
  3. 6
    0
      packages/notus/AUTHORS
  4. 3
    0
      packages/notus/CHANGELOG.md
  5. 23
    0
      packages/notus/LICENSE
  6. 16
    0
      packages/notus/README.md
  7. 20
    0
      packages/notus/analysis_options.yaml
  8. 13
    0
      packages/notus/lib/convert.dart
  9. 18
    0
      packages/notus/lib/notus.dart
  10. 213
    0
      packages/notus/lib/src/convert/markdown.dart
  11. 303
    0
      packages/notus/lib/src/document.dart
  12. 451
    0
      packages/notus/lib/src/document/attributes.dart
  13. 100
    0
      packages/notus/lib/src/document/block.dart
  14. 254
    0
      packages/notus/lib/src/document/leaf.dart
  15. 320
    0
      packages/notus/lib/src/document/line.dart
  16. 310
    0
      packages/notus/lib/src/document/node.dart
  17. 21
    0
      packages/notus/lib/src/embed.dart
  18. 111
    0
      packages/notus/lib/src/heuristics.dart
  19. 73
    0
      packages/notus/lib/src/heuristics/delete_rules.dart
  20. 86
    0
      packages/notus/lib/src/heuristics/embed_rules.dart
  21. 147
    0
      packages/notus/lib/src/heuristics/format_rules.dart
  22. 258
    0
      packages/notus/lib/src/heuristics/insert_rules.dart
  23. 17
    0
      packages/notus/pubspec.yaml
  24. 173
    0
      packages/notus/test/convert/markdown_test.dart
  25. 15
    0
      packages/notus/test/document/attributes_test.dart
  26. 173
    0
      packages/notus/test/document/block_test.dart
  27. 264
    0
      packages/notus/test/document/line_test.dart
  28. 46
    0
      packages/notus/test/document/node_test.dart
  29. 114
    0
      packages/notus/test/document/text_test.dart
  30. 304
    0
      packages/notus/test/document_test.dart
  31. 57
    0
      packages/notus/test/heuristics/delete_rules_test.dart
  32. 96
    0
      packages/notus/test/heuristics/format_rules_test.dart
  33. 215
    0
      packages/notus/test/heuristics/insert_rules_test.dart
  34. 40
    0
      packages/notus/test/heuristics_test.dart
  35. 11
    0
      packages/notus/test/matchers.dart

+ 12
- 0
packages/notus/.gitignore Wyświetl plik

@@ -0,0 +1,12 @@
1
+.DS_Store
2
+.atom/
3
+.idea
4
+.vscode/
5
+.packages
6
+.dart_tool/
7
+packages
8
+pubspec.lock
9
+coverage/
10
+doc/api/
11
+test/.test_coverage.dart
12
+coverage/

+ 15
- 0
packages/notus/.travis.yml Wyświetl plik

@@ -0,0 +1,15 @@
1
+language: dart
2
+
3
+dart:
4
+- dev
5
+
6
+before_script:
7
+- pub global activate coverage
8
+- pub global activate test_coverage
9
+
10
+script:
11
+- pub run test -r expanded
12
+- dartfmt -n --set-exit-if-changed lib/
13
+- dartanalyzer --fatal-infos --fatal-warnings .
14
+- pub global run test_coverage
15
+- bash <(curl -s https://codecov.io/bash)

+ 6
- 0
packages/notus/AUTHORS Wyświetl plik

@@ -0,0 +1,6 @@
1
+# Below is a list of people and organizations that have contributed
2
+# to the Zefyr project. Names should be added to the list like so:
3
+#
4
+#   Name/Organization <email address>
5
+
6
+Memspace <hello@memspace.app>

+ 3
- 0
packages/notus/CHANGELOG.md Wyświetl plik

@@ -0,0 +1,3 @@
1
+## 0.1.0
2
+
3
+*  Initial release.

+ 23
- 0
packages/notus/LICENSE Wyświetl plik

@@ -0,0 +1,23 @@
1
+Copyright 2018, the Zefyr project authors. All rights reserved.
2
+
3
+Redistribution and use in source and binary forms, with or without
4
+modification, are permitted provided that the following conditions are met:
5
+    * Redistributions of source code must retain the above copyright
6
+      notice, this list of conditions and the following disclaimer.
7
+    * Redistributions in binary form must reproduce the above copyright
8
+      notice, this list of conditions and the following disclaimer in the
9
+      documentation and/or other materials provided with the distribution.
10
+    * Neither the name of the <organization> nor the
11
+      names of its contributors may be used to endorse or promote products
12
+      derived from this software without specific prior written permission.
13
+
14
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
15
+ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
16
+WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
17
+DISCLAIMED. IN NO EVENT SHALL <COPYRIGHT HOLDER> BE LIABLE FOR ANY
18
+DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
19
+(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
20
+LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
21
+ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
22
+(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
23
+SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

+ 16
- 0
packages/notus/README.md Wyświetl plik

@@ -0,0 +1,16 @@
1
+# [![Build Status](https://travis-ci.com/memspace/notus.svg?branch=master)](https://travis-ci.com/memspace/notus) [![codecov](https://codecov.io/gh/memspace/notus/branch/master/graph/badge.svg)](https://codecov.io/gh/memspace/notus)
2
+
3
+**Notus** is a rich text document model for [Zefyr][] project.
4
+
5
+Zefyr provides a lightweight and user friendly rich text editor for
6
+Flutter framework.
7
+
8
+For documentation, please see the main [Zefyr][] repository.
9
+
10
+> Zefyr is a Flutter package which means it can only be used inside of
11
+> Flutter projects. Notus is a **generic Dart package** and does not
12
+> depend on Flutter in any way. It can be used on any platform supported
13
+> by the Dart language: web, server (mac, linux, windows) and,
14
+> of course, mobile (android and ios).
15
+
16
+[Zefyr]: https://github.com/memspace/zefyr

+ 20
- 0
packages/notus/analysis_options.yaml Wyświetl plik

@@ -0,0 +1,20 @@
1
+analyzer:
2
+  strong-mode: true
3
+  language:
4
+    enableSuperMixins: true
5
+
6
+# Lint rules and documentation, see http://dart-lang.github.io/linter/lints
7
+linter:
8
+  rules:
9
+    # - avoid_init_to_null
10
+    - cancel_subscriptions
11
+    - close_sinks
12
+    - directives_ordering
13
+    - hash_and_equals
14
+    - iterable_contains_unrelated_type
15
+    - list_remove_unrelated_type
16
+    - prefer_final_fields
17
+    - prefer_is_not_empty
18
+    - test_types_in_equals
19
+    - unrelated_type_equality_checks
20
+    - valid_regexps

+ 13
- 0
packages/notus/lib/convert.dart Wyświetl plik

@@ -0,0 +1,13 @@
1
+// Copyright (c) 2018, the Zefyr project authors.  Please see the AUTHORS file
2
+// for details. All rights reserved. Use of this source code is governed by a
3
+// BSD-style license that can be found in the LICENSE file.
4
+
5
+/// Provides codecs to convert Notus documents to other formats.
6
+library notus.convert;
7
+
8
+import 'src/convert/markdown.dart';
9
+
10
+export 'src/convert/markdown.dart';
11
+
12
+/// Markdown codec for Notus documents.
13
+const NotusMarkdownCodec notusMarkdown = const NotusMarkdownCodec();

+ 18
- 0
packages/notus/lib/notus.dart Wyświetl plik

@@ -0,0 +1,18 @@
1
+// Copyright (c) 2018, the Zefyr project authors.  Please see the AUTHORS file
2
+// for details. All rights reserved. Use of this source code is governed by a
3
+// BSD-style license that can be found in the LICENSE file.
4
+
5
+/// Rich text document model for Zefyr editor.
6
+library notus;
7
+
8
+export 'src/document.dart';
9
+export 'src/document/attributes.dart';
10
+export 'src/document/block.dart';
11
+export 'src/document/leaf.dart';
12
+export 'src/document/line.dart';
13
+export 'src/document/node.dart';
14
+export 'src/embed.dart';
15
+export 'src/heuristics.dart';
16
+export 'src/heuristics/delete_rules.dart';
17
+export 'src/heuristics/format_rules.dart';
18
+export 'src/heuristics/insert_rules.dart';

+ 213
- 0
packages/notus/lib/src/convert/markdown.dart Wyświetl plik

@@ -0,0 +1,213 @@
1
+// Copyright (c) 2018, the Zefyr project authors.  Please see the AUTHORS file
2
+// for details. All rights reserved. Use of this source code is governed by a
3
+// BSD-style license that can be found in the LICENSE file.
4
+
5
+import 'dart:convert';
6
+
7
+import 'package:quill_delta/quill_delta.dart';
8
+import 'package:notus/notus.dart';
9
+
10
+class NotusMarkdownCodec extends Codec<Delta, String> {
11
+  const NotusMarkdownCodec();
12
+
13
+  @override
14
+  Converter<String, Delta> get decoder =>
15
+      throw new UnimplementedError('Decoding is not implemented yet.');
16
+
17
+  @override
18
+  Converter<Delta, String> get encoder => new _NotusMarkdownEncoder();
19
+}
20
+
21
+class _NotusMarkdownEncoder extends Converter<Delta, String> {
22
+  static const kBold = '**';
23
+  static const kItalic = '_';
24
+  static final kSimpleBlocks = <NotusAttribute, String>{
25
+    NotusAttribute.bq: '> ',
26
+    NotusAttribute.ul: '* ',
27
+    NotusAttribute.ol: '1. ',
28
+  };
29
+
30
+  @override
31
+  String convert(Delta input) {
32
+    final iterator = new DeltaIterator(input);
33
+    final buffer = new StringBuffer();
34
+    final lineBuffer = new StringBuffer();
35
+    NotusAttribute<String> currentBlockStyle;
36
+    NotusStyle currentInlineStyle = new NotusStyle();
37
+    List<String> currentBlockLines = [];
38
+
39
+    void _handleBlock(NotusAttribute<String> blockStyle) {
40
+      if (currentBlockLines.isEmpty) {
41
+        return; // Empty block
42
+      }
43
+
44
+      if (blockStyle == null) {
45
+        buffer.write(currentBlockLines.join('\n\n'));
46
+        buffer.writeln();
47
+      } else if (blockStyle == NotusAttribute.code) {
48
+        _writeAttribute(buffer, blockStyle);
49
+        buffer.write(currentBlockLines.join('\n'));
50
+        _writeAttribute(buffer, blockStyle, close: true);
51
+        buffer.writeln();
52
+      } else {
53
+        for (var line in currentBlockLines) {
54
+          _writeBlockTag(buffer, blockStyle);
55
+          buffer.write(line);
56
+          buffer.writeln();
57
+        }
58
+      }
59
+      buffer.writeln();
60
+    }
61
+
62
+    void _handleSpan(String text, Map<String, dynamic> attributes) {
63
+      final style = NotusStyle.fromJson(attributes);
64
+      currentInlineStyle =
65
+          _writeInline(lineBuffer, text, style, currentInlineStyle);
66
+    }
67
+
68
+    void _handleLine(Map<String, dynamic> attributes) {
69
+      final style = NotusStyle.fromJson(attributes);
70
+      final lineBlock = style.get(NotusAttribute.block);
71
+      if (lineBlock == currentBlockStyle) {
72
+        currentBlockLines.add(_writeLine(lineBuffer.toString(), style));
73
+      } else {
74
+        _handleBlock(currentBlockStyle);
75
+        currentBlockLines.clear();
76
+        currentBlockLines.add(_writeLine(lineBuffer.toString(), style));
77
+
78
+        currentBlockStyle = lineBlock;
79
+      }
80
+      lineBuffer.clear();
81
+    }
82
+
83
+    while (iterator.hasNext) {
84
+      final op = iterator.next();
85
+      final lf = op.data.indexOf('\n');
86
+      if (lf == -1) {
87
+        _handleSpan(op.data, op.attributes);
88
+      } else {
89
+        StringBuffer span = new StringBuffer();
90
+        for (var i = 0; i < op.data.length; i++) {
91
+          if (op.data.codeUnitAt(i) == 0x0A) {
92
+            if (span.isNotEmpty) {
93
+              // Write the span if it's not empty.
94
+              _handleSpan(span.toString(), op.attributes);
95
+            }
96
+            // Close any open inline styles.
97
+            _handleSpan('', null);
98
+            _handleLine(op.attributes);
99
+            span.clear();
100
+          } else {
101
+            span.writeCharCode(op.data.codeUnitAt(i));
102
+          }
103
+        }
104
+        // Remaining span
105
+        if (span.isNotEmpty) {
106
+          _handleSpan(span.toString(), op.attributes);
107
+        }
108
+      }
109
+    }
110
+    _handleBlock(currentBlockStyle); // Close the last block
111
+    return buffer.toString();
112
+  }
113
+
114
+  String _writeLine(String text, NotusStyle style) {
115
+    StringBuffer buffer = new StringBuffer();
116
+    if (style.contains(NotusAttribute.heading)) {
117
+      _writeAttribute(buffer, style.get(NotusAttribute.heading));
118
+    }
119
+
120
+    // Write the text itself
121
+    buffer.write(text);
122
+    return buffer.toString();
123
+  }
124
+
125
+  String _trimRight(StringBuffer buffer) {
126
+    String text = buffer.toString();
127
+    if (!text.endsWith(' ')) return '';
128
+    final result = text.trimRight();
129
+    buffer.clear();
130
+    buffer.write(result);
131
+    return ' ' * (text.length - result.length);
132
+  }
133
+
134
+  NotusStyle _writeInline(StringBuffer buffer, String text, NotusStyle style,
135
+      NotusStyle currentStyle) {
136
+    // First close any current styles if needed
137
+    for (var value in currentStyle.values) {
138
+      if (value.scope == NotusAttributeScope.line) continue;
139
+      if (style.containsSame(value)) continue;
140
+      final padding = _trimRight(buffer);
141
+      _writeAttribute(buffer, value, close: true);
142
+      if (padding.isNotEmpty) buffer.write(padding);
143
+    }
144
+    // Now open any new styles.
145
+    for (var value in style.values) {
146
+      if (value.scope == NotusAttributeScope.line) continue;
147
+      if (currentStyle.containsSame(value)) continue;
148
+      final originalText = text;
149
+      text = text.trimLeft();
150
+      final padding = ' ' * (originalText.length - text.length);
151
+      if (padding.isNotEmpty) buffer.write(padding);
152
+      _writeAttribute(buffer, value);
153
+    }
154
+    // Write the text itself
155
+    buffer.write(text);
156
+    return style;
157
+  }
158
+
159
+  void _writeAttribute(StringBuffer buffer, NotusAttribute attribute,
160
+      {bool close: false}) {
161
+    if (attribute == NotusAttribute.bold) {
162
+      _writeBoldTag(buffer);
163
+    } else if (attribute == NotusAttribute.italic) {
164
+      _writeItalicTag(buffer);
165
+    } else if (attribute.key == NotusAttribute.link.key) {
166
+      _writeLinkTag(buffer, attribute, close: close);
167
+    } else if (attribute.key == NotusAttribute.heading.key) {
168
+      _writeHeadingTag(buffer, attribute);
169
+    } else if (attribute.key == NotusAttribute.block.key) {
170
+      _writeBlockTag(buffer, attribute, close: close);
171
+    } else {
172
+      throw new ArgumentError('Cannot handle $attribute');
173
+    }
174
+  }
175
+
176
+  void _writeBoldTag(StringBuffer buffer) {
177
+    buffer.write(kBold);
178
+  }
179
+
180
+  void _writeItalicTag(StringBuffer buffer) {
181
+    buffer.write(kItalic);
182
+  }
183
+
184
+  void _writeLinkTag(StringBuffer buffer, NotusAttribute<String> link,
185
+      {bool close: false}) {
186
+    if (close) {
187
+      buffer.write('](${link.value})');
188
+    } else {
189
+      buffer.write('[');
190
+    }
191
+  }
192
+
193
+  void _writeHeadingTag(StringBuffer buffer, NotusAttribute<int> heading) {
194
+    var level = heading.value;
195
+    buffer.write('#' * level + ' ');
196
+  }
197
+
198
+  void _writeBlockTag(StringBuffer buffer, NotusAttribute<String> block,
199
+      {bool close: false}) {
200
+    if (block == NotusAttribute.code) {
201
+      if (close) {
202
+        buffer.write('\n```');
203
+      } else {
204
+        buffer.write('```\n');
205
+      }
206
+    } else {
207
+      if (close) return; // no close tag needed for simple blocks.
208
+
209
+      final tag = kSimpleBlocks[block];
210
+      buffer.write(tag);
211
+    }
212
+  }
213
+}

+ 303
- 0
packages/notus/lib/src/document.dart Wyświetl plik

@@ -0,0 +1,303 @@
1
+// Copyright (c) 2018, the Zefyr project authors.  Please see the AUTHORS file
2
+// for details. All rights reserved. Use of this source code is governed by a
3
+// BSD-style license that can be found in the LICENSE file.
4
+import 'dart:async';
5
+
6
+import 'package:quill_delta/quill_delta.dart';
7
+
8
+import 'document/attributes.dart';
9
+import 'document/block.dart';
10
+import 'document/leaf.dart';
11
+import 'document/line.dart';
12
+import 'document/node.dart';
13
+import 'embed.dart';
14
+import 'heuristics.dart';
15
+
16
+/// Source of a [NotusChange].
17
+enum ChangeSource {
18
+  /// Change originated from a local action. Typically triggered by user.
19
+  local,
20
+
21
+  /// Change originated from a remote action.
22
+  remote,
23
+}
24
+
25
+/// Represents a change in a [NotusDocument].
26
+class NotusChange {
27
+  NotusChange(this.before, this.change, this.source);
28
+
29
+  /// Document state before [change].
30
+  final Delta before;
31
+
32
+  /// Change delta applied to the document.
33
+  final Delta change;
34
+
35
+  /// The source of this change.
36
+  final ChangeSource source;
37
+}
38
+
39
+/// A rich text document.
40
+class NotusDocument {
41
+  /// Creates new empty Notus document.
42
+  NotusDocument()
43
+      : _heuristics = NotusHeuristics.fallback,
44
+        _delta = new Delta()..insert('\n') {
45
+    _loadDocument(_delta);
46
+  }
47
+
48
+  NotusDocument.fromJson(dynamic data)
49
+      : _heuristics = NotusHeuristics.fallback,
50
+        _delta = Delta.fromJson(data) {
51
+    _loadDocument(_delta);
52
+  }
53
+
54
+  NotusDocument.fromDelta(Delta delta)
55
+      : assert(delta != null),
56
+        _heuristics = NotusHeuristics.fallback,
57
+        _delta = delta {
58
+    _loadDocument(_delta);
59
+  }
60
+
61
+  final NotusHeuristics _heuristics;
62
+
63
+  /// The root node of this document tree.
64
+  RootNode get root => _root;
65
+  final RootNode _root = new RootNode();
66
+
67
+  /// Length of this document.
68
+  int get length => _root.length;
69
+
70
+  /// Stream of [NotusChange]s applied to this document.
71
+  Stream<NotusChange> get changes => _controller.stream;
72
+
73
+  final StreamController<NotusChange> _controller =
74
+      new StreamController.broadcast();
75
+
76
+  /// Returns contents of this document as [Delta].
77
+  Delta toDelta() => new Delta.from(_delta);
78
+  Delta _delta;
79
+
80
+  /// Returns plain text representation of this document.
81
+  String toPlainText() => _delta.toList().map((op) => op.data).join();
82
+
83
+  dynamic toJson() {
84
+    return _delta.toJson();
85
+  }
86
+
87
+  /// Returns `true` if this document and associated stream of [changes]
88
+  /// is closed.
89
+  ///
90
+  /// Modifying a closed document is not allowed.
91
+  bool get isClosed => _controller.isClosed;
92
+
93
+  /// Closes [changes] stream.
94
+  void close() {
95
+    _controller.close();
96
+  }
97
+
98
+  /// Inserts [value] in this document at specified [index]. Value must be a
99
+  /// [String] or an instance of [NotusEmbed].
100
+  ///
101
+  /// This method applies heuristic rules before modifying this document and
102
+  /// produces a [NotusChange] with source set to [ChangeSource.local].
103
+  ///
104
+  /// Returns an instance of [Delta] actually composed into this document.
105
+  Delta insert(int index, dynamic value) {
106
+    assert(index >= 0);
107
+    assert(value is String || value is NotusEmbed,
108
+        'Value must be a string or a NotusEmbed.');
109
+    Delta change;
110
+    if (value is String) {
111
+      assert(value.isNotEmpty);
112
+      value = _sanitizeString(value);
113
+      if (value.isEmpty) return new Delta();
114
+      change = _heuristics.applyInsertRules(this, index, value);
115
+    } else {
116
+      NotusEmbed embed = value;
117
+      change = _heuristics.applyEmbedRules(this, index, embed.attribute);
118
+    }
119
+    compose(change, ChangeSource.local);
120
+    return change;
121
+  }
122
+
123
+  /// Deletes [length] of characters from this document starting at [index].
124
+  ///
125
+  /// This method applies heuristic rules before modifying this document and
126
+  /// produces a [NotusChange] with source set to [ChangeSource.local].
127
+  ///
128
+  /// Returns an instance of [Delta] actually composed into this document.
129
+  Delta delete(int index, int length) {
130
+    assert(index >= 0 && length > 0);
131
+    // TODO: need a heuristic rule to ensure last line-break.
132
+    final change = _heuristics.applyDeleteRules(this, index, length);
133
+    compose(change, ChangeSource.local);
134
+    return change;
135
+  }
136
+
137
+  /// Replaces [length] of characters starting at [index] with [value]. Value
138
+  /// must be a [String] or an instance of [NotusEmbed].
139
+  ///
140
+  /// This method applies heuristic rules before modifying this document and
141
+  /// produces a [NotusChange] with source set to [ChangeSource.local].
142
+  ///
143
+  /// Returns an instance of [Delta] actually composed into this document.
144
+  Delta replace(int index, int length, dynamic value) {
145
+    assert(index >= 0 && (value.isNotEmpty || length > 0),
146
+        'With index $index, length $length and text "$value"');
147
+    assert(value is String || value is NotusEmbed,
148
+        'Value must be a string or a NotusEmbed.');
149
+
150
+    final hasInsert =
151
+        (value is NotusEmbed || (value is String && value.isNotEmpty));
152
+    Delta delta = new Delta();
153
+
154
+    // We have to compose before applying delete rules
155
+    // Otherwise delete would be operating on stale document snapshot.
156
+    if (hasInsert) {
157
+      delta = insert(index, value);
158
+      index = delta.transformPosition(index);
159
+    }
160
+
161
+    if (length > 0) {
162
+      final deleteDelta = delete(index, length);
163
+      delta = delta.compose(deleteDelta);
164
+    }
165
+    return delta;
166
+  }
167
+
168
+  /// Formats portion of this document with specified [attribute].
169
+  ///
170
+  /// Applies heuristic rules before modifying this document and
171
+  /// produces a [NotusChange] with source set to [ChangeSource.local].
172
+  ///
173
+  /// Returns an instance of [Delta] actually composed into this document.
174
+  /// The returned [Delta] may be empty in which case this document remains
175
+  /// unchanged and no [NotusChange] is published to [changes] stream.
176
+  Delta format(int index, int length, NotusAttribute attribute) {
177
+    assert(index >= 0 && length >= 0 && attribute != null);
178
+    Delta change;
179
+    if (attribute is EmbedAttribute) {
180
+      assert(length == 1);
181
+      change = _heuristics.applyEmbedRules(this, index, attribute);
182
+    } else {
183
+      change = _heuristics.applyFormatRules(this, index, length, attribute);
184
+    }
185
+    if (change.isNotEmpty) {
186
+      compose(change, ChangeSource.local);
187
+    }
188
+    return change;
189
+  }
190
+
191
+  /// Returns style of specified text range.
192
+  ///
193
+  /// Only attributes applied to all characters within this range are
194
+  /// included in the result. Inline and block level attributes are
195
+  /// handled separately, e.g.:
196
+  ///
197
+  /// - block attribute X is included in the result only if it exists for
198
+  ///   every line within this range (partially included lines are counted).
199
+  /// - inline attribute X is included in the result only if it exists
200
+  ///   for every character within this range (line-break characters excluded).
201
+  NotusStyle collectStyle(int index, int length) {
202
+    var result = lookupLine(index);
203
+    LineNode line = result.node;
204
+    return line.collectStyle(result.offset, length);
205
+  }
206
+
207
+  /// Returns [LineNode] located at specified character [offset].
208
+  LookupResult lookupLine(int offset) {
209
+    // TODO: prevent user from moving caret after last line-break.
210
+    var result = _root.lookup(offset, inclusive: true);
211
+    if (result.node is LineNode) return result;
212
+    BlockNode block = result.node;
213
+    return block.lookup(result.offset, inclusive: true);
214
+  }
215
+
216
+  /// Composes [change] into this document.
217
+  ///
218
+  /// Use this method with caution as it does not apply heuristic rules to the
219
+  /// [change].
220
+  ///
221
+  /// It is callers responsibility to ensure that the [change] conforms to
222
+  /// the document model semantics and can be composed with the current state
223
+  /// of this document.
224
+  ///
225
+  /// In case the [change] is invalid, behavior of this method is unspecified.
226
+  void compose(Delta change, ChangeSource source) {
227
+    _checkMutable();
228
+    change.trim();
229
+    assert(change.isNotEmpty);
230
+
231
+    int offset = 0;
232
+    final before = toDelta();
233
+    for (final Operation op in change.toList()) {
234
+      final attributes =
235
+          op.attributes != null ? NotusStyle.fromJson(op.attributes) : null;
236
+      if (op.isInsert) {
237
+        _root.insert(offset, op.data, attributes);
238
+      } else if (op.isDelete) {
239
+        _root.delete(offset, op.length);
240
+      } else if (op.attributes != null) {
241
+        _root.retain(offset, op.length, attributes);
242
+      }
243
+      if (!op.isDelete) offset += op.length;
244
+    }
245
+    _delta = _delta.compose(change);
246
+
247
+    if (_delta != _root.toDelta()) {
248
+      throw new StateError('Compose produced inconsistent results. '
249
+          'This is likely due to a bug in the library.');
250
+    }
251
+    _controller.add(new NotusChange(before, change, source));
252
+  }
253
+
254
+  //
255
+  // Overridden members
256
+  //
257
+  @override
258
+  String toString() => _root.toString();
259
+
260
+  //
261
+  // Private members
262
+  //
263
+
264
+  void _checkMutable() {
265
+    assert(!_controller.isClosed,
266
+        'Cannot modify Notus document after it was closed.');
267
+  }
268
+
269
+  String _sanitizeString(String value) {
270
+    if (value.contains(EmbedNode.kPlainTextPlaceholder)) {
271
+      return value.replaceAll(EmbedNode.kPlainTextPlaceholder, '');
272
+    } else {
273
+      return value;
274
+    }
275
+  }
276
+
277
+  /// Loads [document] delta into this document.
278
+  void _loadDocument(Delta doc) {
279
+    assert(doc.last.data.endsWith('\n'),
280
+        'Invalid document delta. Document delta must always end with a line-break.');
281
+    int offset = 0;
282
+    for (final Operation op in doc.toList()) {
283
+      final style =
284
+          op.attributes != null ? NotusStyle.fromJson(op.attributes) : null;
285
+      if (op.isInsert) {
286
+        _root.insert(offset, op.data, style);
287
+      } else {
288
+        throw new ArgumentError.value(doc,
289
+            "Document Delta can only contain insert operations but ${op.key} found.");
290
+      }
291
+      offset += op.length;
292
+    }
293
+    // Must remove last line if it's empty and with no styles.
294
+    // TODO: find a way for DocumentRoot to not create extra line when composing initial delta.
295
+    Node node = _root.last;
296
+    if (node is LineNode &&
297
+        node.parent is! BlockNode &&
298
+        node.style.isEmpty &&
299
+        _root.childCount > 1) {
300
+      _root.remove(node);
301
+    }
302
+  }
303
+}

+ 451
- 0
packages/notus/lib/src/document/attributes.dart Wyświetl plik

@@ -0,0 +1,451 @@
1
+// Copyright (c) 2018, the Zefyr project authors.  Please see the AUTHORS file
2
+// for details. All rights reserved. Use of this source code is governed by a
3
+// BSD-style license that can be found in the LICENSE file.
4
+import 'package:collection/collection.dart';
5
+import 'package:quiver_hashcode/hashcode.dart';
6
+
7
+/// Scope of a style attribute, defines context in which an attribute can be
8
+/// applied.
9
+enum NotusAttributeScope {
10
+  /// Inline-scoped attributes are applicable to all characters within a line.
11
+  ///
12
+  /// Inline attributes cannot be applied to the line itself.
13
+  inline,
14
+
15
+  /// Line-scoped attributes are only applicable to a line of text as a whole.
16
+  ///
17
+  /// Line attributes do not have any effect on any character within the line.
18
+  line,
19
+}
20
+
21
+/// Interface for objects which provide access to an attribute key.
22
+///
23
+/// Implemented by [NotusAttribute] and [NotusAttributeBuilder].
24
+abstract class NotusAttributeKey<T> {
25
+  /// Unique key of this attribute.
26
+  String get key;
27
+}
28
+
29
+/// Builder for style attributes.
30
+///
31
+/// Useful in scenarios when an attribute value is not known upfront, for
32
+/// instance, link attribute.
33
+///
34
+/// See also:
35
+///   * [LinkAttributeBuilder]
36
+///   * [BlockAttributeBuilder]
37
+///   * [HeadingAttributeBuilder]
38
+abstract class NotusAttributeBuilder<T> implements NotusAttributeKey<T> {
39
+  const NotusAttributeBuilder._(this.key, this.scope);
40
+
41
+  final String key;
42
+  final NotusAttributeScope scope;
43
+  NotusAttribute<T> get unset => new NotusAttribute<T>._(key, scope, null);
44
+  NotusAttribute<T> withValue(T value) =>
45
+      new NotusAttribute<T>._(key, scope, value);
46
+}
47
+
48
+/// Style attribute applicable to a segment of a Notus document.
49
+///
50
+/// All supported attributes are available via static fields on this class.
51
+/// Here is an example of applying styles to a document:
52
+///
53
+///     void makeItPretty(Notus document) {
54
+///       // Format 5 characters at position 0 as bold
55
+///       document.format(0, 5, NotusAttribute.bold);
56
+///       // Similarly for italic
57
+///       document.format(0, 5, NotusAttribute.italic);
58
+///       // Format first line as a heading (h1)
59
+///       // Note that there is no need to specify character range of the whole
60
+///       // line. Simply set index position to anywhere within the line and
61
+///       // length to 0.
62
+///       document.format(0, 0, NotusAttribute.h1);
63
+///     }
64
+///
65
+/// List of supported attributes:
66
+///
67
+///   * [NotusAttribute.bold]
68
+///   * [NotusAttribute.italic]
69
+///   * [NotusAttribute.link]
70
+///   * [NotusAttribute.heading]
71
+///   * [NotusAttribute.block]
72
+class NotusAttribute<T> implements NotusAttributeBuilder<T> {
73
+  static final Map<String, NotusAttributeBuilder> _registry = {
74
+    NotusAttribute.bold.key: NotusAttribute.bold,
75
+    NotusAttribute.italic.key: NotusAttribute.italic,
76
+    NotusAttribute.link.key: NotusAttribute.link,
77
+    NotusAttribute.heading.key: NotusAttribute.heading,
78
+    NotusAttribute.block.key: NotusAttribute.block,
79
+    NotusAttribute.embed.key: NotusAttribute.embed,
80
+  };
81
+
82
+  // Inline attributes
83
+
84
+  /// Bold style attribute.
85
+  static const bold = const _BoldAttribute();
86
+
87
+  /// Italic style attribute.
88
+  static const italic = const _ItalicAttribute();
89
+
90
+  /// Link style attribute.
91
+  static const link = const LinkAttributeBuilder._();
92
+
93
+  // Line attributes
94
+
95
+  /// Heading style attribute.
96
+  static const heading = const HeadingAttributeBuilder._();
97
+
98
+  /// Alias for [NotusAttribute.heading.level1].
99
+  static NotusAttribute<int> get h1 => heading.level1;
100
+
101
+  /// Alias for [NotusAttribute.heading.level2].
102
+  static NotusAttribute<int> get h2 => heading.level2;
103
+
104
+  /// Alias for [NotusAttribute.heading.level3].
105
+  static NotusAttribute<int> get h3 => heading.level3;
106
+
107
+  /// Block attribute
108
+  static const block = const BlockAttributeBuilder._();
109
+
110
+  /// Alias for [NotusAttribute.block.bulletList].
111
+  static NotusAttribute<String> get ul => block.bulletList;
112
+
113
+  /// Alias for [NotusAttribute.block.numberList].
114
+  static NotusAttribute<String> get ol => block.numberList;
115
+
116
+  /// Alias for [NotusAttribute.block.quote].
117
+  static NotusAttribute<String> get bq => block.quote;
118
+
119
+  /// Alias for [NotusAttribute.block.code].
120
+  static NotusAttribute<String> get code => block.code;
121
+
122
+  /// Embed style attribute.
123
+  static const embed = const EmbedAttributeBuilder._();
124
+
125
+  factory NotusAttribute._fromKeyValue(String key, T value) {
126
+    if (!_registry.containsKey(key))
127
+      throw new ArgumentError.value(
128
+          key, 'No attribute with key "$key" registered.');
129
+    final builder = _registry[key];
130
+    return builder.withValue(value);
131
+  }
132
+
133
+  const NotusAttribute._(this.key, this.scope, this.value);
134
+
135
+  /// Unique key of this attribute.
136
+  final String key;
137
+
138
+  /// Scope of this attribute.
139
+  final NotusAttributeScope scope;
140
+
141
+  /// Value of this attribute.
142
+  ///
143
+  /// If value is `null` then this attribute represents a transient action
144
+  /// of removing associated style and is never persisted in a resulting
145
+  /// document.
146
+  ///
147
+  /// See also [unset], [NotusStyle.merge] and [NotusStyle.put]
148
+  /// for details.
149
+  final T value;
150
+
151
+  /// Returns special "unset" version of this attribute.
152
+  ///
153
+  /// Unset attribute's [value] is always `null`.
154
+  ///
155
+  /// When composed into a rich text document, unset attributes remove
156
+  /// associated style.
157
+  NotusAttribute<T> get unset => new NotusAttribute<T>._(key, scope, null);
158
+
159
+  /// Returns `true` if this attribute is an unset attribute.
160
+  bool get isUnset => value == null;
161
+
162
+  /// Returns `true` if this is an inline-scoped attribute.
163
+  bool get isInline => scope == NotusAttributeScope.inline;
164
+
165
+  NotusAttribute<T> withValue(T value) =>
166
+      new NotusAttribute<T>._(key, scope, value);
167
+
168
+  @override
169
+  bool operator ==(other) {
170
+    if (identical(this, other)) return true;
171
+    if (other is! NotusAttribute<T>) return false;
172
+    NotusAttribute<T> typedOther = other;
173
+    return key == typedOther.key &&
174
+        scope == typedOther.scope &&
175
+        value == typedOther.value;
176
+  }
177
+
178
+  @override
179
+  int get hashCode => hash3(key, scope, value);
180
+
181
+  @override
182
+  String toString() => '$key: $value';
183
+
184
+  Map<String, dynamic> toJson() => <String, dynamic>{key: value};
185
+}
186
+
187
+/// Collection of style attributes.
188
+class NotusStyle {
189
+  NotusStyle._(this._data);
190
+
191
+  final Map<String, NotusAttribute> _data;
192
+
193
+  static NotusStyle fromJson(Map data) {
194
+    if (data == null) return new NotusStyle();
195
+
196
+    final result = data.map((key, value) {
197
+      var attr = new NotusAttribute._fromKeyValue(key, value);
198
+      return new MapEntry<String, NotusAttribute>(key, attr);
199
+    });
200
+    return new NotusStyle._(result);
201
+  }
202
+
203
+  NotusStyle() : _data = new Map<String, NotusAttribute>();
204
+
205
+  /// Returns `true` if this attribute set is empty.
206
+  bool get isEmpty => _data.isEmpty;
207
+
208
+  /// Returns `true` if this attribute set is note empty.
209
+  bool get isNotEmpty => _data.isNotEmpty;
210
+
211
+  /// Returns `true` if this style is not empty and contains only inline-scoped
212
+  /// attributes and is not empty.
213
+  bool get isInline => isNotEmpty && values.every((item) => item.isInline);
214
+
215
+  /// Checks that this style has only one attribute, and returns that attribute.
216
+  NotusAttribute get single => _data.values.single;
217
+
218
+  /// Returns `true` if attribute with [key] is present in this set.
219
+  ///
220
+  /// Only checks for presence of specified [key] regardless of the associated
221
+  /// value.
222
+  ///
223
+  /// To test if this set contains an attribute with specific value consider
224
+  /// using [containsSame].
225
+  bool contains(NotusAttributeKey key) => _data.containsKey(key.key);
226
+
227
+  /// Returns `true` if this set contains attribute with the same value as
228
+  /// [attribute].
229
+  bool containsSame(NotusAttribute attribute) {
230
+    assert(attribute != null);
231
+    return get(attribute) == attribute;
232
+  }
233
+
234
+  /// Returns value of specified attribute [key] in this set.
235
+  T value<T>(NotusAttributeKey<T> key) => get(key).value;
236
+
237
+  /// Returns [NotusAttribute] from this set by specified [key].
238
+  NotusAttribute<T> get<T>(NotusAttributeKey<T> key) =>
239
+      _data[key.key] as NotusAttribute<T>;
240
+
241
+  /// Returns collection of all attribute keys in this set.
242
+  Iterable<String> get keys => _data.keys;
243
+
244
+  /// Returns collection of all attributes in this set.
245
+  Iterable<NotusAttribute> get values => _data.values;
246
+
247
+  /// Puts [attribute] into this attribute set and returns result as a new set.
248
+  NotusStyle put(NotusAttribute attribute) {
249
+    final result = new Map<String, NotusAttribute>.from(_data);
250
+    result[attribute.key] = attribute;
251
+    return new NotusStyle._(result);
252
+  }
253
+
254
+  /// Merges this attribute set with [attribute] and returns result as a new
255
+  /// attribute set.
256
+  ///
257
+  /// Performs compaction if [attribute] is an "unset" value, e.g. removes
258
+  /// corresponding attribute from this set completely.
259
+  ///
260
+  /// See also [put] method which does not perform compaction and allows
261
+  /// constructing styles with "unset" values.
262
+  NotusStyle merge(NotusAttribute attribute) {
263
+    final merged = new Map<String, NotusAttribute>.from(_data);
264
+    if (attribute.isUnset) {
265
+      merged.remove(attribute.key);
266
+    } else {
267
+      merged[attribute.key] = attribute;
268
+    }
269
+    return new NotusStyle._(merged);
270
+  }
271
+
272
+  /// Merges all attributes from [other] into this style and returns result
273
+  /// as a new instance of [NotusStyle].
274
+  NotusStyle mergeAll(NotusStyle other) {
275
+    var result = new NotusStyle._(_data);
276
+    for (var value in other.values) {
277
+      result = result.merge(value);
278
+    }
279
+    return result;
280
+  }
281
+
282
+  /// Removes [attributes] from this style and returns new instance of
283
+  /// [NotusStyle] containing result.
284
+  NotusStyle removeAll(Iterable<NotusAttribute> attributes) {
285
+    final merged = new Map<String, NotusAttribute>.from(_data);
286
+    attributes.map((item) => item.key).forEach(merged.remove);
287
+    return new NotusStyle._(merged);
288
+  }
289
+
290
+  /// Returns JSON-serializable representation of this style.
291
+  Map<String, dynamic> toJson() => _data.isEmpty
292
+      ? null
293
+      : _data.map(
294
+          (_, value) => new MapEntry<String, dynamic>(value.key, value.value));
295
+
296
+  @override
297
+  bool operator ==(other) {
298
+    if (identical(this, other)) return true;
299
+    if (other is! NotusStyle) return false;
300
+    NotusStyle typedOther = other;
301
+    final eq = const MapEquality<String, NotusAttribute>();
302
+    return eq.equals(_data, typedOther._data);
303
+  }
304
+
305
+  @override
306
+  int get hashCode {
307
+    final hashes = _data.entries.map((entry) => hash2(entry.key, entry.value));
308
+    return hashObjects(hashes);
309
+  }
310
+
311
+  @override
312
+  String toString() => "{${_data.values.join(', ')}}";
313
+}
314
+
315
+/// Applies bold style to a text segment.
316
+class _BoldAttribute extends NotusAttribute<bool> {
317
+  const _BoldAttribute() : super._('b', NotusAttributeScope.inline, true);
318
+}
319
+
320
+/// Applies italic style to a text segment.
321
+class _ItalicAttribute extends NotusAttribute<bool> {
322
+  const _ItalicAttribute() : super._('i', NotusAttributeScope.inline, true);
323
+}
324
+
325
+/// Builder for link attribute values.
326
+///
327
+/// There is no need to use this class directly, consider using
328
+/// [NotusAttribute.link] instead.
329
+class LinkAttributeBuilder extends NotusAttributeBuilder<String> {
330
+  static const _kLink = 'a';
331
+  const LinkAttributeBuilder._() : super._(_kLink, NotusAttributeScope.inline);
332
+
333
+  /// Creates a link attribute with specified link [value].
334
+  NotusAttribute<String> fromString(String value) =>
335
+      new NotusAttribute<String>._(key, scope, value);
336
+}
337
+
338
+/// Builder for heading attribute styles.
339
+///
340
+/// There is no need to use this class directly, consider using
341
+/// [NotusAttribute.heading] instead.
342
+class HeadingAttributeBuilder extends NotusAttributeBuilder<int> {
343
+  static const _kHeading = 'heading';
344
+  const HeadingAttributeBuilder._()
345
+      : super._(_kHeading, NotusAttributeScope.line);
346
+
347
+  /// Level 1 heading, equivalent of `H1` in HTML.
348
+  NotusAttribute<int> get level1 => new NotusAttribute<int>._(key, scope, 1);
349
+
350
+  /// Level 2 heading, equivalent of `H2` in HTML.
351
+  NotusAttribute<int> get level2 => new NotusAttribute<int>._(key, scope, 2);
352
+
353
+  /// Level 3 heading, equivalent of `H3` in HTML.
354
+  NotusAttribute<int> get level3 => new NotusAttribute<int>._(key, scope, 3);
355
+}
356
+
357
+/// Builder for block attribute styles (number/bullet lists, code and quote).
358
+///
359
+/// There is no need to use this class directly, consider using
360
+/// [NotusAttribute.block] instead.
361
+class BlockAttributeBuilder extends NotusAttributeBuilder<String> {
362
+  static const _kBlock = 'block';
363
+  const BlockAttributeBuilder._() : super._(_kBlock, NotusAttributeScope.line);
364
+
365
+  /// Formats a block of lines as a bullet list.
366
+  NotusAttribute<String> get bulletList =>
367
+      new NotusAttribute<String>._(key, scope, 'ul');
368
+
369
+  /// Formats a block of lines as a number list.
370
+  NotusAttribute<String> get numberList =>
371
+      new NotusAttribute<String>._(key, scope, 'ol');
372
+
373
+  /// Formats a block of lines as a code snippet, using monospace font.
374
+  NotusAttribute<String> get code =>
375
+      new NotusAttribute<String>._(key, scope, 'code');
376
+
377
+  /// Formats a block of lines as a quote.
378
+  NotusAttribute<String> get quote =>
379
+      new NotusAttribute<String>._(key, scope, 'quote');
380
+}
381
+
382
+class EmbedAttributeBuilder
383
+    extends NotusAttributeBuilder<Map<String, dynamic>> {
384
+  const EmbedAttributeBuilder._()
385
+      : super._(EmbedAttribute._kEmbed, NotusAttributeScope.inline);
386
+
387
+  NotusAttribute<Map<String, dynamic>> get horizontalRule =>
388
+      EmbedAttribute.horizontalRule();
389
+
390
+  NotusAttribute<Map<String, dynamic>> image(String source) =>
391
+      EmbedAttribute.image(source);
392
+
393
+  @override
394
+  NotusAttribute<Map<String, dynamic>> get unset => EmbedAttribute._(null);
395
+
396
+  NotusAttribute<Map<String, dynamic>> withValue(Map<String, dynamic> value) =>
397
+      EmbedAttribute._(value);
398
+}
399
+
400
+/// Type of embedded content.
401
+enum EmbedType { horizontalRule, image }
402
+
403
+class EmbedAttribute extends NotusAttribute<Map<String, dynamic>> {
404
+  static const _kValueEquality = const MapEquality<String, dynamic>();
405
+  static const _kEmbed = 'embed';
406
+  static const _kHorizontalRuleEmbed = 'hr';
407
+  static const _kImageEmbed = 'image';
408
+
409
+  EmbedAttribute._(Map<String, dynamic> value)
410
+      : super._(_kEmbed, NotusAttributeScope.inline, value);
411
+
412
+  EmbedAttribute.horizontalRule()
413
+      : this._(<String, dynamic>{'type': _kHorizontalRuleEmbed});
414
+
415
+  EmbedAttribute.image(String source)
416
+      : this._(<String, dynamic>{'type': _kImageEmbed, 'source': source});
417
+
418
+  /// Type of this embed.
419
+  EmbedType get type {
420
+    if (value['type'] == _kHorizontalRuleEmbed) return EmbedType.horizontalRule;
421
+    if (value['type'] == _kImageEmbed) return EmbedType.image;
422
+    assert(false, 'Unknown embed attribute value $value.');
423
+    return null;
424
+  }
425
+
426
+  @override
427
+  NotusAttribute<Map<String, dynamic>> get unset => new EmbedAttribute._(null);
428
+
429
+  @override
430
+  bool operator ==(other) {
431
+    if (identical(this, other)) return true;
432
+    if (other is! EmbedAttribute) return false;
433
+    EmbedAttribute typedOther = other;
434
+    return key == typedOther.key &&
435
+        scope == typedOther.scope &&
436
+        _kValueEquality.equals(value, typedOther.value);
437
+  }
438
+
439
+  @override
440
+  int get hashCode {
441
+    final objects = [key, scope];
442
+    if (value != null) {
443
+      final valueHashes =
444
+          value.entries.map((entry) => hash2(entry.key, entry.value));
445
+      objects.addAll(valueHashes);
446
+    } else {
447
+      objects.add(value);
448
+    }
449
+    return hashObjects(objects);
450
+  }
451
+}

+ 100
- 0
packages/notus/lib/src/document/block.dart Wyświetl plik

@@ -0,0 +1,100 @@
1
+// Copyright (c) 2018, the Zefyr project authors.  Please see the AUTHORS file
2
+// for details. All rights reserved. Use of this source code is governed by a
3
+// BSD-style license that can be found in the LICENSE file.
4
+import 'package:quill_delta/quill_delta.dart';
5
+
6
+import 'attributes.dart';
7
+import 'line.dart';
8
+import 'node.dart';
9
+
10
+/// A block represents a group of adjacent [LineNode]s with the same block
11
+/// style.
12
+///
13
+/// Block examples: lists, quotes, code snippets.
14
+class BlockNode extends ContainerNode<LineNode>
15
+    with StyledNodeMixin
16
+    implements StyledNode {
17
+  /// Creates new unmounted [BlockNode] with the same attributes.
18
+  BlockNode clone() {
19
+    final node = new BlockNode();
20
+    node.applyStyle(style);
21
+    return node;
22
+  }
23
+
24
+  /// Unwraps [line] from this block.
25
+  void unwrapLine(LineNode line) {
26
+    assert(children.contains(line));
27
+
28
+    if (line.isFirst) {
29
+      line.unlink();
30
+      insertBefore(line);
31
+    } else if (line.isLast) {
32
+      line.unlink();
33
+      insertAfter(line);
34
+    } else {
35
+      /// need to split this block into two as [line] is in the middle.
36
+      BlockNode before = clone();
37
+      insertBefore(before);
38
+
39
+      LineNode child = this.first;
40
+      while (child != line) {
41
+        child.unlink();
42
+        before.add(child);
43
+        child = this.first;
44
+      }
45
+      line.unlink();
46
+      insertBefore(line);
47
+    }
48
+    optimize();
49
+  }
50
+
51
+  @override
52
+  LineNode get defaultChild => new LineNode();
53
+
54
+  @override
55
+  Delta toDelta() {
56
+    // Line nodes take care of incorporating block style into their delta.
57
+    return children
58
+        .map((child) => child.toDelta())
59
+        .fold(new Delta(), (a, b) => a.concat(b));
60
+  }
61
+
62
+  @override
63
+  String toString() {
64
+    final block = style.value(NotusAttribute.block);
65
+    final buffer = new StringBuffer('§ {$block}\n');
66
+    for (var child in children) {
67
+      final tree = child.isLast ? '└' : '├';
68
+      buffer.write('  $tree $child');
69
+      if (!child.isLast) buffer.writeln();
70
+    }
71
+    return buffer.toString();
72
+  }
73
+
74
+  @override
75
+  void optimize() {
76
+    if (isEmpty) {
77
+      Node sibling = this.previous;
78
+      unlink();
79
+      if (sibling != null) sibling.optimize();
80
+      return;
81
+    }
82
+
83
+    var block = this;
84
+    if (!block.isFirst && block.previous is BlockNode) {
85
+      BlockNode prev = block.previous;
86
+      if (prev.style == block.style) {
87
+        block.moveChildren(prev);
88
+        block.unlink();
89
+        block = prev;
90
+      }
91
+    }
92
+    if (!block.isLast && block.next is BlockNode) {
93
+      BlockNode nextBlock = block.next;
94
+      if (nextBlock.style == block.style) {
95
+        nextBlock.moveChildren(block);
96
+        nextBlock.unlink();
97
+      }
98
+    }
99
+  }
100
+}

+ 254
- 0
packages/notus/lib/src/document/leaf.dart Wyświetl plik

@@ -0,0 +1,254 @@
1
+// Copyright (c) 2018, the Zefyr project authors.  Please see the AUTHORS file
2
+// for details. All rights reserved. Use of this source code is governed by a
3
+// BSD-style license that can be found in the LICENSE file.
4
+import 'dart:math' as math;
5
+
6
+import 'package:quill_delta/quill_delta.dart';
7
+
8
+import 'attributes.dart';
9
+import 'line.dart';
10
+import 'node.dart';
11
+
12
+/// A leaf node in Notus document tree.
13
+abstract class LeafNode extends Node
14
+    with StyledNodeMixin
15
+    implements StyledNode {
16
+  /// Creates a new [LeafNode] with specified [value].
17
+  LeafNode._([String value = ''])
18
+      : assert(value != null && !value.contains('\n')),
19
+        _value = value;
20
+
21
+  factory LeafNode([String value = '']) {
22
+    LeafNode node;
23
+    if (value == kZeroWidthSpace) {
24
+      // Zero-width space is reserved for embed nodes.
25
+      node = new EmbedNode();
26
+    } else {
27
+      assert(
28
+          !value.contains(kZeroWidthSpace),
29
+          'Zero-width space is reserved for embed leaf nodes and cannot be used '
30
+          'inside regular text nodes.');
31
+      node = new TextNode(value);
32
+    }
33
+    return node;
34
+  }
35
+
36
+  /// Plain-text value of this node.
37
+  String get value => _value;
38
+  String _value;
39
+
40
+  /// Splits this leaf node at [index] and returns new node.
41
+  ///
42
+  /// If this is the last node in its list and [index] equals this node's
43
+  /// length then this method returns `null` as there is nothing left to split.
44
+  /// If there is another leaf node after this one and [index] equals this
45
+  /// node's length then the next leaf node is returned.
46
+  ///
47
+  /// If [index] equals to `0` then this node itself is returned unchanged.
48
+  ///
49
+  /// In case a new node is actually split from this one, it inherits this
50
+  /// node's style.
51
+  LeafNode splitAt(int index) {
52
+    assert(index >= 0 && index <= length);
53
+    if (index == 0) return this;
54
+    if (index == length && isLast) return null;
55
+    if (index == length && !isLast) return next;
56
+
57
+    String text = _value;
58
+    _value = text.substring(0, index);
59
+    final split = new LeafNode(text.substring(index));
60
+    split.applyStyle(style);
61
+    insertAfter(split);
62
+    return split;
63
+  }
64
+
65
+  /// Cuts a leaf node from [index] to the end of this node and returns new node
66
+  /// in detached state (e.g. [mounted] returns `false`).
67
+  ///
68
+  /// Splitting logic is identical to one described in [splitAt], meaning this
69
+  /// method may return `null`.
70
+  LeafNode cutAt(int index) {
71
+    assert(index >= 0 && index <= length);
72
+    LeafNode cut = splitAt(index);
73
+    cut?.unlink();
74
+    return cut;
75
+  }
76
+
77
+  /// Isolates a new leaf node starting at [index] with specified [length].
78
+  ///
79
+  /// Splitting logic is identical to one described in [splitAt], with one
80
+  /// exception that it is required for [index] to always be less than this
81
+  /// node's length. As a result this method always returns a [LeafNode]
82
+  /// instance. Note that returned node may still be the same as this node
83
+  /// if provided [index] is `0`.
84
+  LeafNode isolate(int index, int length) {
85
+    assert(
86
+        index >= 0 && index < this.length && (index + length <= this.length),
87
+        'Index or length is out of bounds. Index: $index, length: $length. '
88
+        'Actual node length: ${this.length}.');
89
+    // Since `index < this.length` (guarded by assert) below line
90
+    // always returns a new node.
91
+    LeafNode target = splitAt(index);
92
+    target.splitAt(length);
93
+    return target;
94
+  }
95
+
96
+  /// Formats this node and optimizes it with adjacent leaf nodes if needed.
97
+  void formatAndOptimize(NotusStyle style) {
98
+    if (style != null && style.isNotEmpty) {
99
+      applyStyle(style);
100
+    }
101
+    optimize();
102
+  }
103
+
104
+  @override
105
+  void applyStyle(NotusStyle value) {
106
+    assert(value != null && (value.isInline || value.isEmpty),
107
+        "Style cannot be applied to this leaf node: $value");
108
+    assert(() {
109
+      if (value.contains(NotusAttribute.embed)) {
110
+        if (value.get(NotusAttribute.embed) == NotusAttribute.embed.unset) {
111
+          throw 'Unsetting embed attribute is not allowed. '
112
+              'This operation means that the embed itself must be deleted from the document. '
113
+              'Make sure there is FormatEmbedsRule in your heuristics registry, '
114
+              'which is responsible for handling this scenario.';
115
+        }
116
+        if (this is! EmbedNode) {
117
+          throw 'Embed style can only be applied to an EmbedNode.';
118
+        }
119
+      }
120
+      return true;
121
+    }());
122
+
123
+    super.applyStyle(value);
124
+  }
125
+
126
+  @override
127
+  LineNode get parent => super.parent;
128
+
129
+  @override
130
+  int get length => _value.length;
131
+
132
+  @override
133
+  Delta toDelta() {
134
+    return new Delta()..insert(_value, style.toJson());
135
+  }
136
+
137
+  @override
138
+  String toPlainText() => _value;
139
+
140
+  @override
141
+  void insert(int index, String value, NotusStyle style) {
142
+    assert(index >= 0 && (index <= length), 'Index: $index, Length: $length.');
143
+    assert(value.isNotEmpty);
144
+    final node = new LeafNode(value);
145
+    if (index == length) {
146
+      insertAfter(node);
147
+    } else {
148
+      splitAt(index).insertBefore(node);
149
+    }
150
+    node.formatAndOptimize(style);
151
+  }
152
+
153
+  @override
154
+  void retain(int index, int length, NotusStyle style) {
155
+    if (style == null) return;
156
+
157
+    final local = math.min(this.length - index, length);
158
+    final node = isolate(index, local);
159
+
160
+    int remaining = length - local;
161
+    if (remaining > 0) {
162
+      assert(node.next != null);
163
+      node.next.retain(0, remaining, style);
164
+    }
165
+    // Optimize at the very end
166
+    node.formatAndOptimize(style);
167
+  }
168
+
169
+  @override
170
+  void delete(int index, int length) {
171
+    assert(index < this.length);
172
+
173
+    final local = math.min(this.length - index, length);
174
+    final target = isolate(index, local);
175
+    // Memorize siblings before un-linking.
176
+    final needsOptimize = target.previous;
177
+    final actualNext = target.next;
178
+    target.unlink();
179
+
180
+    int remaining = length - local;
181
+    if (remaining > 0) {
182
+      assert(actualNext != null);
183
+      actualNext.delete(0, remaining);
184
+    }
185
+
186
+    if (needsOptimize != null) needsOptimize.optimize();
187
+  }
188
+
189
+  @override
190
+  String toString() {
191
+    final keys = style.keys.toList(growable: false)..sort();
192
+    String styleKeys = keys.join();
193
+    return "⟨$value⟩$styleKeys";
194
+  }
195
+
196
+  /// Optimizes this text node by merging it with adjacent nodes if they share
197
+  /// the same style.
198
+  @override
199
+  void optimize() {
200
+    LeafNode node = this;
201
+    if (!node.isFirst) {
202
+      LeafNode mergeWith = node.previous;
203
+      if (mergeWith.style == node.style) {
204
+        mergeWith._value += node.value;
205
+        node.unlink();
206
+        node = mergeWith;
207
+      }
208
+    }
209
+    if (!node.isLast) {
210
+      LeafNode mergeWith = node.next;
211
+      if (mergeWith.style == node.style) {
212
+        node._value += mergeWith._value;
213
+        mergeWith.unlink();
214
+      }
215
+    }
216
+  }
217
+}
218
+
219
+/// A span of formatted text within a line in a Notus document.
220
+///
221
+/// TextNode is a leaf node of a document tree.
222
+///
223
+/// Parent of a text node is always a [LineNode], and as a consequence text
224
+/// node's [value] cannot contain any line-break characters.
225
+///
226
+/// See also:
227
+///
228
+///   * [LineNode], a node representing a line of text.
229
+///   * [BlockNode], a node representing a group of lines.
230
+class TextNode extends LeafNode {
231
+  TextNode([String content = '']) : super._(content);
232
+}
233
+
234
+final kZeroWidthSpace = new String.fromCharCode(0x200b);
235
+
236
+/// An embed node inside of a line in a Notus document.
237
+///
238
+/// Embed node is a leaf node similar to [TextNode]. It represents an
239
+/// arbitrary piece of non-text content embedded into a document, such as,
240
+/// image, horizontal rule, video, or any other object with defined structure,
241
+/// like tweet, for instance.
242
+///
243
+/// Embed node's length is always `1` character and it is represented with
244
+/// zero-width space in the document text.
245
+///
246
+/// Any inline style can be applied to an embed, however this does not
247
+/// necessarily mean the embed will look according to that style. For instance,
248
+/// applying "bold" style to an image gives no effect, while adding a "link" to
249
+/// an image actually makes the image react to user's action.
250
+class EmbedNode extends LeafNode {
251
+  static final kPlainTextPlaceholder = new String.fromCharCode(0x200b);
252
+
253
+  EmbedNode() : super._(kPlainTextPlaceholder);
254
+}

+ 320
- 0
packages/notus/lib/src/document/line.dart Wyświetl plik

@@ -0,0 +1,320 @@
1
+// Copyright (c) 2018, the Zefyr project authors.  Please see the AUTHORS file
2
+// for details. All rights reserved. Use of this source code is governed by a
3
+// BSD-style license that can be found in the LICENSE file.
4
+import 'dart:math' as math;
5
+
6
+import 'package:quill_delta/quill_delta.dart';
7
+
8
+import 'attributes.dart';
9
+import 'block.dart';
10
+import 'leaf.dart';
11
+import 'node.dart';
12
+
13
+/// A line of text in a Notus document.
14
+///
15
+/// LineNode serves as a container for [LeafNode]s, like [TextNode] and
16
+/// [ImageNode].
17
+class LineNode extends ContainerNode<LeafNode>
18
+    with StyledNodeMixin
19
+    implements StyledNode {
20
+  /// Returns next [LineNode] or `null` if this is the last line in the document.
21
+  LineNode get nextLine {
22
+    if (isLast) {
23
+      if (parent is BlockNode) {
24
+        if (parent.isLast) return null;
25
+        return (parent.next is BlockNode)
26
+            ? (parent.next as BlockNode).first
27
+            : parent.next;
28
+      } else
29
+        return null;
30
+    } else {
31
+      return (next is BlockNode) ? (next as BlockNode).first : next;
32
+    }
33
+  }
34
+
35
+  /// Creates new empty [LineNode] with the same style.
36
+  LineNode clone() {
37
+    final node = new LineNode();
38
+    node.applyStyle(style);
39
+    return node;
40
+  }
41
+
42
+  /// Splits this line into two at specified character [index].
43
+  ///
44
+  /// This is an equivalent of inserting a line-break character at [index].
45
+  LineNode splitAt(int index) {
46
+    assert(index == 0 || (index > 0 && index < length),
47
+        'Index is out of bounds. Index: $index. Actual node length: ${this.length}.');
48
+
49
+    LineNode line = clone();
50
+    insertAfter(line);
51
+    if (index == length - 1) return line;
52
+
53
+    final split = lookup(index);
54
+    while (!split.node.isLast) {
55
+      LeafNode child = this.last;
56
+      child.unlink();
57
+      line.addFirst(child);
58
+    }
59
+    LeafNode child = split.node;
60
+    line.addFirst(child.cutAt(split.offset));
61
+    return line;
62
+  }
63
+
64
+  /// Unwraps this line from it's parent [BlockNode].
65
+  ///
66
+  /// This method asserts if current [parent] of this line is not a [BlockNode].
67
+  void unwrap() {
68
+    assert(parent is BlockNode);
69
+    BlockNode block = parent;
70
+    block.unwrapLine(this);
71
+  }
72
+
73
+  /// Wraps this line with new parent [block].
74
+  ///
75
+  /// This line can not be in a [BlockNode] when this method is called.
76
+  void wrap(BlockNode block) {
77
+    assert(parent != null && parent is! BlockNode);
78
+    insertAfter(block);
79
+    unlink();
80
+    block.add(this);
81
+  }
82
+
83
+  /// Returns style for specified text range.
84
+  ///
85
+  /// Only attributes applied to all characters within this range are
86
+  /// included in the result. Inline and line level attributes are
87
+  /// handled separately, e.g.:
88
+  ///
89
+  /// - line attribute X is included in the result only if it exists for
90
+  ///   every line within this range (partially included lines are counted).
91
+  /// - inline attribute X is included in the result only if it exists
92
+  ///   for every character within this range (line-break characters excluded).
93
+  NotusStyle collectStyle(int offset, int length) {
94
+    int local = math.min(this.length - offset, length);
95
+
96
+    NotusStyle result = new NotusStyle();
97
+    Set<NotusAttribute> excluded = new Set();
98
+
99
+    void _handle(NotusStyle style) {
100
+      if (result.isEmpty) {
101
+        excluded.addAll(style.values);
102
+      } else {
103
+        for (var attr in result.values) {
104
+          if (!style.contains(attr)) {
105
+            excluded.add(attr);
106
+          }
107
+        }
108
+      }
109
+      final remaining = style.removeAll(excluded);
110
+      result = result.removeAll(excluded);
111
+      result = result.mergeAll(remaining);
112
+    }
113
+
114
+    final data = lookup(offset, inclusive: true);
115
+    TextNode node = data.node;
116
+    if (node != null) {
117
+      result = result.mergeAll(node.style);
118
+      int pos = node.length - data.offset;
119
+      while (!node.isLast && pos < local) {
120
+        node = node.next;
121
+        _handle(node.style);
122
+        pos += node.length;
123
+      }
124
+    }
125
+
126
+    result = result.mergeAll(this.style);
127
+    if (parent is BlockNode) {
128
+      BlockNode block = parent;
129
+      result = result.mergeAll(block.style);
130
+    }
131
+
132
+    int remaining = length - local;
133
+    if (remaining > 0) {
134
+      final rest = nextLine.collectStyle(0, remaining);
135
+      _handle(rest);
136
+    }
137
+
138
+    return result;
139
+  }
140
+
141
+  @override
142
+  LeafNode get defaultChild => new TextNode();
143
+
144
+  // TODO: should be able to cache length and invalidate on any child-related operation
145
+  @override
146
+  int get length => super.length + 1;
147
+
148
+  @override
149
+  Delta toDelta() {
150
+    final Delta delta = children
151
+        .map((text) => text.toDelta())
152
+        .fold(new Delta(), (a, b) => a.concat(b));
153
+    var attributes = style;
154
+    if (parent is BlockNode) {
155
+      BlockNode block = parent;
156
+      attributes = attributes.mergeAll(block.style);
157
+    }
158
+    delta.insert('\n', attributes.toJson());
159
+    return delta;
160
+  }
161
+
162
+  @override
163
+  String toPlainText() => super.toPlainText() + '\n';
164
+
165
+  @override
166
+  String toString() {
167
+    final body = children.join(' → ');
168
+    final styleString = style.isNotEmpty ? ' $style' : '';
169
+    return '¶ $body ⏎$styleString';
170
+  }
171
+
172
+  @override
173
+  void optimize() {
174
+    // No-op, line merging is done in insert/delete operations
175
+  }
176
+
177
+  @override
178
+  void insert(int index, String text, NotusStyle style) {
179
+    final lf = text.indexOf('\n');
180
+    if (lf == -1) {
181
+      _insertSafe(index, text, style);
182
+      // No need to update line or block format since those attributes can only
183
+      // be attached to `\n` character and we already know it's not present.
184
+      return;
185
+    }
186
+
187
+    final substring = text.substring(0, lf);
188
+    _insertSafe(index, substring, style);
189
+    if (substring.isNotEmpty) index += substring.length;
190
+
191
+    final nextLine = splitAt(index); // Next line inherits our format.
192
+
193
+    // Reset our format and unwrap from a block if needed.
194
+    clearStyle();
195
+    if (parent is BlockNode) unwrap();
196
+
197
+    // Now we can apply new format and re-layout.
198
+    _formatAndOptimize(style);
199
+
200
+    // Continue with remaining part.
201
+    final remaining = text.substring(lf + 1);
202
+    nextLine.insert(0, remaining, style);
203
+  }
204
+
205
+  @override
206
+  void retain(int index, int length, NotusStyle style) {
207
+    if (style == null) return;
208
+    int thisLength = this.length;
209
+
210
+    final local = math.min(thisLength - index, length);
211
+    // If index is at line-break character this is line/block format update.
212
+    bool isLineFormat = (index + local == thisLength) && local == 1;
213
+
214
+    if (isLineFormat) {
215
+      assert(
216
+          style.values.every((attr) => attr.scope == NotusAttributeScope.line),
217
+          'It is not allowed to apply inline attributes to line itself.');
218
+      _formatAndOptimize(style);
219
+    } else {
220
+      // otherwise forward to children as it's inline format update.
221
+      assert(index + local != thisLength,
222
+          'It is not allowed to apply inline attributes to line itself.');
223
+      assert(style.values
224
+          .every((attr) => attr.scope == NotusAttributeScope.inline));
225
+      super.retain(index, local, style);
226
+    }
227
+
228
+    int remaining = length - local;
229
+    if (remaining > 0) {
230
+      assert(nextLine != null);
231
+      nextLine.retain(0, remaining, style);
232
+    }
233
+  }
234
+
235
+  @override
236
+  void delete(int index, int length) {
237
+    final local = math.min(this.length - index, length);
238
+    bool isLFDeleted = (index + local == this.length);
239
+    if (isLFDeleted) {
240
+      // Our line-break deleted with all style information.
241
+      clearStyle();
242
+      if (local > 1) {
243
+        // Exclude line-break from delete range for children.
244
+        super.delete(index, local - 1);
245
+      }
246
+    } else {
247
+      super.delete(index, local);
248
+    }
249
+
250
+    int remaining = length - local;
251
+    if (remaining > 0) {
252
+      assert(nextLine != null);
253
+      nextLine.delete(0, remaining);
254
+    }
255
+    if (isLFDeleted && isNotEmpty) {
256
+      // Since we lost our line-break and still have child text nodes those must
257
+      // migrate to the next line.
258
+
259
+      // nextLine might have been unmounted since last assert so we need to
260
+      // check again we still have a line after us.
261
+      assert(nextLine != null);
262
+
263
+      // Move remaining stuff in this line to next line so that all attributes
264
+      // of nextLine are preserved.
265
+      nextLine.moveChildren(this); // TODO: avoid double move
266
+      moveChildren(nextLine);
267
+    }
268
+
269
+    if (isLFDeleted) {
270
+      // Now we can remove this line.
271
+      final block = parent; // remember reference before un-linking.
272
+      unlink();
273
+      block.optimize();
274
+    }
275
+  }
276
+
277
+  /// Formats this line and optimizes layout afterwards.
278
+  void _formatAndOptimize(NotusStyle newStyle) {
279
+    if (newStyle == null || newStyle.isEmpty) return;
280
+
281
+    applyStyle(newStyle);
282
+    if (!newStyle.contains(NotusAttribute.block))
283
+      return; // no block-level changes
284
+
285
+    final blockStyle = newStyle.get(NotusAttribute.block);
286
+    if (parent is BlockNode) {
287
+      final parentStyle = (parent as BlockNode).style.get(NotusAttribute.block);
288
+      if (blockStyle == NotusAttribute.block.unset) {
289
+        unwrap();
290
+      } else if (blockStyle != parentStyle) {
291
+        unwrap();
292
+        BlockNode block = new BlockNode();
293
+        block.applyAttribute(blockStyle);
294
+        wrap(block);
295
+        block.optimize();
296
+      } // else the same style, no-op.
297
+    } else if (blockStyle != NotusAttribute.block.unset) {
298
+      // Only wrap with a new block if this is not an unset
299
+      BlockNode block = new BlockNode();
300
+      block.applyAttribute(blockStyle);
301
+      wrap(block);
302
+      block.optimize();
303
+    }
304
+  }
305
+
306
+  void _insertSafe(int index, String text, NotusStyle style) {
307
+    assert(index == 0 || (index > 0 && index < length));
308
+    assert(text.contains('\n') == false);
309
+    if (text.isEmpty) return;
310
+
311
+    if (isEmpty) {
312
+      final child = new LeafNode(text);
313
+      add(child);
314
+      child.formatAndOptimize(style);
315
+    } else {
316
+      final LookupResult result = lookup(index, inclusive: true);
317
+      result.node.insert(result.offset, text, style);
318
+    }
319
+  }
320
+}

+ 310
- 0
packages/notus/lib/src/document/node.dart Wyświetl plik

@@ -0,0 +1,310 @@
1
+// Copyright (c) 2018, the Zefyr project authors.  Please see the AUTHORS file
2
+// for details. All rights reserved. Use of this source code is governed by a
3
+// BSD-style license that can be found in the LICENSE file.
4
+import 'dart:collection';
5
+
6
+import 'package:meta/meta.dart';
7
+import 'package:quill_delta/quill_delta.dart';
8
+
9
+import 'attributes.dart';
10
+import 'line.dart';
11
+
12
+/// An abstract node in a document tree.
13
+///
14
+/// Represents a segment of a Notus document with specified [offset]
15
+/// and [length].
16
+///
17
+/// The [offset] property is relative to [parent]. See also [documentOffset]
18
+/// which provides absolute offset of this node within the document.
19
+///
20
+/// The current parent node is exposed by the [parent] property. A node is
21
+/// considered [mounted] when the [parent] property is not `null`.
22
+abstract class Node extends LinkedListEntry<Node> {
23
+  /// Current parent of this node. May be null if this node is not mounted.
24
+  ContainerNode get parent => _parent;
25
+  ContainerNode _parent;
26
+
27
+  /// Returns `true` if this node is the first node in the [parent] list.
28
+  bool get isFirst => list.first == this;
29
+
30
+  /// Returns `true` if this node is the last node in the [parent] list.
31
+  bool get isLast => list.last == this;
32
+
33
+  /// Length of this node in characters.
34
+  int get length;
35
+
36
+  /// Returns `true` if this node is currently mounted, e.g. [parent] is not
37
+  /// `null`.
38
+  bool get mounted => _parent != null;
39
+
40
+  /// Offset in characters of this node relative to [parent] node.
41
+  ///
42
+  /// To get offset of this node in the document see [documentOffset].
43
+  int get offset {
44
+    if (isFirst) return 0;
45
+    int offset = 0;
46
+    Node node = this;
47
+    do {
48
+      node = node.previous;
49
+      offset += node.length;
50
+    } while (!node.isFirst);
51
+    return offset;
52
+  }
53
+
54
+  /// Offset in characters of this node in the document.
55
+  int get documentOffset {
56
+    int parentOffset = (_parent is! RootNode) ? _parent.documentOffset : 0;
57
+    return parentOffset + this.offset;
58
+  }
59
+
60
+  /// Returns `true` if this node contains character at specified [offset] in
61
+  /// the document.
62
+  bool containsOffset(int offset) {
63
+    int o = documentOffset;
64
+    return o <= offset && offset < o + length;
65
+  }
66
+
67
+  /// Optimize this node within [parent].
68
+  ///
69
+  /// Subclasses should override this method to perform necessary optimizations.
70
+  @protected
71
+  void optimize();
72
+
73
+  /// Returns [Delta] representation of this node.
74
+  Delta toDelta();
75
+
76
+  /// Returns plain-text representation of this node.
77
+  String toPlainText();
78
+
79
+  /// Insert [text] at specified character [index] with style [style].
80
+  void insert(int index, String text, NotusStyle style);
81
+
82
+  /// Format [length] characters of this node starting from [index] with
83
+  /// specified style [style].
84
+  void retain(int index, int length, NotusStyle style);
85
+
86
+  /// Delete [length] characters of this node starting from [index].
87
+  void delete(int index, int length);
88
+
89
+  @override
90
+  void insertBefore(Node entry) {
91
+    assert(entry._parent == null && _parent != null);
92
+    entry._parent = _parent;
93
+    super.insertBefore(entry);
94
+  }
95
+
96
+  @override
97
+  void insertAfter(Node entry) {
98
+    assert(entry._parent == null && _parent != null);
99
+    entry._parent = _parent;
100
+    super.insertAfter(entry);
101
+  }
102
+
103
+  @override
104
+  void unlink() {
105
+    assert(_parent != null);
106
+    _parent = null;
107
+    super.unlink();
108
+  }
109
+}
110
+
111
+/// Result of a child lookup in a [ContainerNode].
112
+class LookupResult {
113
+  /// The child node if found, otherwise `null`.
114
+  final Node node;
115
+
116
+  /// Starting offset within the child [node] which points at the same
117
+  /// character in the document as the original offset passed to
118
+  /// [ContainerNode.lookup] method.
119
+  final int offset;
120
+
121
+  LookupResult(this.node, this.offset);
122
+
123
+  /// Returns `true` if there is no child node found, e.g. [node] is `null`.
124
+  bool get isEmpty => node == null;
125
+
126
+  /// Returns `true` [node] is not `null`.
127
+  bool get isNotEmpty => node != null;
128
+}
129
+
130
+/// Container node can accommodate other nodes.
131
+///
132
+/// Delegates insert, retain and delete operations to children nodes. For each
133
+/// operation container looks for a child at specified index position and
134
+/// forwards operation to that child.
135
+///
136
+/// Most of the operation handling logic is implemented by [LineNode] and
137
+/// [TextNode].
138
+abstract class ContainerNode<T extends Node> extends Node {
139
+  final LinkedList<Node> _children = new LinkedList<Node>();
140
+
141
+  /// List of children.
142
+  LinkedList<Node> get children => _children;
143
+
144
+  /// Returns total number of child nodes in this container.
145
+  ///
146
+  /// To get text length of this container see [length].
147
+  int get childCount => _children.length;
148
+
149
+  /// Returns the first child [Node].
150
+  Node get first => _children.first;
151
+
152
+  /// Returns the last child [Node].
153
+  Node get last => _children.last;
154
+
155
+  /// Returns an instance of default child for this container node.
156
+  ///
157
+  /// Always returns fresh instance.
158
+  T get defaultChild;
159
+
160
+  /// Returns `true` if this container has no child nodes.
161
+  bool get isEmpty => _children.isEmpty;
162
+
163
+  /// Returns `true` if this container has at least 1 child.
164
+  bool get isNotEmpty => _children.isNotEmpty;
165
+
166
+  /// Adds [node] to the end of this container children list.
167
+  void add(T node) {
168
+    assert(node._parent == null);
169
+    node._parent = this;
170
+    _children.add(node);
171
+  }
172
+
173
+  /// Adds [node] to the beginning of this container children list.
174
+  void addFirst(T node) {
175
+    assert(node._parent == null);
176
+    node._parent = this;
177
+    _children.addFirst(node);
178
+  }
179
+
180
+  /// Removes [node] from this container.
181
+  void remove(T node) {
182
+    assert(node._parent == this);
183
+    node._parent = null;
184
+    _children.remove(node);
185
+  }
186
+
187
+  /// Moves children of this node to [newParent].
188
+  void moveChildren(ContainerNode newParent) {
189
+    if (isEmpty) return;
190
+    T toBeOptimized = newParent.isEmpty ? null : newParent.last;
191
+    while (isNotEmpty) {
192
+      T child = first;
193
+      child.unlink();
194
+      newParent.add(child);
195
+    }
196
+
197
+    /// In case [newParent] already had children we need to make sure
198
+    /// combined list is optimized.
199
+    if (toBeOptimized != null) toBeOptimized.optimize();
200
+  }
201
+
202
+  /// Looks up a child [Node] at specified character [offset] in this container.
203
+  ///
204
+  /// Returns [LookupResult]. The result may contain found node or `null` if
205
+  /// no node is found at specified offset.
206
+  ///
207
+  /// [LookupResult.offset] is set to relative offset within returned child node
208
+  /// which points at the same character position in the document as the
209
+  /// original [offset].
210
+  LookupResult lookup(int offset, {bool inclusive: false}) {
211
+    assert(offset >= 0 && offset <= this.length);
212
+
213
+    for (Node node in children) {
214
+      final int length = node.length;
215
+      if (offset < length || (inclusive && offset == length && (node.isLast))) {
216
+        return new LookupResult(node, offset);
217
+      }
218
+      offset -= length;
219
+    }
220
+    return new LookupResult(null, 0);
221
+  }
222
+
223
+  //
224
+  // Overridden members
225
+  //
226
+
227
+  @override
228
+  String toPlainText() => children.map((child) => child.toPlainText()).join();
229
+
230
+  /// Content length of this node's children. To get number of children in this
231
+  /// node use [childCount].
232
+  @override
233
+  int get length => _children.fold(0, (current, node) => current + node.length);
234
+
235
+  @override
236
+  void insert(int index, String value, NotusStyle style) {
237
+    assert(index == 0 || (index > 0 && index < length));
238
+
239
+    if (isEmpty) {
240
+      assert(index == 0);
241
+      Node node = defaultChild;
242
+      add(node);
243
+      node.insert(index, value, style);
244
+    } else {
245
+      LookupResult result = lookup(index);
246
+      result.node.insert(result.offset, value, style);
247
+    }
248
+  }
249
+
250
+  @override
251
+  void retain(int index, int length, NotusStyle attributes) {
252
+    assert(isNotEmpty);
253
+    final res = lookup(index);
254
+    res.node.retain(res.offset, length, attributes);
255
+  }
256
+
257
+  @override
258
+  void delete(int index, int length) {
259
+    assert(isNotEmpty);
260
+    final res = lookup(index);
261
+    res.node.delete(res.offset, length);
262
+  }
263
+
264
+  @override
265
+  String toString() => _children.join('\n');
266
+}
267
+
268
+/// An interface for document nodes with style.
269
+abstract class StyledNode implements Node {
270
+  /// Style of this node.
271
+  NotusStyle get style;
272
+}
273
+
274
+/// Mixin used by nodes that wish to implement [StyledNode] interface.
275
+abstract class StyledNodeMixin implements StyledNode {
276
+  @override
277
+  NotusStyle get style => _style;
278
+  NotusStyle _style = new NotusStyle();
279
+
280
+  /// Applies style [attribute] to this node.
281
+  void applyAttribute(NotusAttribute attribute) {
282
+    _style = _style.merge(attribute);
283
+  }
284
+
285
+  /// Applies new style [value] to this node. Provided [value] is merged
286
+  /// into current style.
287
+  void applyStyle(NotusStyle value) {
288
+    assert(value != null);
289
+    _style = _style.mergeAll(value);
290
+  }
291
+
292
+  /// Clears style of this node.
293
+  void clearStyle() {
294
+    _style = new NotusStyle();
295
+  }
296
+}
297
+
298
+/// Root node of document tree.
299
+class RootNode extends ContainerNode<ContainerNode<Node>> {
300
+  @override
301
+  ContainerNode<Node> get defaultChild => new LineNode();
302
+
303
+  @override
304
+  void optimize() {/* no-op */}
305
+
306
+  @override
307
+  Delta toDelta() => children
308
+      .map((child) => child.toDelta())
309
+      .fold(new Delta(), (a, b) => a.concat(b));
310
+}

+ 21
- 0
packages/notus/lib/src/embed.dart Wyświetl plik

@@ -0,0 +1,21 @@
1
+import 'document/attributes.dart';
2
+
3
+abstract class NotusEmbed {
4
+  NotusAttribute get attribute;
5
+}
6
+
7
+class HorizontalRuleEmbed implements NotusEmbed {
8
+  const HorizontalRuleEmbed();
9
+
10
+  @override
11
+  NotusAttribute<Map<String, dynamic>> get attribute =>
12
+      NotusAttribute.embed.horizontalRule;
13
+}
14
+
15
+class ImageEmbed implements NotusEmbed {
16
+  const ImageEmbed(this.source) : assert(source != null);
17
+  final String source;
18
+
19
+  @override
20
+  NotusAttribute get attribute => NotusAttribute.embed.image(source);
21
+}

+ 111
- 0
packages/notus/lib/src/heuristics.dart Wyświetl plik

@@ -0,0 +1,111 @@
1
+// Copyright (c) 2018, the Zefyr project authors.  Please see the AUTHORS file
2
+// for details. All rights reserved. Use of this source code is governed by a
3
+// BSD-style license that can be found in the LICENSE file.
4
+
5
+import 'package:notus/notus.dart';
6
+import 'package:quill_delta/quill_delta.dart';
7
+
8
+import 'heuristics/delete_rules.dart';
9
+import 'heuristics/embed_rules.dart';
10
+import 'heuristics/format_rules.dart';
11
+import 'heuristics/insert_rules.dart';
12
+
13
+/// Registry for insert, format and delete heuristic rules used by
14
+/// [NotusDocument] documents.
15
+class NotusHeuristics {
16
+  /// Default set of heuristic rules.
17
+  static const NotusHeuristics fallback = NotusHeuristics(
18
+    formatRules: [
19
+      FormatLinkAtCaretPositionRule(),
20
+      ResolveLineFormatRule(),
21
+      ResolveInlineFormatRule(),
22
+      // No need in catch-all rule here since the above rules cover all
23
+      // attributes.
24
+    ],
25
+    insertRules: [
26
+      ForceNewlineForInsertsAroundEmbedRule(),
27
+      PreserveLineStyleOnSplitRule(),
28
+      AutoExitBlockRule(),
29
+      ResetLineFormatOnNewLineRule(),
30
+      AutoFormatLinksRule(),
31
+      PreserveInlineStylesRule(),
32
+      CatchAllInsertRule(),
33
+    ],
34
+    deleteRules: [
35
+      PreserveLineStyleOnMergeRule(),
36
+      CatchAllDeleteRule(),
37
+    ],
38
+    embedRules: [
39
+      FormatEmbedsRule(),
40
+    ],
41
+  );
42
+
43
+  const NotusHeuristics({
44
+    this.formatRules,
45
+    this.insertRules,
46
+    this.deleteRules,
47
+    this.embedRules,
48
+  });
49
+
50
+  /// List of format rules in this registry.
51
+  final List<FormatRule> formatRules;
52
+
53
+  /// List of insert rules in this registry.
54
+  final List<InsertRule> insertRules;
55
+
56
+  /// List of delete rules in this registry.
57
+  final List<DeleteRule> deleteRules;
58
+
59
+  /// List of embed rules in this registry.
60
+  final List<EmbedRule> embedRules;
61
+
62
+  /// Applies heuristic rules to specified insert operation based on current
63
+  /// state of Notus [document].
64
+  Delta applyInsertRules(NotusDocument document, int index, String insert) {
65
+    final delta = document.toDelta();
66
+    for (var rule in insertRules) {
67
+      final result = rule.apply(delta, index, insert);
68
+      if (result != null) return result..trim();
69
+    }
70
+    throw new StateError(
71
+        'Failed to apply insert heuristic rules: none applied.');
72
+  }
73
+
74
+  /// Applies heuristic rules to specified format operation based on current
75
+  /// state of Notus [document].
76
+  Delta applyFormatRules(
77
+      NotusDocument document, int index, int length, NotusAttribute value) {
78
+    final delta = document.toDelta();
79
+    for (var rule in formatRules) {
80
+      final result = rule.apply(delta, index, length, value);
81
+      if (result != null) return result..trim();
82
+    }
83
+    throw new StateError(
84
+        'Failed to apply format heuristic rules: none applied.');
85
+  }
86
+
87
+  /// Applies heuristic rules to specified delete operation based on current
88
+  /// state of Notus [document].
89
+  Delta applyDeleteRules(NotusDocument document, int index, int length) {
90
+    final delta = document.toDelta();
91
+    for (var rule in deleteRules) {
92
+      final result = rule.apply(delta, index, length);
93
+      if (result != null) return result..trim();
94
+    }
95
+    throw new StateError(
96
+        'Failed to apply delete heuristic rules: none applied.');
97
+  }
98
+
99
+  /// Applies heuristic rules to specified embed operation based on current
100
+  /// state of [document].
101
+  Delta applyEmbedRules(
102
+      NotusDocument document, int index, EmbedAttribute embed) {
103
+    final delta = document.toDelta();
104
+    for (var rule in embedRules) {
105
+      final result = rule.apply(delta, index, embed);
106
+      if (result != null) return result..trim();
107
+    }
108
+    throw new StateError(
109
+        'Failed to apply embed heuristic rules: none applied.');
110
+  }
111
+}

+ 73
- 0
packages/notus/lib/src/heuristics/delete_rules.dart Wyświetl plik

@@ -0,0 +1,73 @@
1
+// Copyright (c) 2018, the Zefyr project authors.  Please see the AUTHORS file
2
+// for details. All rights reserved. Use of this source code is governed by a
3
+// BSD-style license that can be found in the LICENSE file.
4
+
5
+import 'package:quill_delta/quill_delta.dart';
6
+
7
+/// A heuristic rule for delete operations.
8
+abstract class DeleteRule {
9
+  /// Constant constructor allows subclasses to declare constant constructors.
10
+  const DeleteRule();
11
+
12
+  /// Applies heuristic rule to a delete operation on a [document] and returns
13
+  /// resulting [Delta].
14
+  Delta apply(Delta document, int index, int length);
15
+}
16
+
17
+/// Fallback rule for delete operations which simply deletes specified text
18
+/// range without any special handling.
19
+class CatchAllDeleteRule extends DeleteRule {
20
+  const CatchAllDeleteRule();
21
+
22
+  @override
23
+  Delta apply(Delta document, int index, int length) {
24
+    return new Delta()
25
+      ..retain(index)
26
+      ..delete(length);
27
+  }
28
+}
29
+
30
+/// Preserves line format when user deletes the line's line-break character
31
+/// effectively merging it with the next line.
32
+///
33
+/// This rule makes sure to apply all style attributes of deleted line-break
34
+/// to the next available line-break, which may reset any style attributes
35
+/// already present there.
36
+class PreserveLineStyleOnMergeRule extends DeleteRule {
37
+  const PreserveLineStyleOnMergeRule();
38
+
39
+  @override
40
+  Delta apply(Delta document, int index, int length) {
41
+    DeltaIterator iter = new DeltaIterator(document);
42
+    iter.skip(index);
43
+    final target = iter.next(1);
44
+    if (target.data != '\n') return null;
45
+    iter.skip(length - 1);
46
+    final Delta result = new Delta()
47
+      ..retain(index)
48
+      ..delete(length);
49
+
50
+    // Look for next line-break to apply the attributes
51
+    while (iter.hasNext) {
52
+      final op = iter.next();
53
+      int lf = op.data.indexOf('\n');
54
+      if (lf == -1) {
55
+        result..retain(op.length);
56
+        continue;
57
+      }
58
+      var attributes = _unsetAttributes(op.attributes);
59
+      if (target.isNotPlain) {
60
+        attributes ??= <String, dynamic>{};
61
+        attributes.addAll(target.attributes);
62
+      }
63
+      result..retain(lf)..retain(1, attributes);
64
+      break;
65
+    }
66
+    return result;
67
+  }
68
+
69
+  Map<String, dynamic> _unsetAttributes(Map<String, dynamic> attributes) {
70
+    if (attributes == null) return null;
71
+    return attributes.map((key, value) => new MapEntry(key, null));
72
+  }
73
+}

+ 86
- 0
packages/notus/lib/src/heuristics/embed_rules.dart Wyświetl plik

@@ -0,0 +1,86 @@
1
+// Copyright (c) 2018, the Zefyr project authors.  Please see the AUTHORS file
2
+// for details. All rights reserved. Use of this source code is governed by a
3
+// BSD-style license that can be found in the LICENSE file.
4
+
5
+import 'package:quill_delta/quill_delta.dart';
6
+import 'package:notus/notus.dart';
7
+
8
+/// A heuristic rule for embed operations.
9
+abstract class EmbedRule {
10
+  /// Constant constructor allows subclasses to declare constant constructors.
11
+  const EmbedRule();
12
+
13
+  /// Applies heuristic rule to an embed operation on a [document] and returns
14
+  /// resulting [Delta].
15
+  Delta apply(Delta document, int index, EmbedAttribute embed);
16
+}
17
+
18
+/// Handles all operations which manipulate embeds.
19
+class FormatEmbedsRule extends EmbedRule {
20
+  const FormatEmbedsRule();
21
+
22
+  @override
23
+  Delta apply(Delta document, int index, EmbedAttribute embed) {
24
+    final iter = new DeltaIterator(document);
25
+    final previous = iter.skip(index);
26
+
27
+    final target = iter.next();
28
+    Delta result = new Delta()..retain(index);
29
+    if (target.length == 1 && target.data == EmbedNode.kPlainTextPlaceholder) {
30
+      assert(() {
31
+        final style = NotusStyle.fromJson(target.attributes);
32
+        return style.single.key == NotusAttribute.embed.key;
33
+      }());
34
+
35
+      // There is already embed here, simply update its style.
36
+      return result..retain(1, embed.toJson());
37
+    } else {
38
+      // Target is not an embed, need to insert.
39
+      // Embeds can be inserted only into an empty line.
40
+
41
+      // Check if [index] is on an empty line already.
42
+      final isNewlineBefore = previous == null || previous.data.endsWith('\n');
43
+      final isNewlineAfter = target.data.startsWith('\n');
44
+      final isOnEmptyLine = isNewlineBefore && isNewlineAfter;
45
+      if (isOnEmptyLine) {
46
+        return result..insert(EmbedNode.kPlainTextPlaceholder, embed.toJson());
47
+      }
48
+      // We are on a non-empty line, split it (preserving style if needed)
49
+      // and insert our embed.
50
+      final lineStyle = _getLineStyle(iter, target);
51
+      if (!isNewlineBefore) {
52
+        result..insert('\n', lineStyle);
53
+      }
54
+      result..insert(EmbedNode.kPlainTextPlaceholder, embed.toJson());
55
+      if (!isNewlineAfter) {
56
+        result..insert('\n');
57
+      }
58
+      return result;
59
+    }
60
+//
61
+//    if (embed == NotusAttribute.embed.unset) {
62
+//      // Convert into a delete operation.
63
+//      return result..delete(1);
64
+//    } else {
65
+//      return result..retain(1, embed.toJson());
66
+//    }
67
+  }
68
+
69
+  Map<String, dynamic> _getLineStyle(
70
+      DeltaIterator iterator, Operation current) {
71
+    if (current.data.indexOf('\n') >= 0) {
72
+      return current.attributes;
73
+    }
74
+    // Continue looking for line-break.
75
+    Map<String, dynamic> attributes;
76
+    while (iterator.hasNext) {
77
+      final op = iterator.next();
78
+      int lf = op.data.indexOf('\n');
79
+      if (lf >= 0) {
80
+        attributes = op.attributes;
81
+        break;
82
+      }
83
+    }
84
+    return attributes;
85
+  }
86
+}

+ 147
- 0
packages/notus/lib/src/heuristics/format_rules.dart Wyświetl plik

@@ -0,0 +1,147 @@
1
+// Copyright (c) 2018, the Zefyr project authors.  Please see the AUTHORS file
2
+// for details. All rights reserved. Use of this source code is governed by a
3
+// BSD-style license that can be found in the LICENSE file.
4
+
5
+import 'package:quill_delta/quill_delta.dart';
6
+import 'package:notus/notus.dart';
7
+
8
+/// A heuristic rule for format (retain) operations.
9
+abstract class FormatRule {
10
+  /// Constant constructor allows subclasses to declare constant constructors.
11
+  const FormatRule();
12
+
13
+  /// Applies heuristic rule to a retain (format) operation on a [document] and
14
+  /// returns resulting [Delta].
15
+  Delta apply(Delta document, int index, int length, NotusAttribute attribute);
16
+}
17
+
18
+/// Produces Delta with line-level attributes applied strictly to
19
+/// line-break characters.
20
+class ResolveLineFormatRule extends FormatRule {
21
+  const ResolveLineFormatRule() : super();
22
+
23
+  @override
24
+  Delta apply(Delta document, int index, int length, NotusAttribute attribute) {
25
+    if (attribute.scope != NotusAttributeScope.line) return null;
26
+
27
+    Delta result = new Delta()..retain(index);
28
+    final iter = new DeltaIterator(document);
29
+    iter.skip(index);
30
+
31
+    // Apply line styles to all line-break characters within range of this
32
+    // retain operation.
33
+    int current = 0;
34
+    while (current < length && iter.hasNext) {
35
+      final op = iter.next(length - current);
36
+      if (op.data.contains('\n')) {
37
+        final delta = _applyAttribute(op.data, attribute);
38
+        result = result.concat(delta);
39
+      } else {
40
+        result.retain(op.length);
41
+      }
42
+      current += op.length;
43
+    }
44
+    // And include extra line-break after retain
45
+    while (iter.hasNext) {
46
+      final op = iter.next();
47
+      int lf = op.data.indexOf('\n');
48
+      if (lf == -1) {
49
+        result..retain(op.length);
50
+        continue;
51
+      }
52
+      result..retain(lf)..retain(1, attribute.toJson());
53
+      break;
54
+    }
55
+    return result;
56
+  }
57
+
58
+  Delta _applyAttribute(String text, NotusAttribute attribute) {
59
+    final result = new Delta();
60
+    int offset = 0;
61
+    int lf = text.indexOf('\n');
62
+    while (lf >= 0) {
63
+      result..retain(lf - offset)..retain(1, attribute.toJson());
64
+      offset = lf + 1;
65
+      lf = text.indexOf('\n', offset);
66
+    }
67
+    // Retain any remaining characters in text
68
+    result.retain(text.length - offset);
69
+    return result;
70
+  }
71
+}
72
+
73
+/// Produces Delta with inline-level attributes applied too all characters
74
+/// except line-breaks.
75
+class ResolveInlineFormatRule extends FormatRule {
76
+  const ResolveInlineFormatRule();
77
+
78
+  @override
79
+  Delta apply(Delta document, int index, int length, NotusAttribute attribute) {
80
+    if (attribute.scope != NotusAttributeScope.inline) return null;
81
+
82
+    Delta result = new Delta()..retain(index);
83
+    final iter = new DeltaIterator(document);
84
+    iter.skip(index);
85
+
86
+    // Apply inline styles to all non-line-break characters within range of this
87
+    // retain operation.
88
+    int current = 0;
89
+    while (current < length && iter.hasNext) {
90
+      final op = iter.next(length - current);
91
+      int lf = op.data.indexOf('\n');
92
+      if (lf != -1) {
93
+        int pos = 0;
94
+        while (lf != -1) {
95
+          result..retain(lf - pos, attribute.toJson())..retain(1);
96
+          pos = lf + 1;
97
+          lf = op.data.indexOf('\n', pos);
98
+        }
99
+        if (pos < op.length) result.retain(op.length - pos, attribute.toJson());
100
+      } else {
101
+        result.retain(op.length, attribute.toJson());
102
+      }
103
+      current += op.length;
104
+    }
105
+
106
+    return result;
107
+  }
108
+}
109
+
110
+/// Allows updating link format with collapsed selection.
111
+class FormatLinkAtCaretPositionRule extends FormatRule {
112
+  const FormatLinkAtCaretPositionRule();
113
+
114
+  @override
115
+  Delta apply(Delta document, int index, int length, NotusAttribute attribute) {
116
+    if (attribute.key != NotusAttribute.link.key) return null;
117
+    // If user selection is not collapsed we let it fallback to default rule
118
+    // which simply applies the attribute to selected range.
119
+    // This may still not be a bulletproof approach as selection can span
120
+    // multiple lines or be a subset of existing link-formatted text.
121
+    // So certain improvements can be made in the future to account for such
122
+    // edge cases.
123
+    if (length != 0) return null;
124
+
125
+    Delta result = new Delta();
126
+    final iter = new DeltaIterator(document);
127
+    final before = iter.skip(index);
128
+    final after = iter.next();
129
+    int startIndex = index;
130
+    int retain = 0;
131
+    if (before != null && before.hasAttribute(attribute.key)) {
132
+      startIndex -= before.length;
133
+      retain = before.length;
134
+    }
135
+    if (after != null && after.hasAttribute(attribute.key)) {
136
+      retain += after.length;
137
+    }
138
+    // There is no link-styled text around `index` position so it becomes a
139
+    // no-op action.
140
+    if (retain == 0) return null;
141
+
142
+    result..retain(startIndex)..retain(retain, attribute.toJson());
143
+
144
+    return result;
145
+  }
146
+}
147
+

+ 258
- 0
packages/notus/lib/src/heuristics/insert_rules.dart Wyświetl plik

@@ -0,0 +1,258 @@
1
+// Copyright (c) 2018, the Zefyr project authors.  Please see the AUTHORS file
2
+// for details. All rights reserved. Use of this source code is governed by a
3
+// BSD-style license that can be found in the LICENSE file.
4
+
5
+import 'package:quill_delta/quill_delta.dart';
6
+import 'package:notus/notus.dart';
7
+
8
+/// A heuristic rule for insert operations.
9
+abstract class InsertRule {
10
+  /// Constant constructor allows subclasses to declare constant constructors.
11
+  const InsertRule();
12
+
13
+  /// Applies heuristic rule to an insert operation on a [document] and returns
14
+  /// resulting [Delta].
15
+  Delta apply(Delta document, int index, String text);
16
+}
17
+
18
+/// Fallback rule which simply inserts text as-is without any special handling.
19
+class CatchAllInsertRule extends InsertRule {
20
+  const CatchAllInsertRule();
21
+
22
+  @override
23
+  Delta apply(Delta document, int index, String text) {
24
+    return new Delta()
25
+      ..retain(index)
26
+      ..insert(text);
27
+  }
28
+}
29
+
30
+/// Preserves line format when user splits the line into two.
31
+///
32
+/// This rule ignores scenarios when the line is split on its edge, meaning
33
+/// a line-break is inserted at the beginning or the end of the line.
34
+class PreserveLineStyleOnSplitRule extends InsertRule {
35
+  const PreserveLineStyleOnSplitRule();
36
+
37
+  bool isEdgeLineSplit(Operation before, Operation after) {
38
+    if (before == null) return true; // split at the beginning of a doc
39
+    return before.data.endsWith('\n') || after.data.startsWith('\n');
40
+  }
41
+
42
+  @override
43
+  Delta apply(Delta document, int index, String text) {
44
+    if (text != '\n') return null;
45
+
46
+    DeltaIterator iter = new DeltaIterator(document);
47
+    final before = iter.skip(index);
48
+    final after = iter.next();
49
+    if (isEdgeLineSplit(before, after)) return null;
50
+    Delta result = new Delta()..retain(index);
51
+    if (after.data.indexOf('\n') >= 0) {
52
+      // It is not allowed to combine line and inline styles in insert
53
+      // operation containing line-break together with other characters.
54
+      // The only scenario we get such operation is when the text is plain.
55
+      assert(after.isPlain);
56
+      // No attributes to apply so we simply create a new line.
57
+      result.insert('\n');
58
+      return result;
59
+    }
60
+    // Continue looking for line-break.
61
+    Map<String, dynamic> attributes;
62
+    while (iter.hasNext) {
63
+      final op = iter.next();
64
+      int lf = op.data.indexOf('\n');
65
+      if (lf >= 0) {
66
+        attributes = op.attributes;
67
+        break;
68
+      }
69
+    }
70
+    result.insert('\n', attributes);
71
+    return result;
72
+  }
73
+}
74
+
75
+/// Resets format for a newly inserted line when insert occurred at the end
76
+/// of a line (right before a line-break).
77
+class ResetLineFormatOnNewLineRule extends InsertRule {
78
+  const ResetLineFormatOnNewLineRule();
79
+
80
+  @override
81
+  Delta apply(Delta document, int index, String text) {
82
+    if (text != '\n') return null;
83
+
84
+    DeltaIterator iter = new DeltaIterator(document);
85
+    iter.skip(index);
86
+    final target = iter.next();
87
+
88
+    if (target.data.startsWith('\n')) {
89
+      Map<String, dynamic> resetStyle = null;
90
+      if (target.attributes != null &&
91
+          target.attributes.containsKey(NotusAttribute.heading.key)) {
92
+        resetStyle = NotusAttribute.heading.unset.toJson();
93
+      }
94
+      return new Delta()
95
+        ..retain(index)
96
+        ..insert('\n', target.attributes)
97
+        ..retain(1, resetStyle)
98
+        ..trim();
99
+    }
100
+    return null;
101
+  }
102
+}
103
+
104
+/// Heuristic rule to exit current block when user inserts two consecutive
105
+/// line-breaks.
106
+// TODO: update this rule to handle code blocks differently, at least allow 3 consecutive line-breaks before exiting.
107
+class AutoExitBlockRule extends InsertRule {
108
+  const AutoExitBlockRule();
109
+
110
+  bool isEmptyLine(Operation previous, Operation target) {
111
+    return (previous == null || previous.data.endsWith('\n')) &&
112
+        target.data.startsWith('\n');
113
+  }
114
+
115
+  @override
116
+  Delta apply(Delta document, int index, String text) {
117
+    if (text != '\n') return null;
118
+
119
+    DeltaIterator iter = new DeltaIterator(document);
120
+    final previous = iter.skip(index);
121
+    final target = iter.next();
122
+    final isInBlock = target.isNotPlain &&
123
+        target.attributes.containsKey(NotusAttribute.block.key);
124
+    if (isEmptyLine(previous, target) && isInBlock) {
125
+      // We reset block style even if this line is not the last one in it's
126
+      // block which effectively splits the block into two.
127
+      // TODO: For code blocks this should not split the block but allow inserting as many lines as needed.
128
+      var attributes =
129
+          target.attributes != null ? target.attributes : <String, dynamic>{};
130
+      attributes.addAll(NotusAttribute.block.unset.toJson());
131
+      return new Delta()..retain(index)..retain(1, attributes);
132
+    }
133
+    return null;
134
+  }
135
+}
136
+
137
+/// Preserves inline styles when user inserts text inside formatted segment.
138
+class PreserveInlineStylesRule extends InsertRule {
139
+  const PreserveInlineStylesRule();
140
+
141
+  @override
142
+  Delta apply(Delta document, int index, String text) {
143
+    // This rule is only applicable to characters other than line-break.
144
+    if (text.contains('\n')) return null;
145
+
146
+    DeltaIterator iter = new DeltaIterator(document);
147
+    final previous = iter.skip(index);
148
+    // If there is a line-break in previous chunk, there should be no inline
149
+    // styles. Also if there is no previous operation we are at the beginning
150
+    // of the document so no styles to inherit from.
151
+    if (previous == null || previous.data.contains('\n')) return null;
152
+
153
+    final attributes = previous.attributes;
154
+    final hasLink =
155
+        (attributes != null && attributes.containsKey(NotusAttribute.link.key));
156
+    if (!hasLink) {
157
+      return new Delta()
158
+        ..retain(index)
159
+        ..insert(text, attributes);
160
+    }
161
+    // Special handling needed for inserts inside fragments with link attribute.
162
+    // Link style should only be preserved if insert occurs inside the fragment.
163
+    // Link style should NOT be preserved on the boundaries.
164
+    var noLinkAttributes = previous.attributes;
165
+    noLinkAttributes.remove(NotusAttribute.link.key);
166
+    final noLinkResult = new Delta()
167
+      ..retain(index)
168
+      ..insert(text, noLinkAttributes.isEmpty ? null : noLinkAttributes);
169
+    final next = iter.next();
170
+    if (next == null) {
171
+      // Nothing after us, we are not inside link-styled fragment.
172
+      return noLinkResult;
173
+    }
174
+    final nextAttributes = next.attributes ?? <String, dynamic>{};
175
+    if (!nextAttributes.containsKey(NotusAttribute.link.key)) {
176
+      // Next fragment is not styled as link.
177
+      return noLinkResult;
178
+    }
179
+    // We must make sure links are identical in previous and next operations.
180
+    if (attributes[NotusAttribute.link.key] ==
181
+        nextAttributes[NotusAttribute.link.key]) {
182
+      return new Delta()
183
+        ..retain(index)
184
+        ..insert(text, attributes);
185
+    } else {
186
+      return noLinkResult;
187
+    }
188
+  }
189
+}
190
+
191
+/// Applies link format to text segment (which looks like a link) when user
192
+/// inserts space character after it.
193
+class AutoFormatLinksRule extends InsertRule {
194
+  const AutoFormatLinksRule();
195
+
196
+  @override
197
+  Delta apply(Delta document, int index, String text) {
198
+    // This rule applies to a space inserted after a link, so we can ignore
199
+    // everything else.
200
+    if (text != ' ') return null;
201
+
202
+    DeltaIterator iter = new DeltaIterator(document);
203
+    final previous = iter.skip(index);
204
+    // No previous operation means no link.
205
+    if (previous == null) return null;
206
+
207
+    // Split text of previous operation in lines and words and take last word to test.
208
+    final candidate = previous.data.split('\n').last.split(' ').last;
209
+    try {
210
+      final link = Uri.parse(candidate);
211
+      if (!['https', 'http'].contains(link.scheme)) {
212
+        // TODO: might need a more robust way of validating links here.
213
+        return null;
214
+      }
215
+      final attributes = previous.attributes ?? <String, dynamic>{};
216
+
217
+      // Do nothing if already formatted as link.
218
+      if (attributes.containsKey(NotusAttribute.link.key)) return null;
219
+
220
+      attributes
221
+          .addAll(NotusAttribute.link.fromString(link.toString()).toJson());
222
+      return new Delta()
223
+        ..retain(index - candidate.length)
224
+        ..retain(candidate.length, attributes)
225
+        ..insert(text, previous.attributes);
226
+    } on FormatException {
227
+      return null; // Our candidate is not a link.
228
+    }
229
+  }
230
+}
231
+
232
+/// Forces text inserted on the same line with an embed (before or after it)
233
+/// to be moved to a new line adjacent to the original line.
234
+///
235
+/// This rule assumes that a line is only allowed to have single embed child.
236
+class ForceNewlineForInsertsAroundEmbedRule extends InsertRule {
237
+  const ForceNewlineForInsertsAroundEmbedRule();
238
+
239
+  @override
240
+  Delta apply(Delta document, int index, String text) {
241
+    DeltaIterator iter = new DeltaIterator(document);
242
+    final previous = iter.skip(index);
243
+    final target = iter.next();
244
+    final beforeEmbed = target.data == EmbedNode.kPlainTextPlaceholder;
245
+    final afterEmbed = previous?.data == EmbedNode.kPlainTextPlaceholder;
246
+    if (beforeEmbed || afterEmbed) {
247
+      final delta = new Delta()..retain(index);
248
+      if (beforeEmbed && !text.endsWith('\n')) {
249
+        return delta..insert(text)..insert('\n');
250
+      }
251
+      if (afterEmbed && !text.startsWith('\n')) {
252
+        return delta..insert('\n')..insert(text);
253
+      }
254
+      return delta..insert(text);
255
+    }
256
+    return null;
257
+  }
258
+}

+ 17
- 0
packages/notus/pubspec.yaml Wyświetl plik

@@ -0,0 +1,17 @@
1
+name: notus
2
+description: Rich text document model for Zefyr editor.
3
+version: 0.1.0
4
+author: Memspace <hello@memspace.app>
5
+homepage: https://github.com/memspace/notus
6
+
7
+environment:
8
+  sdk: '>=2.0.0-dev.64.0 <2.0.0'
9
+
10
+dependencies:
11
+  collection: ^1.14.6
12
+  quill_delta: ^1.0.0-dev
13
+  quiver_hashcode: ^1.0.0
14
+
15
+dev_dependencies:
16
+  test: ^1.0.0
17
+  test_coverage: ^0.2.0

+ 173
- 0
packages/notus/test/convert/markdown_test.dart Wyświetl plik

@@ -0,0 +1,173 @@
1
+// Copyright (c) 2018, the Zefyr project authors.  Please see the AUTHORS file
2
+// for details. All rights reserved. Use of this source code is governed by a
3
+// BSD-style license that can be found in the LICENSE file.
4
+import 'dart:convert';
5
+
6
+import 'package:test/test.dart';
7
+import 'package:quill_delta/quill_delta.dart';
8
+import 'package:notus/notus.dart';
9
+import 'package:notus/convert.dart';
10
+
11
+void main() {
12
+  group('$NotusMarkdownCodec.encode', () {
13
+    test('unimplemented', () {
14
+      expect(() {
15
+        notusMarkdown.decode('test');
16
+      }, throwsUnimplementedError);
17
+    });
18
+  });
19
+
20
+  group('$NotusMarkdownCodec.encode', () {
21
+    test('split adjacent paragraphs', () {
22
+      final delta = new Delta()..insert('First line\nSecond line\n');
23
+      final result = notusMarkdown.encode(delta);
24
+      expect(result, 'First line\n\nSecond line\n\n');
25
+    });
26
+
27
+    test('bold italic', () {
28
+      runFor(NotusAttribute<bool> attribute, String expected) {
29
+        final delta = new Delta()
30
+          ..insert('This ')
31
+          ..insert('house', attribute.toJson())
32
+          ..insert(' is a ')
33
+          ..insert('circus', attribute.toJson())
34
+          ..insert('\n');
35
+
36
+        final result = notusMarkdown.encode(delta);
37
+        expect(result, expected);
38
+      }
39
+
40
+      runFor(NotusAttribute.bold, 'This **house** is a **circus**\n\n');
41
+      runFor(NotusAttribute.italic, 'This _house_ is a _circus_\n\n');
42
+    });
43
+
44
+    test('intersecting inline styles', () {
45
+      final b = NotusAttribute.bold.toJson();
46
+      final i = NotusAttribute.italic.toJson();
47
+      final bi = new Map<String, dynamic>.from(b);
48
+      bi.addAll(i);
49
+
50
+      final delta = new Delta()
51
+        ..insert('This ')
52
+        ..insert('house', b)
53
+        ..insert(' is a ', bi)
54
+        ..insert('circus', b)
55
+        ..insert('\n');
56
+
57
+      final result = notusMarkdown.encode(delta);
58
+      expect(result, 'This **house _is a_ circus**\n\n');
59
+    });
60
+
61
+    test('normalize inline styles', () {
62
+      final b = NotusAttribute.bold.toJson();
63
+      final i = NotusAttribute.italic.toJson();
64
+      final delta = new Delta()
65
+        ..insert('This')
66
+        ..insert(' house ', b)
67
+        ..insert('is a')
68
+        ..insert(' circus ', i)
69
+        ..insert('\n');
70
+
71
+      final result = notusMarkdown.encode(delta);
72
+      expect(result, 'This **house** is a _circus_ \n\n');
73
+    });
74
+
75
+    test('links', () {
76
+      final b = NotusAttribute.bold.toJson();
77
+      final link = NotusAttribute.link.fromString('https://github.com');
78
+      final delta = new Delta()
79
+        ..insert('This')
80
+        ..insert(' house ', b)
81
+        ..insert('is a')
82
+        ..insert(' circus ', link.toJson())
83
+        ..insert('\n');
84
+
85
+      final result = notusMarkdown.encode(delta);
86
+      expect(result, 'This **house** is a [circus](https://github.com) \n\n');
87
+    });
88
+
89
+    test('heading styles', () {
90
+      runFor(NotusAttribute<int> attribute, String source, String expected) {
91
+        final delta = new Delta()
92
+          ..insert(source)
93
+          ..insert('\n', attribute.toJson());
94
+        final result = notusMarkdown.encode(delta);
95
+        expect(result, expected);
96
+      }
97
+
98
+      runFor(NotusAttribute.h1, 'Title', '# Title\n\n');
99
+      runFor(NotusAttribute.h2, 'Title', '## Title\n\n');
100
+      runFor(NotusAttribute.h3, 'Title', '### Title\n\n');
101
+    });
102
+
103
+    test('block styles', () {
104
+      runFor(NotusAttribute<String> attribute, String source, String expected) {
105
+        final delta = new Delta()
106
+          ..insert(source)
107
+          ..insert('\n', attribute.toJson());
108
+        final result = notusMarkdown.encode(delta);
109
+        expect(result, expected);
110
+      }
111
+
112
+      runFor(NotusAttribute.ul, 'List item', '* List item\n\n');
113
+      runFor(NotusAttribute.ol, 'List item', '1. List item\n\n');
114
+      runFor(NotusAttribute.bq, 'List item', '> List item\n\n');
115
+      runFor(NotusAttribute.code, 'List item', '```\nList item\n```\n\n');
116
+    });
117
+
118
+    test('multiline blocks', () {
119
+      runFor(NotusAttribute<String> attribute, String source, String expected) {
120
+        final delta = new Delta()
121
+          ..insert(source)
122
+          ..insert('\n', attribute.toJson())
123
+          ..insert(source)
124
+          ..insert('\n', attribute.toJson());
125
+        final result = notusMarkdown.encode(delta);
126
+        expect(result, expected);
127
+      }
128
+
129
+      runFor(NotusAttribute.ul, 'text', '* text\n* text\n\n');
130
+      runFor(NotusAttribute.ol, 'text', '1. text\n1. text\n\n');
131
+      runFor(NotusAttribute.bq, 'text', '> text\n> text\n\n');
132
+      runFor(NotusAttribute.code, 'text', '```\ntext\ntext\n```\n\n');
133
+    });
134
+
135
+    test('multiple styles', () {
136
+      final result = notusMarkdown.encode(delta);
137
+      expect(result, expectedMarkdown);
138
+    });
139
+  });
140
+}
141
+
142
+final doc =
143
+    r'[{"insert":"Zefyr"},{"insert":"\n","attributes":{"heading":1}},{"insert":"Soft and gentle rich text editing for Flutter applications.","attributes":{"i":true}},{"insert":"\nZefyr is an "},{"insert":"early preview","attributes":{"b":true}},{"insert":" open source library.\nDocumentation"},{"insert":"\n","attributes":{"heading":3}},{"insert":"Quick Start"},{"insert":"\n","attributes":{"block":"ul"}},{"insert":"Data format and Document Model"},{"insert":"\n","attributes":{"block":"ul"}},{"insert":"Style attributes"},{"insert":"\n","attributes":{"block":"ul"}},{"insert":"Heuristic rules"},{"insert":"\n","attributes":{"block":"ul"}},{"insert":"Clean and modern look"},{"insert":"\n","attributes":{"heading":2}},{"insert":"Zefyr’s rich text editor is built with simplicity and flexibility in mind. It provides clean interface for distraction-free editing. Think Medium.com-like experience.\nimport ‘package:flutter/material.dart’;"},{"insert":"\n","attributes":{"block":"code"}},{"insert":"import ‘package:notus/notus.dart’;"},{"insert":"\n\n","attributes":{"block":"code"}},{"insert":"void main() {"},{"insert":"\n","attributes":{"block":"code"}},{"insert":" print(“Hello world!”);"},{"insert":"\n","attributes":{"block":"code"}},{"insert":"}"},{"insert":"\n","attributes":{"block":"code"}}]';
144
+final delta = Delta.fromJson(json.decode(doc));
145
+
146
+final expectedMarkdown = '''
147
+# Zefyr
148
+
149
+_Soft and gentle rich text editing for Flutter applications._
150
+
151
+Zefyr is an **early preview** open source library.
152
+
153
+### Documentation
154
+
155
+* Quick Start
156
+* Data format and Document Model
157
+* Style attributes
158
+* Heuristic rules
159
+
160
+## Clean and modern look
161
+
162
+Zefyr’s rich text editor is built with simplicity and flexibility in mind. It provides clean interface for distraction-free editing. Think Medium.com-like experience.
163
+
164
+```
165
+import ‘package:flutter/material.dart’;
166
+import ‘package:notus/notus.dart’;
167
+
168
+void main() {
169
+ print(“Hello world!”);
170
+}
171
+```
172
+
173
+''';

+ 15
- 0
packages/notus/test/document/attributes_test.dart Wyświetl plik

@@ -0,0 +1,15 @@
1
+// Copyright (c) 2018, the Zefyr project authors.  Please see the AUTHORS file
2
+// for details. All rights reserved. Use of this source code is governed by a
3
+// BSD-style license that can be found in the LICENSE file.
4
+import 'package:test/test.dart';
5
+import 'package:notus/notus.dart';
6
+
7
+void main() {
8
+  group('$NotusStyle', () {
9
+    test('get', () {
10
+      var attrs = NotusStyle.fromJson({'block': 'ul'});
11
+      var attr = attrs.get(NotusAttribute.block);
12
+      expect(attr, NotusAttribute.ul);
13
+    });
14
+  });
15
+}

+ 173
- 0
packages/notus/test/document/block_test.dart Wyświetl plik

@@ -0,0 +1,173 @@
1
+// Copyright (c) 2018, the Zefyr project authors.  Please see the AUTHORS file
2
+// for details. All rights reserved. Use of this source code is governed by a
3
+// BSD-style license that can be found in the LICENSE file.
4
+import 'package:notus/notus.dart';
5
+import 'package:test/test.dart';
6
+import 'package:quill_delta/quill_delta.dart';
7
+
8
+final ulAttrs = new NotusStyle().merge(NotusAttribute.ul);
9
+final olAttrs = new NotusStyle().merge(NotusAttribute.ol);
10
+final h1Attrs = new NotusStyle().merge(NotusAttribute.h1);
11
+
12
+void main() {
13
+  group('$BlockNode', () {
14
+    ContainerNode root;
15
+    setUp(() {
16
+      root = new RootNode();
17
+    });
18
+
19
+    test('empty', () {
20
+      BlockNode node = new BlockNode();
21
+      expect(node, isEmpty);
22
+      expect(node.length, 0);
23
+      expect(node.style, new NotusStyle());
24
+    });
25
+
26
+    test('toString', () {
27
+      LineNode line = new LineNode();
28
+      line.add(new TextNode('London "Grammar"'));
29
+      BlockNode block = new BlockNode();
30
+      block.applyAttribute(NotusAttribute.ul);
31
+      block.add(line);
32
+      final expected = '§ {ul}\n  └ ¶ ⟨London "Grammar"⟩ ⏎';
33
+      expect('$block', expected);
34
+    });
35
+
36
+    test('unwrapLine from first block', () {
37
+      root.insert(0, 'One\nTwo\nThree', null);
38
+      root.retain(3, 1, ulAttrs);
39
+      root.retain(7, 1, ulAttrs);
40
+      root.retain(13, 1, ulAttrs);
41
+      expect(root.childCount, 1);
42
+      BlockNode block = root.first;
43
+      LineNode line = block.children.elementAt(1);
44
+      block.unwrapLine(line);
45
+      expect(root.children, hasLength(3));
46
+      expect(root.children.elementAt(0), const TypeMatcher<BlockNode>());
47
+      expect(root.children.elementAt(1), line);
48
+      expect(root.children.elementAt(2), block);
49
+    });
50
+
51
+    test('format first line as list', () {
52
+      root.insert(0, 'Hello world', null);
53
+      root.retain(11, 1, ulAttrs);
54
+
55
+      expect(root.childCount, 1);
56
+      BlockNode block = root.first;
57
+      expect(block.style.get(NotusAttribute.block),
58
+          NotusAttribute.ul);
59
+      expect(block.childCount, 1);
60
+      expect(block.first, const TypeMatcher<LineNode>());
61
+
62
+      LineNode line = block.first;
63
+      Delta delta = new Delta()
64
+        ..insert('Hello world')
65
+        ..insert('\n', ulAttrs.toJson());
66
+      expect(line.toDelta(), delta);
67
+    });
68
+
69
+    test('format second line as list', () {
70
+      root.insert(0, 'Hello world\nAb cd ef!', null);
71
+      root.retain(21, 1, ulAttrs);
72
+
73
+      expect(root.childCount, 2);
74
+      BlockNode block = root.last;
75
+      expect(block.style.get(NotusAttribute.block),
76
+          NotusAttribute.ul);
77
+      expect(block.childCount, 1);
78
+      expect(block.first, const TypeMatcher<LineNode>());
79
+    });
80
+
81
+    test('format two sibling lines as list', () {
82
+      root.insert(0, 'Hello world\nAb cd ef!', null);
83
+      root.retain(11, 1, ulAttrs);
84
+      root.retain(21, 1, ulAttrs);
85
+
86
+      expect(root.childCount, 1);
87
+      BlockNode block = root.first;
88
+      expect(block.style.get(NotusAttribute.block),
89
+          NotusAttribute.ul);
90
+      expect(block.childCount, 2);
91
+      expect(block.first, const TypeMatcher<LineNode>());
92
+      expect(block.last, const TypeMatcher<LineNode>());
93
+    });
94
+
95
+    test('format to split first line from block', () {
96
+      root.insert(
97
+          0, 'London Grammar Songs\nHey now\nStrong\nIf You Wait', null);
98
+      root.retain(20, 1, h1Attrs);
99
+      root.retain(28, 1, ulAttrs);
100
+      root.retain(35, 1, ulAttrs);
101
+      root.retain(47, 1, ulAttrs);
102
+      expect(root.childCount, 2);
103
+      root.retain(28, 1, olAttrs);
104
+      expect(root.childCount, 3);
105
+      final expected = new Delta()
106
+        ..insert('London Grammar Songs')
107
+        ..insert('\n', NotusAttribute.h1.toJson())
108
+        ..insert('Hey now')
109
+        ..insert('\n', NotusAttribute.ol.toJson())
110
+        ..insert('Strong')
111
+        ..insert('\n', ulAttrs.toJson())
112
+        ..insert('If You Wait')
113
+        ..insert('\n', ulAttrs.toJson());
114
+      expect(root.toDelta(), expected);
115
+    });
116
+
117
+    test('format to split last line from block', () {
118
+      root.insert(
119
+          0, 'London Grammar Songs\nHey now\nStrong\nIf You Wait', null);
120
+      root.retain(20, 1, h1Attrs);
121
+      root.retain(28, 1, ulAttrs);
122
+      root.retain(35, 1, ulAttrs);
123
+      root.retain(47, 1, ulAttrs);
124
+      expect(root.childCount, 2);
125
+      root.retain(47, 1, olAttrs);
126
+      expect(root.childCount, 3);
127
+      final expected = new Delta()
128
+        ..insert('London Grammar Songs')
129
+        ..insert('\n', NotusAttribute.h1.toJson())
130
+        ..insert('Hey now')
131
+        ..insert('\n', ulAttrs.toJson())
132
+        ..insert('Strong')
133
+        ..insert('\n', ulAttrs.toJson())
134
+        ..insert('If You Wait')
135
+        ..insert('\n', NotusAttribute.ol.toJson());
136
+      expect(root.toDelta(), expected);
137
+    });
138
+
139
+    test('format to split middle line from block', () {
140
+      root.insert(
141
+          0, 'London Grammar Songs\nHey now\nStrong\nIf You Wait', null);
142
+      root.retain(20, 1, h1Attrs);
143
+      root.retain(28, 1, ulAttrs);
144
+      root.retain(35, 1, ulAttrs);
145
+      root.retain(47, 1, ulAttrs);
146
+      expect(root.childCount, 2);
147
+      root.retain(35, 1, olAttrs);
148
+      expect(root.childCount, 4);
149
+      final expected = new Delta()
150
+        ..insert('London Grammar Songs')
151
+        ..insert('\n', NotusAttribute.h1.toJson())
152
+        ..insert('Hey now')
153
+        ..insert('\n', ulAttrs.toJson())
154
+        ..insert('Strong')
155
+        ..insert('\n', NotusAttribute.ol.toJson())
156
+        ..insert('If You Wait')
157
+        ..insert('\n', ulAttrs.toJson());
158
+      expect(root.toDelta(), expected);
159
+    });
160
+
161
+    test('insert line-break at the begining of the document', () {
162
+      root.insert(
163
+          0, 'London Grammar Songs\nHey now\nStrong\nIf You Wait', null);
164
+      root.retain(20, 1, ulAttrs);
165
+      root.retain(28, 1, ulAttrs);
166
+      root.retain(35, 1, ulAttrs);
167
+      root.retain(47, 1, ulAttrs);
168
+      expect(root.childCount, 1);
169
+      root.insert(0, '\n', null);
170
+      expect(root.childCount, 2);
171
+    });
172
+  });
173
+}

+ 264
- 0
packages/notus/test/document/line_test.dart Wyświetl plik

@@ -0,0 +1,264 @@
1
+// Copyright (c) 2018, the Zefyr project authors.  Please see the AUTHORS file
2
+// for details. All rights reserved. Use of this source code is governed by a
3
+// BSD-style license that can be found in the LICENSE file.
4
+import 'package:test/test.dart';
5
+import 'package:quill_delta/quill_delta.dart';
6
+import 'package:notus/notus.dart';
7
+
8
+final boldStyle = new NotusStyle().merge(NotusAttribute.bold);
9
+final h1Style = new NotusStyle().merge(NotusAttribute.h1);
10
+final h2Style = new NotusStyle().merge(NotusAttribute.h2);
11
+final ulStyle = new NotusStyle().merge(NotusAttribute.ul);
12
+final bqStyle = new NotusStyle().merge(NotusAttribute.bq);
13
+
14
+void main() {
15
+  group('$LineNode', () {
16
+    ContainerNode root;
17
+    setUp(() {
18
+      root = new RootNode();
19
+    });
20
+
21
+    test('empty', () {
22
+      LineNode node = new LineNode();
23
+      expect(node, isEmpty);
24
+      expect(node.length, 1);
25
+      expect(node.style, new NotusStyle());
26
+      expect(node.toDelta().toList(), [new Operation.insert('\n')]);
27
+    });
28
+
29
+    test('nextLine', () {
30
+      root.insert(
31
+          0, 'Hello world\nThis is my first multiline\nItem\ndocument.', null);
32
+      root.retain(38, 1, ulStyle);
33
+      root.retain(43, 1, bqStyle);
34
+      LineNode line = root.first;
35
+      expect(line.toPlainText(), 'Hello world\n');
36
+      var next = line.nextLine;
37
+      expect(next.toPlainText(), 'This is my first multiline\n');
38
+      next = next.nextLine;
39
+      expect(next.toPlainText(), 'Item\n');
40
+      next = next.nextLine;
41
+      expect(next.toPlainText(), 'document.\n');
42
+      expect(next.nextLine, isNull);
43
+    });
44
+
45
+    test('toString', () {
46
+      LineNode node = new LineNode();
47
+      node.insert(0, 'London "Grammar" - Hey Now', null);
48
+      node.retain(0, 16, boldStyle);
49
+      node.applyAttribute(NotusAttribute.h1);
50
+      expect('$node', '¶ ⟨London "Grammar"⟩b → ⟨ - Hey Now⟩ ⏎ {heading: 1}');
51
+    });
52
+
53
+    test('splitAt with multiple text segments', (){
54
+      root.insert(0, 'This house is a circus', null);
55
+      root.retain(0, 4, boldStyle);
56
+      root.retain(16, 6, boldStyle);
57
+      LineNode line = root.first;
58
+      final newLine = line.splitAt(10);
59
+      expect(line.toPlainText(), 'This house\n');
60
+      expect(newLine.toPlainText(), ' is a circus\n');
61
+    });
62
+
63
+    test('insert into empty line', () {
64
+      LineNode node = new LineNode();
65
+      node.insert(0, 'London "Grammar" - Hey Now', null);
66
+      expect(node, hasLength(27));
67
+      expect(
68
+          node.toDelta(), new Delta()..insert('London "Grammar" - Hey Now\n'));
69
+    });
70
+
71
+    test('insert into empty line with styles', () {
72
+      LineNode node = new LineNode();
73
+      node.insert(0, 'London "Grammar" - Hey Now', null);
74
+      node.retain(0, 16, boldStyle);
75
+      node.applyAttribute(NotusAttribute.h1);
76
+      expect(node, hasLength(27));
77
+      expect(node.childCount, 2);
78
+
79
+      final delta = new Delta()
80
+        ..insert('London "Grammar"', boldStyle.toJson())
81
+        ..insert(' - Hey Now')
82
+        ..insert('\n', NotusAttribute.h1.toJson());
83
+      expect(node.toDelta(), delta);
84
+    });
85
+
86
+    test('insert into non-empty line', () {
87
+      LineNode node = new LineNode();
88
+      node.insert(0, 'Hello world', null);
89
+      node.insert(11, '!!!', null);
90
+      expect(node, hasLength(15));
91
+      expect(node.childCount, 1);
92
+      expect(node.toDelta(), new Delta()..insert('Hello world!!!\n'));
93
+    });
94
+
95
+    test('insert text with a line-break at the end of line', () {
96
+      root.insert(0, 'Hello world', null);
97
+      root.insert(11, '!!!\n', null);
98
+      expect(root.childCount, 2);
99
+
100
+      LineNode line = root.first;
101
+      expect(line, hasLength(15));
102
+      expect(line.toDelta(), new Delta()..insert('Hello world!!!\n'));
103
+
104
+      LineNode line2 = root.last;
105
+      expect(line2, hasLength(1));
106
+      expect(line2.toDelta(), new Delta()..insert('\n'));
107
+    });
108
+
109
+    test('insert into second text segment', () {
110
+      root.insert(0, 'Hello world', null);
111
+      root.retain(6, 5, boldStyle);
112
+      root.insert(11, '!!!', null);
113
+
114
+      LineNode line = root.first;
115
+      expect(line, hasLength(15));
116
+      Delta delta = new Delta()
117
+        ..insert('Hello ')
118
+        ..insert('world', boldStyle.toJson())
119
+        ..insert('!!!\n');
120
+      expect(line.toDelta(), delta);
121
+    });
122
+
123
+    test('format line', () {
124
+      root.insert(0, 'Hello world', null);
125
+      root.retain(11, 1, h1Style);
126
+
127
+      LineNode line = root.first;
128
+      expect(line, hasLength(12));
129
+
130
+      Delta delta = new Delta()
131
+        ..insert('Hello world')
132
+        ..insert('\n', NotusAttribute.h1.toJson());
133
+      expect(line.toDelta(), delta);
134
+    });
135
+
136
+    test('format line with inline attributes', () {
137
+      root.insert(0, 'Hello world', null);
138
+      expect(() {
139
+        root.retain(11, 1, boldStyle);
140
+      }, throwsA(const TypeMatcher<AssertionError>()));
141
+    });
142
+
143
+    test('format text inside line with block/line attributes', () {
144
+      root.insert(0, 'Hello world', null);
145
+      expect(() {
146
+        root.retain(10, 2, h1Style);
147
+      }, throwsA(const TypeMatcher<AssertionError>()));
148
+    });
149
+
150
+    test('format root line to unset block style', () {
151
+      final unsetBlock = new NotusStyle().put(NotusAttribute.block.unset);
152
+      root.insert(0, 'Hello world', null);
153
+      root.retain(11, 1, unsetBlock);
154
+      expect(root.childCount, 1);
155
+      expect(root.first, const TypeMatcher<LineNode>());
156
+      LineNode line = root.first;
157
+      expect(line.style.contains(NotusAttribute.block), isFalse);
158
+    });
159
+
160
+    test('format multiple empty lines', () {
161
+      root.insert(0, 'Hello world\n\n\n', null);
162
+      root.retain(11, 3, ulStyle);
163
+      expect(root.children, hasLength(2));
164
+      BlockNode block = root.first;
165
+      expect(block.children, hasLength(3));
166
+      expect(block.toPlainText(), 'Hello world\n\n\n');
167
+    });
168
+
169
+    test('delete a line', () {
170
+      root.insert(0, 'Hello world', null);
171
+      root.delete(0, 12);
172
+      expect(root, isEmpty);
173
+      // TODO: this should really enforce at least one empty line.
174
+    });
175
+
176
+    test('delete from the middle of a line', () {
177
+      root.insert(0, 'Hello world', null);
178
+      root.delete(4, 3);
179
+      root.delete(6, 1);
180
+      expect(root.childCount, 1);
181
+      LineNode line = root.first;
182
+      expect(line, hasLength(8));
183
+      expect(line.childCount, 1);
184
+      Delta lineDelta = new Delta()..insert('Hellord\n');
185
+      expect(line.toDelta(), lineDelta);
186
+    });
187
+
188
+    test('delete from non-first segment in line', () {
189
+      root.insert(0, 'Hello world, Ab cd ef!', null);
190
+      root.retain(6, 5, boldStyle);
191
+      root.delete(10, 5);
192
+      expect(root.childCount, 1);
193
+      LineNode line = root.first;
194
+      expect(line, hasLength(18));
195
+      Delta lineDelta = new Delta()
196
+        ..insert('Hello ')
197
+        ..insert('worl', boldStyle.toJson())
198
+        ..insert(' cd ef!\n');
199
+      expect(line.toDelta(), lineDelta);
200
+    });
201
+
202
+    test('delete on multiple lines', () {
203
+      root.insert(0, 'delete\nmultiple\nlines', null);
204
+      root.retain(21, 1, h2Style);
205
+      root.delete(3, 15);
206
+      expect(root.childCount, 1);
207
+      LineNode line = root.first;
208
+      expect(line.childCount, 1);
209
+      final delta = new Delta()
210
+        ..insert('delnes')
211
+        ..insert('\n', h2Style.toJson());
212
+      expect(line.toDelta(), delta);
213
+    });
214
+
215
+    test('delete empty line', () {
216
+      root.insert(
217
+          0, 'Hello world\nThis is my first multiline\n\ndocument.', null);
218
+      expect(root.childCount, 4);
219
+      root.delete(39, 1);
220
+      expect(root.childCount, 3);
221
+    });
222
+
223
+    test('delete line-break of non-empty line', () {
224
+      root.insert(
225
+          0, 'Hello world\nThis is my first multiline\n\ndocument.', null);
226
+      root.retain(39, 1, h2Style);
227
+      expect(root.childCount, 4);
228
+      root.delete(38, 1);
229
+      expect(root.childCount, 3);
230
+      LineNode line = root.children.elementAt(1);
231
+      expect(line.style.get(NotusAttribute.heading), NotusAttribute.h2);
232
+    });
233
+
234
+    test('insert at the beginning of a line', () {
235
+      root.insert(
236
+          0, 'Hello world\nThis is my first multiline\ndocument.', null);
237
+      root.insert(12, 'Boom! ', null);
238
+      expect(root.childCount, 3);
239
+      expect(root.children.elementAt(1), hasLength(33));
240
+    });
241
+
242
+    test('delete last character of a line', () {
243
+      root.insert(
244
+          0, 'Hello world\nThis is my first multiline\ndocument.', null);
245
+      root.delete(37, 1);
246
+      expect(root.childCount, 3);
247
+      LineNode line = root.children.elementAt(1);
248
+      expect(
249
+          line.toDelta(), new Delta()..insert('This is my first multilin\n'));
250
+    });
251
+
252
+    test('collectStyle', () {
253
+      // TODO: need more test cases for collectStyle
254
+      root.insert(
255
+          0, 'Hello world\nThis is my first multiline\n\ndocument.', null);
256
+      root.retain(38, 1, h2Style);
257
+      root.retain(23, 5, boldStyle);
258
+      var result = root.lookup(20);
259
+      LineNode line = result.node;
260
+      var attrs = line.collectStyle(result.offset, 5);
261
+      expect(attrs, h2Style);
262
+    });
263
+  });
264
+}

+ 46
- 0
packages/notus/test/document/node_test.dart Wyświetl plik

@@ -0,0 +1,46 @@
1
+// Copyright (c) 2018, the Zefyr project authors.  Please see the AUTHORS file
2
+// for details. All rights reserved. Use of this source code is governed by a
3
+// BSD-style license that can be found in the LICENSE file.
4
+import 'package:test/test.dart';
5
+import 'package:notus/notus.dart';
6
+
7
+void main() {
8
+  group('$Node', () {
9
+    RootNode root;
10
+    setUp(() {
11
+      root = new RootNode();
12
+    });
13
+
14
+    test('mounted', () {
15
+      LineNode line = new LineNode();
16
+      TextNode text = new TextNode();
17
+      expect(text.mounted, isFalse);
18
+      line.add(text);
19
+      expect(text.mounted, isTrue);
20
+    });
21
+
22
+    test('offset', () {
23
+      root.insert(0, 'First line\nSecond line', null);
24
+      expect(root.children.first.offset, 0);
25
+      expect(root.children.elementAt(1).offset, 11);
26
+    });
27
+
28
+    test('documentOffset', () {
29
+      root.insert(0, 'First line\nSecond line', null);
30
+      LineNode line = root.children.last;
31
+      TextNode text = line.first;
32
+      expect(line.documentOffset, 11);
33
+      expect(text.documentOffset, 11);
34
+    });
35
+
36
+    test('containsOffset', () {
37
+      root.insert(0, 'First line\nSecond line', null);
38
+      LineNode line = root.children.last;
39
+      TextNode text = line.first;
40
+      expect(line.containsOffset(10), isFalse);
41
+      expect(line.containsOffset(12), isTrue);
42
+      expect(text.containsOffset(10), isFalse);
43
+      expect(text.containsOffset(12), isTrue);
44
+    });
45
+  });
46
+}

+ 114
- 0
packages/notus/test/document/text_test.dart Wyświetl plik

@@ -0,0 +1,114 @@
1
+// Copyright (c) 2018, the Zefyr project authors.  Please see the AUTHORS file
2
+// for details. All rights reserved. Use of this source code is governed by a
3
+// BSD-style license that can be found in the LICENSE file.
4
+import 'package:test/test.dart';
5
+import 'package:quill_delta/quill_delta.dart';
6
+import 'package:notus/notus.dart';
7
+
8
+final boldStyle = new NotusStyle().merge(NotusAttribute.bold);
9
+final boldUnsetStyle = new NotusStyle().put(NotusAttribute.bold.unset);
10
+final italicStyle = new NotusStyle().merge(NotusAttribute.italic);
11
+
12
+void main() {
13
+  group('$TextNode', () {
14
+    LineNode line;
15
+    TextNode node;
16
+
17
+    setUp(() {
18
+      line = new LineNode();
19
+      node = new TextNode('London "Grammar"');
20
+      line.add(node);
21
+    });
22
+
23
+    test('new empty text', () {
24
+      final node = new TextNode();
25
+      expect(node.value, isEmpty);
26
+      expect(node.length, 0);
27
+      expect(node.style, new NotusStyle());
28
+      expect(node.toDelta(), isEmpty);
29
+    });
30
+
31
+    test('toString', () {
32
+      node.applyAttribute(NotusAttribute.bold);
33
+      node.applyAttribute(NotusAttribute.link.fromString('link'));
34
+      expect('$node', '⟨London "Grammar"⟩ab');
35
+    });
36
+
37
+    test('new text with contents', () {
38
+      expect(node.value, isNotEmpty);
39
+      expect(node.length, 16);
40
+      expect(
41
+          node.toDelta().toList(), [new Operation.insert('London "Grammar"')]);
42
+    });
43
+
44
+    test('insert at the end', () {
45
+      node.insert(16, '!!!', null);
46
+      expect(node.value, 'London "Grammar"!!!');
47
+    });
48
+
49
+    test('delete tail', () {
50
+      node.delete(6, 10);
51
+      expect(node.value, 'London');
52
+    });
53
+
54
+    test('format substring', () {
55
+      node.retain(8, 7, boldStyle);
56
+      expect(line.children, hasLength(3));
57
+      expect(line.children.elementAt(0), hasLength(8));
58
+      expect(line.children.elementAt(1), hasLength(7));
59
+      expect(line.children.elementAt(2), hasLength(1));
60
+    });
61
+
62
+    test('format full segment', () {
63
+      node.retain(0, 16, boldStyle);
64
+      expect(line.childCount, 1);
65
+      expect(node.value, 'London "Grammar"');
66
+      expect(node.style.values, [NotusAttribute.bold]);
67
+    });
68
+
69
+    test('format with multiple styles', () {
70
+      line.retain(0, 6, boldStyle);
71
+      line.retain(0, 6, italicStyle);
72
+      expect(line.childCount, 2);
73
+    });
74
+
75
+    test('format to remove attribute', () {
76
+      line.retain(0, 6, boldStyle);
77
+      line.retain(0, 6, boldUnsetStyle);
78
+      expect(line.childCount, 1);
79
+
80
+      expect(node.value, 'London "Grammar"');
81
+      expect(node.style, isEmpty);
82
+    });
83
+
84
+    test('format intersecting nodes', () {
85
+      line.retain(0, 6, boldStyle);
86
+      line.retain(3, 10, italicStyle);
87
+      expect(line.childCount, 4);
88
+      expect(line.children.elementAt(0), hasLength(3));
89
+      expect(line.children.elementAt(1), hasLength(3));
90
+      expect(line.children.elementAt(2), hasLength(7));
91
+      expect(line.children.elementAt(3), hasLength(3));
92
+    });
93
+
94
+    test('insert in formatted node', () {
95
+      line.retain(0, 6, boldStyle);
96
+      expect(line.childCount, 2);
97
+      line.insert(3, 'don', null);
98
+      expect(line.childCount, 4);
99
+      final b = boldStyle.toJson();
100
+      expect(
101
+        line.children.elementAt(0).toDelta(),
102
+        new Delta()..insert('Lon', b),
103
+      );
104
+      expect(
105
+        line.children.elementAt(1).toDelta(),
106
+        new Delta()..insert('don'),
107
+      );
108
+      expect(
109
+        line.children.elementAt(2).toDelta(),
110
+        new Delta()..insert('don', b),
111
+      );
112
+    });
113
+  });
114
+}

+ 304
- 0
packages/notus/test/document_test.dart Wyświetl plik

@@ -0,0 +1,304 @@
1
+// Copyright (c) 2018, the Zefyr project authors.  Please see the AUTHORS file
2
+// for details. All rights reserved. Use of this source code is governed by a
3
+// BSD-style license that can be found in the LICENSE file.
4
+import 'dart:convert';
5
+
6
+import 'package:test/test.dart';
7
+import 'package:quill_delta/quill_delta.dart';
8
+import 'package:notus/notus.dart';
9
+import 'matchers.dart';
10
+
11
+NotusDocument dartconfDoc() {
12
+  Delta delta = new Delta()..insert('DartConf\nLos Angeles\n');
13
+  return new NotusDocument.fromDelta(delta);
14
+}
15
+
16
+final ul = NotusAttribute.ul.toJson();
17
+final h1 = NotusAttribute.h1.toJson();
18
+
19
+void main() {
20
+  group('$NotusDocument', () {
21
+    test('validates for doc delta', () {
22
+      var badDelta = new Delta()
23
+        ..insert('Text')
24
+        ..retain(5)
25
+        ..insert('\n');
26
+      expect(() {
27
+        new NotusDocument.fromDelta(badDelta);
28
+      }, throwsArgumentError);
29
+    });
30
+
31
+    test('empty document contains single empty line', () {
32
+      NotusDocument doc = new NotusDocument();
33
+      expect(doc.toPlainText(), '\n');
34
+    });
35
+
36
+    test('json serialization', () {
37
+      final original = dartconfDoc();
38
+      final jsonData = json.encode(original);
39
+      final doc = NotusDocument.fromJson(json.decode(jsonData));
40
+      expect(doc.toDelta(), original.toDelta());
41
+      expect(json.encode(doc), jsonData);
42
+    });
43
+
44
+    test('length', () {
45
+      final doc = dartconfDoc();
46
+      expect(doc.length, 21);
47
+    });
48
+
49
+    test('toString', () {
50
+      NotusDocument doc = dartconfDoc();
51
+      expect(doc.toString(), doc.toString());
52
+    });
53
+
54
+    test('load non-empty document', () {
55
+      NotusDocument doc = dartconfDoc();
56
+      expect(doc.toPlainText(), 'DartConf\nLos Angeles\n');
57
+    });
58
+
59
+    test('document delta must end with line-break', () {
60
+      Delta delta = new Delta()..insert('DartConf\nLos Angeles');
61
+      expect(() {
62
+        new NotusDocument.fromDelta(delta);
63
+      }, throwsA(const TypeMatcher<AssertionError>()));
64
+    });
65
+
66
+    test('lookupLine', () {
67
+      final doc = dartconfDoc();
68
+      doc.format(20, 1, NotusAttribute.bq);
69
+      var line1 = doc.lookupLine(3);
70
+      var line2 = doc.lookupLine(13);
71
+
72
+      expect(line1.node, const TypeMatcher<LineNode>());
73
+      expect(line1.node.toPlainText(), 'DartConf\n');
74
+      expect(line2.node, const TypeMatcher<LineNode>());
75
+      expect(line2.node.toPlainText(), 'Los Angeles\n');
76
+    });
77
+
78
+    test('format applies heuristics', () {
79
+      NotusDocument doc = dartconfDoc();
80
+      doc.format(0, 15, NotusAttribute.ul);
81
+      expect(doc.root.children, hasLength(1));
82
+      expect(doc.root.children.first, const TypeMatcher<BlockNode>());
83
+    });
84
+
85
+    test('format ignores empty changes', () async {
86
+      NotusDocument doc = dartconfDoc();
87
+      var changeList = doc.changes.toList();
88
+      var change = doc.format(1, 0, NotusAttribute.bold);
89
+      doc.close();
90
+      var changes = await changeList;
91
+      expect(change, isEmpty);
92
+      expect(changes, isEmpty);
93
+    });
94
+
95
+    test('format returns actual change delta', () {
96
+      NotusDocument doc = dartconfDoc();
97
+      final change = doc.format(0, 15, NotusAttribute.ul);
98
+      final expectedChange = new Delta()
99
+        ..retain(8)
100
+        ..retain(1, ul)
101
+        ..retain(11)
102
+        ..retain(1, ul);
103
+      expect(change, expectedChange);
104
+    });
105
+
106
+    test('format updates document delta', () {
107
+      NotusDocument doc = dartconfDoc();
108
+      doc.format(0, 15, NotusAttribute.ul);
109
+      final expectedDoc = new Delta()
110
+        ..insert('DartConf')
111
+        ..insert('\n', ul)
112
+        ..insert('Los Angeles')
113
+        ..insert('\n', ul);
114
+      expect(doc.toDelta(), expectedDoc);
115
+    });
116
+
117
+    test('format allows zero-length updates', () {
118
+      NotusDocument doc = dartconfDoc();
119
+      doc.format(0, 0, NotusAttribute.ul);
120
+      final expectedDoc = new Delta()
121
+        ..insert('DartConf')
122
+        ..insert('\n', ul)
123
+        ..insert('Los Angeles')
124
+        ..insert('\n');
125
+      expect(doc.toDelta(), expectedDoc);
126
+    });
127
+
128
+    test('insert applies heuristics', () {
129
+      NotusDocument doc = dartconfDoc();
130
+      doc.format(0, 15, NotusAttribute.ul);
131
+      doc.insert(8, '\n');
132
+      expect(doc.root.children, hasLength(1));
133
+      expect(doc.root.children.first, const TypeMatcher<BlockNode>());
134
+    });
135
+
136
+    test('insert returns actual change delta', () {
137
+      NotusDocument doc = dartconfDoc();
138
+      doc.format(0, 15, NotusAttribute.ul);
139
+      final change = doc.insert(8, '\n');
140
+      final expectedChange = new Delta()
141
+        ..retain(8)
142
+        ..insert('\n', ul);
143
+      expect(change, expectedChange);
144
+    });
145
+
146
+    test('insert updates document delta', () {
147
+      NotusDocument doc = dartconfDoc();
148
+      doc.format(0, 15, NotusAttribute.ul);
149
+      doc.insert(8, '\n');
150
+      final expectedDoc = new Delta()
151
+        ..insert('DartConf')
152
+        ..insert('\n\n', ul)
153
+        ..insert('Los Angeles')
154
+        ..insert('\n', ul);
155
+      expect(doc.toDelta(), expectedDoc);
156
+    });
157
+
158
+    test('insert throws assert error if change is empty', () {
159
+      NotusDocument doc = dartconfDoc();
160
+      expect(() {
161
+        doc.insert(8, '');
162
+      }, throwsA(const TypeMatcher<AssertionError>()));
163
+    });
164
+
165
+    test('replace throws assert error if change is empty', () {
166
+      NotusDocument doc = dartconfDoc();
167
+      expect(() {
168
+        doc.replace(8, 0, '');
169
+      }, throwsA(const TypeMatcher<AssertionError>()));
170
+    });
171
+
172
+    test('compose throws assert error if change is empty', () {
173
+      NotusDocument doc = dartconfDoc();
174
+      expect(() {
175
+        doc.compose(new Delta()..retain(1), ChangeSource.local);
176
+      }, throwsA(const TypeMatcher<AssertionError>()));
177
+    });
178
+
179
+    test('replace applies heuristic rules', () {
180
+      NotusDocument doc = dartconfDoc();
181
+      doc.format(0, 0, NotusAttribute.h1);
182
+      doc.replace(8, 1, ' ');
183
+      expect(doc.root.children, hasLength(1));
184
+      LineNode line = doc.root.children.first;
185
+      expect(line.style.get(NotusAttribute.heading), NotusAttribute.h1);
186
+      expect(line.toPlainText(), 'DartConf Los Angeles\n');
187
+    });
188
+
189
+    test('delete applies heuristic rules', () {
190
+      NotusDocument doc = dartconfDoc();
191
+      doc.format(0, 0, NotusAttribute.h1);
192
+      doc.delete(8, 1);
193
+      expect(doc.root.children, hasLength(1));
194
+      LineNode line = doc.root.children.first;
195
+      expect(line.style.get(NotusAttribute.heading), NotusAttribute.h1);
196
+    });
197
+
198
+    test('checks for closed state', () {
199
+      final doc = dartconfDoc();
200
+      expect(doc.isClosed, isFalse);
201
+      doc.close();
202
+      expect(doc.isClosed, isTrue);
203
+      expect(() {
204
+        doc.compose(new Delta()..insert('a'), ChangeSource.local);
205
+      }, throwsAssertionError);
206
+      expect(() {
207
+        doc.insert(0, 'a');
208
+      }, throwsAssertionError);
209
+      expect(() {
210
+        doc.format(0, 1, NotusAttribute.bold);
211
+      }, throwsAssertionError);
212
+      expect(() {
213
+        doc.delete(0, 1);
214
+      }, throwsAssertionError);
215
+    });
216
+
217
+    test('collectStyle', () {
218
+      final doc = dartconfDoc();
219
+      final style = doc.collectStyle(0, 10);
220
+      expect(style, isNotNull);
221
+    });
222
+
223
+    test('insert embed after newline', () {
224
+      final doc = dartconfDoc();
225
+      doc.insert(9, const HorizontalRuleEmbed());
226
+      expect(doc.root.children, hasLength(3));
227
+      expect(doc.root.first.toPlainText(), 'DartConf\n');
228
+      expect(doc.root.last.toPlainText(), 'Los Angeles\n');
229
+      LineNode line = doc.root.children.elementAt(1);
230
+      EmbedNode embed = line.first;
231
+      expect(embed.toPlainText(), EmbedNode.kPlainTextPlaceholder);
232
+      final style = new NotusStyle().merge(NotusAttribute.embed.horizontalRule);
233
+      expect(embed.style, style);
234
+    });
235
+
236
+    test('insert embed before newline', () {
237
+      final doc = dartconfDoc();
238
+      doc.insert(8, const HorizontalRuleEmbed());
239
+      expect(doc.root.children, hasLength(3));
240
+      expect(doc.root.first.toPlainText(), 'DartConf\n');
241
+      expect(doc.root.last.toPlainText(), 'Los Angeles\n');
242
+      LineNode line = doc.root.children.elementAt(1);
243
+      EmbedNode embed = line.first;
244
+      expect(embed.toPlainText(), EmbedNode.kPlainTextPlaceholder);
245
+      final style = new NotusStyle().merge(NotusAttribute.embed.horizontalRule);
246
+      expect(embed.style, style);
247
+    });
248
+
249
+    test('insert embed in the middle of a line', () {
250
+      final doc = dartconfDoc();
251
+      doc.insert(4, const HorizontalRuleEmbed());
252
+      expect(doc.root.children, hasLength(4));
253
+      expect(doc.root.children.elementAt(0).toPlainText(), 'Dart\n');
254
+      expect(doc.root.children.elementAt(1).toPlainText(),
255
+          '${EmbedNode.kPlainTextPlaceholder}\n');
256
+      expect(doc.root.children.elementAt(2).toPlainText(), 'Conf\n');
257
+      expect(doc.root.children.elementAt(3).toPlainText(), 'Los Angeles\n');
258
+      LineNode line = doc.root.children.elementAt(1);
259
+      EmbedNode embed = line.first;
260
+      expect(embed.toPlainText(), EmbedNode.kPlainTextPlaceholder);
261
+      final style = new NotusStyle().merge(NotusAttribute.embed.horizontalRule);
262
+      expect(embed.style, style);
263
+    });
264
+
265
+    test('delete embed', () {
266
+      final doc = dartconfDoc();
267
+      doc.insert(8, const HorizontalRuleEmbed());
268
+      expect(doc.root.children, hasLength(3));
269
+      doc.delete(9, 1);
270
+      expect(doc.root.children, hasLength(3));
271
+      LineNode line = doc.root.children.elementAt(1);
272
+      expect(line, isEmpty);
273
+    });
274
+
275
+    test('insert text containing zero-width space', () {
276
+      final doc = dartconfDoc();
277
+      final change = doc.insert(0, EmbedNode.kPlainTextPlaceholder);
278
+      expect(change, isEmpty);
279
+      expect(doc.length, 21);
280
+    });
281
+
282
+    test('insert text before embed', () {
283
+      final doc = dartconfDoc();
284
+      doc.insert(8, const HorizontalRuleEmbed());
285
+      expect(doc.root.children, hasLength(3));
286
+      doc.insert(9, 'text');
287
+      expect(doc.root.children, hasLength(4));
288
+      expect(doc.root.children.elementAt(1).toPlainText(), 'text\n');
289
+      expect(doc.root.children.elementAt(2).toPlainText(),
290
+          '${EmbedNode.kPlainTextPlaceholder}\n');
291
+    });
292
+
293
+    test('insert text after embed', () {
294
+      final doc = dartconfDoc();
295
+      doc.insert(8, const HorizontalRuleEmbed());
296
+      expect(doc.root.children, hasLength(3));
297
+      doc.insert(10, 'text');
298
+      expect(doc.root.children, hasLength(4));
299
+      expect(doc.root.children.elementAt(1).toPlainText(),
300
+          '${EmbedNode.kPlainTextPlaceholder}\n');
301
+      expect(doc.root.children.elementAt(2).toPlainText(), 'text\n');
302
+    });
303
+  });
304
+}

+ 57
- 0
packages/notus/test/heuristics/delete_rules_test.dart Wyświetl plik

@@ -0,0 +1,57 @@
1
+// Copyright (c) 2018, the Zefyr project authors.  Please see the AUTHORS file
2
+// for details. All rights reserved. Use of this source code is governed by a
3
+// BSD-style license that can be found in the LICENSE file.
4
+import 'package:test/test.dart';
5
+import 'package:quill_delta/quill_delta.dart';
6
+import 'package:notus/notus.dart';
7
+
8
+final ul = NotusAttribute.ul.toJson();
9
+final bold = NotusAttribute.bold.toJson();
10
+
11
+void main() {
12
+  group('$PreserveLineStyleOnMergeRule', () {
13
+    final rule = new PreserveLineStyleOnMergeRule();
14
+    test('preserves block style', () {
15
+      final ul = NotusAttribute.ul.toJson();
16
+      final doc = new Delta()
17
+        ..insert('Title\nOne')
18
+        ..insert('\n', ul)
19
+        ..insert('Two\n');
20
+      final actual = rule.apply(doc, 9, 1);
21
+      final expected = new Delta()
22
+        ..retain(9)
23
+        ..delete(1)
24
+        ..retain(3)
25
+        ..retain(1, ul);
26
+      expect(actual, expected);
27
+    });
28
+
29
+    test('resets block style', () {
30
+      final unsetUl = NotusAttribute.block.unset.toJson();
31
+      final doc = new Delta()
32
+        ..insert('Title\nOne')
33
+        ..insert('\n', NotusAttribute.ul.toJson())
34
+        ..insert('Two\n');
35
+      final actual = rule.apply(doc, 5, 1);
36
+      final expected = new Delta()
37
+        ..retain(5)
38
+        ..delete(1)
39
+        ..retain(3)
40
+        ..retain(1, unsetUl);
41
+      expect(actual, expected);
42
+    });
43
+  });
44
+
45
+  group('$CatchAllDeleteRule', () {
46
+    final rule = new CatchAllDeleteRule();
47
+
48
+    test('applies change as-is', () {
49
+      final doc = new Delta()..insert('Document\n');
50
+      final actual = rule.apply(doc, 3, 5);
51
+      final expected = new Delta()
52
+        ..retain(3)
53
+        ..delete(5);
54
+      expect(actual, expected);
55
+    });
56
+  });
57
+}

+ 96
- 0
packages/notus/test/heuristics/format_rules_test.dart Wyświetl plik

@@ -0,0 +1,96 @@
1
+// Copyright (c) 2018, the Zefyr project authors.  Please see the AUTHORS file
2
+// for details. All rights reserved. Use of this source code is governed by a
3
+// BSD-style license that can be found in the LICENSE file.
4
+import 'package:test/test.dart';
5
+import 'package:quill_delta/quill_delta.dart';
6
+import 'package:notus/notus.dart';
7
+
8
+final ul = NotusAttribute.ul.toJson();
9
+final bold = NotusAttribute.bold.toJson();
10
+
11
+void main() {
12
+  group('$ResolveLineFormatRule', () {
13
+    final rule = new ResolveLineFormatRule();
14
+
15
+    test('apply', () {
16
+      final doc = new Delta()..insert('Correct\nLine\nStyle\nRule\n');
17
+
18
+      final actual = rule.apply(doc, 0, 20, NotusAttribute.ul);
19
+      expect(actual, isNotNull);
20
+      final ul = NotusAttribute.ul.toJson();
21
+      final expected = new Delta()
22
+        ..retain(7)
23
+        ..retain(1, ul)
24
+        ..retain(4)
25
+        ..retain(1, ul)
26
+        ..retain(5)
27
+        ..retain(1, ul)
28
+        ..retain(4)
29
+        ..retain(1, ul);
30
+      expect(actual, expected);
31
+    });
32
+
33
+    test('apply with zero length (collapsed selection)', () {
34
+      final doc = new Delta()..insert('Correct\nLine\nStyle\nRule\n');
35
+      final actual = rule.apply(doc, 0, 0, NotusAttribute.ul);
36
+      expect(actual, isNotNull);
37
+      final ul = NotusAttribute.ul.toJson();
38
+      final expected = new Delta()..retain(7)..retain(1, ul);
39
+      expect(actual, expected);
40
+    });
41
+
42
+    test('apply with zero length in the middle of a line', () {
43
+      final ul = NotusAttribute.ul.toJson();
44
+      final doc = new Delta()
45
+        ..insert('Title\nOne')
46
+        ..insert('\n', ul)
47
+        ..insert('Two')
48
+        ..insert('\n', ul)
49
+        ..insert('Three!\n');
50
+      final actual = rule.apply(doc, 7, 0, NotusAttribute.ul);
51
+      final expected = new Delta()..retain(9)..retain(1, ul);
52
+      expect(actual, expected);
53
+    });
54
+  });
55
+
56
+  group('$ResolveInlineFormatRule', () {
57
+    final rule = new ResolveInlineFormatRule();
58
+
59
+    test('apply', () {
60
+      final doc = new Delta()..insert('Correct\nLine\nStyle\nRule\n');
61
+
62
+      final actual = rule.apply(doc, 0, 20, NotusAttribute.bold);
63
+      expect(actual, isNotNull);
64
+      final b = NotusAttribute.bold.toJson();
65
+      final expected = new Delta()
66
+        ..retain(7, b)
67
+        ..retain(1)
68
+        ..retain(4, b)
69
+        ..retain(1)
70
+        ..retain(5, b)
71
+        ..retain(1)
72
+        ..retain(1, b);
73
+      expect(actual, expected);
74
+    });
75
+  });
76
+
77
+  group('$FormatLinkAtCaretPositionRule', () {
78
+    final rule = new FormatLinkAtCaretPositionRule();
79
+
80
+    test('apply', () {
81
+      final link =
82
+          NotusAttribute.link.fromString('https://github.com/memspace/bold');
83
+      final newLink =
84
+          NotusAttribute.link.fromString('https://github.com/memspace/zefyr');
85
+      final doc = new Delta()
86
+        ..insert('Visit our ')
87
+        ..insert('website', link.toJson())
88
+        ..insert(' for more details.\n');
89
+
90
+      final actual = rule.apply(doc, 13, 0, newLink);
91
+      expect(actual, isNotNull);
92
+      final expected = new Delta()..retain(10)..retain(7, newLink.toJson());
93
+      expect(actual, expected);
94
+    });
95
+  });
96
+}

+ 215
- 0
packages/notus/test/heuristics/insert_rules_test.dart Wyświetl plik

@@ -0,0 +1,215 @@
1
+// Copyright (c) 2018, the Zefyr project authors.  Please see the AUTHORS file
2
+// for details. All rights reserved. Use of this source code is governed by a
3
+// BSD-style license that can be found in the LICENSE file.
4
+import 'package:test/test.dart';
5
+import 'package:quill_delta/quill_delta.dart';
6
+import 'package:notus/notus.dart';
7
+
8
+final ul = NotusAttribute.ul.toJson();
9
+final bold = NotusAttribute.bold.toJson();
10
+
11
+void main() {
12
+  group('$CatchAllInsertRule', () {
13
+    final rule = new CatchAllInsertRule();
14
+
15
+    test('applies change as-is', () {
16
+      final doc = new Delta()..insert('Document\n');
17
+      final actual = rule.apply(doc, 8, '!');
18
+      final expected = new Delta()
19
+        ..retain(8)
20
+        ..insert('!');
21
+      expect(actual, expected);
22
+    });
23
+  });
24
+
25
+  group('$PreserveLineStyleOnSplitRule', () {
26
+    final rule = new PreserveLineStyleOnSplitRule();
27
+
28
+    test('skips at the beginning of a document', () {
29
+      final doc = new Delta()..insert('One\n');
30
+      final actual = rule.apply(doc, 0, '\n');
31
+      expect(actual, isNull);
32
+    });
33
+
34
+    test('applies in a block', () {
35
+      final doc = new Delta()
36
+        ..insert('One and two')
37
+        ..insert('\n', ul)
38
+        ..insert('Three')
39
+        ..insert('\n', ul);
40
+      final actual = rule.apply(doc, 8, '\n');
41
+      final expected = new Delta()
42
+        ..retain(8)
43
+        ..insert('\n', ul);
44
+      expect(actual, isNotNull);
45
+      expect(actual, expected);
46
+    });
47
+  });
48
+
49
+  group('$ResetLineFormatOnNewLineRule', () {
50
+    final rule = const ResetLineFormatOnNewLineRule();
51
+
52
+    test('applies when line-break is inserted at the end of line', () {
53
+      final doc = new Delta()
54
+        ..insert('Hello world')
55
+        ..insert('\n', NotusAttribute.h1.toJson());
56
+      final actual = rule.apply(doc, 11, '\n');
57
+      expect(actual, isNotNull);
58
+      final expected = new Delta()
59
+        ..retain(11)
60
+        ..insert('\n', NotusAttribute.h1.toJson())
61
+        ..retain(1, NotusAttribute.heading.unset.toJson());
62
+      expect(actual, expected);
63
+    });
64
+
65
+    test('applies without style reset if not needed', () {
66
+      final doc = new Delta()..insert('Hello world\n');
67
+      final actual = rule.apply(doc, 11, '\n');
68
+      expect(actual, isNotNull);
69
+      final expected = new Delta()
70
+        ..retain(11)
71
+        ..insert('\n');
72
+      expect(actual, expected);
73
+    });
74
+
75
+    test('applies at the beginning of a document', () {
76
+      final doc = new Delta()
77
+        ..insert('\n', NotusAttribute.h1.toJson());
78
+      final actual = rule.apply(doc, 0, '\n');
79
+      expect(actual, isNotNull);
80
+      final expected = new Delta()
81
+        ..insert('\n', NotusAttribute.h1.toJson())
82
+        ..retain(1, NotusAttribute.heading.unset.toJson());
83
+      expect(actual, expected);
84
+    });
85
+
86
+    test('applies and keeps block style', () {
87
+      final style = NotusAttribute.ul.toJson();
88
+      style.addAll(NotusAttribute.h1.toJson());
89
+      final doc = new Delta()..insert('Hello world')..insert('\n', style);
90
+      final actual = rule.apply(doc, 11, '\n');
91
+      expect(actual, isNotNull);
92
+      final expected = new Delta()
93
+        ..retain(11)
94
+        ..insert('\n', style)
95
+        ..retain(1, NotusAttribute.heading.unset.toJson());
96
+      expect(actual, expected);
97
+    });
98
+
99
+    test('applies to a line in the middle of a document', () {
100
+      final doc = new Delta()
101
+        ..insert('Hello \nworld!\nMore lines here.')
102
+        ..insert('\n', NotusAttribute.h2.toJson());
103
+      final actual = rule.apply(doc, 30, '\n');
104
+      expect(actual, isNotNull);
105
+      final expected = new Delta()
106
+        ..retain(30)
107
+        ..insert('\n', NotusAttribute.h2.toJson())
108
+        ..retain(1, NotusAttribute.heading.unset.toJson());
109
+      expect(actual, expected);
110
+    });
111
+  });
112
+
113
+  group('$AutoExitBlockRule', () {
114
+    final rule = new AutoExitBlockRule();
115
+
116
+    test('applies for new line-break on empty line in a block', () {
117
+      final ul = NotusAttribute.ul.toJson();
118
+      final doc = new Delta()
119
+        ..insert('Item 1')
120
+        ..insert('\n', ul)
121
+        ..insert('Item 2')
122
+        ..insert('\n\n', ul);
123
+      final actual = rule.apply(doc, 14, '\n');
124
+      expect(actual, isNotNull);
125
+      final expected = new Delta()
126
+        ..retain(14)
127
+        ..retain(1, NotusAttribute.block.unset.toJson());
128
+      expect(actual, expected);
129
+    });
130
+
131
+    test('applies only on empty line', () {
132
+      final ul = NotusAttribute.ul.toJson();
133
+      final doc = new Delta()..insert('Item 1')..insert('\n', ul);
134
+      final actual = rule.apply(doc, 6, '\n');
135
+      expect(actual, isNull);
136
+    });
137
+
138
+    test('applies at the beginning of a document', () {
139
+      final ul = NotusAttribute.ul.toJson();
140
+      final doc = new Delta()..insert('\n', ul);
141
+      final actual = rule.apply(doc, 0, '\n');
142
+      expect(actual, isNotNull);
143
+      final expected = new Delta()
144
+        ..retain(1, NotusAttribute.block.unset.toJson());
145
+      expect(actual, expected);
146
+    });
147
+
148
+    test('ignores non-empty line at the beginning of a document', () {
149
+      final ul = NotusAttribute.ul.toJson();
150
+      final doc = new Delta()..insert('Text\n', ul);
151
+      final actual = rule.apply(doc, 0, '\n');
152
+      expect(actual, isNull);
153
+    });
154
+  });
155
+
156
+  group('$PreserveInlineStylesRule', () {
157
+    final rule = new PreserveInlineStylesRule();
158
+    test('apply', () {
159
+      final doc = new Delta()
160
+        ..insert('Doc with ')
161
+        ..insert('bold', bold)
162
+        ..insert(' text');
163
+      final actual = rule.apply(doc, 13, 'er');
164
+      final expected = new Delta()
165
+        ..retain(13)
166
+        ..insert('er', bold);
167
+      expect(expected, actual);
168
+    });
169
+
170
+    test('apply at the beginning of a document', () {
171
+      final doc = new Delta()..insert('Doc with ');
172
+      final actual = rule.apply(doc, 0, 'A ');
173
+      expect(actual, isNull);
174
+    });
175
+  });
176
+
177
+  group('$AutoFormatLinksRule', () {
178
+    final rule = new AutoFormatLinksRule();
179
+    final link = NotusAttribute.link.fromString('https://example.com').toJson();
180
+
181
+    test('apply simple', () {
182
+      final doc = new Delta()..insert('Doc with link https://example.com');
183
+      final actual = rule.apply(doc, 33, ' ');
184
+      final expected = new Delta()
185
+        ..retain(14)
186
+        ..retain(19, link)
187
+        ..insert(' ');
188
+      expect(expected, actual);
189
+    });
190
+
191
+    test('applies only to insert of single space', () {
192
+      final doc = new Delta()..insert('Doc with link https://example.com');
193
+      final actual = rule.apply(doc, 33, '/');
194
+      expect(actual, isNull);
195
+    });
196
+
197
+    test('applies for links at the beginning of line', () {
198
+      final doc = new Delta()..insert('Doc with link\nhttps://example.com');
199
+      final actual = rule.apply(doc, 33, ' ');
200
+      final expected = new Delta()
201
+        ..retain(14)
202
+        ..retain(19, link)
203
+        ..insert(' ');
204
+      expect(expected, actual);
205
+    });
206
+
207
+    test('ignores if already formatted as link', () {
208
+      final doc = new Delta()
209
+        ..insert('Doc with link\n')
210
+        ..insert('https://example.com', link);
211
+      final actual = rule.apply(doc, 33, ' ');
212
+      expect(actual, isNull);
213
+    });
214
+  });
215
+}

+ 40
- 0
packages/notus/test/heuristics_test.dart Wyświetl plik

@@ -0,0 +1,40 @@
1
+// Copyright (c) 2018, the Zefyr project authors.  Please see the AUTHORS file
2
+// for details. All rights reserved. Use of this source code is governed by a
3
+// BSD-style license that can be found in the LICENSE file.
4
+
5
+import 'package:notus/notus.dart';
6
+import 'package:quill_delta/quill_delta.dart';
7
+import 'package:test/test.dart';
8
+
9
+NotusDocument dartconfDoc() {
10
+  Delta delta = new Delta()..insert('DartConf\nLos Angeles\n');
11
+  return new NotusDocument.fromDelta(delta);
12
+}
13
+
14
+final ul = NotusAttribute.ul.toJson();
15
+final h1 = NotusAttribute.h1.toJson();
16
+
17
+void main() {
18
+  group('$NotusHeuristics', () {
19
+    test('ensures heuristics are applied', () {
20
+      final doc = dartconfDoc();
21
+      final heuristics = new NotusHeuristics(
22
+        formatRules: [],
23
+        insertRules: [],
24
+        deleteRules: [],
25
+      );
26
+
27
+      expect(() {
28
+        heuristics.applyInsertRules(doc, 0, 'a');
29
+      }, throwsStateError);
30
+
31
+      expect(() {
32
+        heuristics.applyDeleteRules(doc, 0, 1);
33
+      }, throwsStateError);
34
+
35
+      expect(() {
36
+        heuristics.applyFormatRules(doc, 0, 1, NotusAttribute.bold);
37
+      }, throwsStateError);
38
+    });
39
+  });
40
+}

+ 11
- 0
packages/notus/test/matchers.dart Wyświetl plik

@@ -0,0 +1,11 @@
1
+// Copyright (c) 2018, the Zefyr project authors.  Please see the AUTHORS file
2
+// for details. All rights reserved. Use of this source code is governed by a
3
+// BSD-style license that can be found in the LICENSE file.
4
+
5
+import 'package:test/test.dart';
6
+
7
+const isAssertionError = const TypeMatcher<AssertionError>();
8
+
9
+const Matcher throwsAssertionError =
10
+    // ignore: deprecated_member_use
11
+    const Throws(isAssertionError);