Browse Source

Fixes for embed-related rules

Anatoly Pulyaevskiy 6 years ago
parent
commit
cb886097ae

+ 1
- 1
packages/notus/lib/src/document.dart View File

@@ -130,7 +130,7 @@ class NotusDocument {
130 130
     assert(index >= 0 && length > 0);
131 131
     // TODO: need a heuristic rule to ensure last line-break.
132 132
     final change = _heuristics.applyDeleteRules(this, index, length);
133
-    compose(change, ChangeSource.local);
133
+    if (change.isNotEmpty) compose(change, ChangeSource.local);
134 134
     return change;
135 135
   }
136 136
 

+ 15
- 3
packages/notus/lib/src/document/line.dart View File

@@ -10,13 +10,25 @@ import 'block.dart';
10 10
 import 'leaf.dart';
11 11
 import 'node.dart';
12 12
 
13
-/// A line of text in a Notus document.
13
+/// A line of rich text in a Notus document.
14 14
 ///
15 15
 /// LineNode serves as a container for [LeafNode]s, like [TextNode] and
16
-/// [ImageNode].
16
+/// [EmbedNode].
17
+///
18
+/// When a line contains an embed, it fully occupies the line, no other embeds
19
+/// or text nodes are allowed.
17 20
 class LineNode extends ContainerNode<LeafNode>
18 21
     with StyledNodeMixin
19 22
     implements StyledNode {
23
+  /// Returns `true` if this line contains an embed.
24
+  bool get hasEmbed {
25
+    if (childCount == 1) {
26
+      return children.single is EmbedNode;
27
+    }
28
+    assert(children.every((child) => child is TextNode));
29
+    return false;
30
+  }
31
+
20 32
   /// Returns next [LineNode] or `null` if this is the last line in the document.
21 33
   LineNode get nextLine {
22 34
     if (isLast) {
@@ -112,7 +124,7 @@ class LineNode extends ContainerNode<LeafNode>
112 124
     }
113 125
 
114 126
     final data = lookup(offset, inclusive: true);
115
-    TextNode node = data.node;
127
+    LeafNode node = data.node;
116 128
     if (node != null) {
117 129
       result = result.mergeAll(node.style);
118 130
       int pos = node.length - data.offset;

+ 1
- 0
packages/notus/lib/src/heuristics.dart View File

@@ -32,6 +32,7 @@ class NotusHeuristics {
32 32
       CatchAllInsertRule(),
33 33
     ],
34 34
     deleteRules: [
35
+      EnsureEmbedLineRule(),
35 36
       PreserveLineStyleOnMergeRule(),
36 37
       CatchAllDeleteRule(),
37 38
     ],

+ 51
- 0
packages/notus/lib/src/heuristics/delete_rules.dart View File

@@ -3,6 +3,7 @@
3 3
 // BSD-style license that can be found in the LICENSE file.
4 4
 
5 5
 import 'package:quill_delta/quill_delta.dart';
6
+import 'package:notus/notus.dart';
6 7
 
7 8
 /// A heuristic rule for delete operations.
8 9
 abstract class DeleteRule {
@@ -71,3 +72,53 @@ class PreserveLineStyleOnMergeRule extends DeleteRule {
71 72
     return attributes.map((key, value) => new MapEntry(key, null));
72 73
   }
73 74
 }
75
+
76
+/// Prevents user from merging line containing an embed with other lines.
77
+class EnsureEmbedLineRule extends DeleteRule {
78
+  const EnsureEmbedLineRule();
79
+
80
+  @override
81
+  Delta apply(Delta document, int index, int length) {
82
+    DeltaIterator iter = new DeltaIterator(document);
83
+    // First, check if line-break deleted after an embed.
84
+    Operation op = iter.skip(index);
85
+    int indexDelta = 0;
86
+    int lengthDelta = 0;
87
+    int remaining = length;
88
+    bool foundEmbed = false;
89
+    if (op != null && op.data.endsWith(kZeroWidthSpace)) {
90
+      foundEmbed = true;
91
+      Operation candidate = iter.next(1);
92
+      remaining--;
93
+      if (candidate.data == '\n') {
94
+        indexDelta += 1;
95
+        lengthDelta -= 1;
96
+
97
+        /// Check if it's an empty line
98
+        candidate = iter.next(1);
99
+        remaining--;
100
+        if (candidate.data == '\n') {
101
+          // Allow deleting empty line after an embed.
102
+          lengthDelta += 1;
103
+        }
104
+      }
105
+    }
106
+
107
+    // Second, check if line-break deleted before an embed.
108
+    op = iter.skip(remaining);
109
+    if (op != null && op.data.endsWith('\n')) {
110
+      final candidate = iter.next(1);
111
+      if (candidate.data == kZeroWidthSpace) {
112
+        foundEmbed = true;
113
+        lengthDelta -= 1;
114
+      }
115
+    }
116
+    if (foundEmbed) {
117
+      return new Delta()
118
+        ..retain(index + indexDelta)
119
+        ..delete(length + lengthDelta);
120
+    }
121
+
122
+    return null; // fallback
123
+  }
124
+}

+ 1
- 1
packages/notus/pubspec.yaml View File

@@ -5,7 +5,7 @@ author: Memspace <hello@memspace.app>
5 5
 homepage: https://github.com/memspace/notus
6 6
 
7 7
 environment:
8
-  sdk: '>=2.0.0-dev.64.0 <2.0.0'
8
+  sdk: '>=2.0.0-dev.63.0 <2.0.0'
9 9
 
10 10
 dependencies:
11 11
   collection: ^1.14.6

+ 21
- 2
packages/notus/test/document/line_test.dart View File

@@ -26,6 +26,13 @@ void main() {
26 26
       expect(node.toDelta().toList(), [new Operation.insert('\n')]);
27 27
     });
28 28
 
29
+    test('hasEmbed', () {
30
+      LineNode node = new LineNode();
31
+      expect(node.hasEmbed, isFalse);
32
+      node.add(new EmbedNode());
33
+      expect(node.hasEmbed, isTrue);
34
+    });
35
+
29 36
     test('nextLine', () {
30 37
       root.insert(
31 38
           0, 'Hello world\nThis is my first multiline\nItem\ndocument.', null);
@@ -50,7 +57,7 @@ void main() {
50 57
       expect('$node', '¶ ⟨London "Grammar"⟩b → ⟨ - Hey Now⟩ ⏎ {heading: 1}');
51 58
     });
52 59
 
53
-    test('splitAt with multiple text segments', (){
60
+    test('splitAt with multiple text segments', () {
54 61
       root.insert(0, 'This house is a circus', null);
55 62
       root.retain(0, 4, boldStyle);
56 63
       root.retain(16, 6, boldStyle);
@@ -92,7 +99,7 @@ void main() {
92 99
       expect(node.toDelta(), new Delta()..insert('Hello world!!!\n'));
93 100
     });
94 101
 
95
-    test('insert text with a line-break at the end of line', () {
102
+    test('insert text with line-break at the end of line', () {
96 103
       root.insert(0, 'Hello world', null);
97 104
       root.insert(11, '!!!\n', null);
98 105
       expect(root.childCount, 2);
@@ -260,5 +267,17 @@ void main() {
260 267
       var attrs = line.collectStyle(result.offset, 5);
261 268
       expect(attrs, h2Style);
262 269
     });
270
+
271
+    test('collectStyle with embed nodes', () {
272
+      root.insert(0, 'Hello world\n\nMore text.\n', null);
273
+      NotusStyle style = new NotusStyle();
274
+      style = style.put(NotusAttribute.embed.horizontalRule);
275
+      root.insert(12, EmbedNode.kPlainTextPlaceholder, style);
276
+
277
+      var lookup = root.lookup(0);
278
+      LineNode line = lookup.node;
279
+      var result = line.collectStyle(lookup.offset, 15);
280
+      expect(result, isEmpty);
281
+    });
263 282
   });
264 283
 }

+ 21
- 3
packages/notus/test/document_test.dart View File

@@ -13,6 +13,16 @@ NotusDocument dartconfDoc() {
13 13
   return new NotusDocument.fromDelta(delta);
14 14
 }
15 15
 
16
+NotusDocument dartconfEmbedDoc() {
17
+  final hr = NotusAttribute.embed.horizontalRule.toJson();
18
+  Delta delta = new Delta()
19
+    ..insert('DartConf\n')
20
+    ..insert(kZeroWidthSpace, hr)
21
+    ..insert('\n')
22
+    ..insert('Los Angeles\n');
23
+  return new NotusDocument.fromDelta(delta);
24
+}
25
+
16 26
 final ul = NotusAttribute.ul.toJson();
17 27
 final h1 = NotusAttribute.h1.toJson();
18 28
 
@@ -56,7 +66,7 @@ void main() {
56 66
       expect(doc.toPlainText(), 'DartConf\nLos Angeles\n');
57 67
     });
58 68
 
59
-    test('document delta must end with line-break', () {
69
+    test('document delta must end with line-break character', () {
60 70
       Delta delta = new Delta()..insert('DartConf\nLos Angeles');
61 71
       expect(() {
62 72
         new NotusDocument.fromDelta(delta);
@@ -195,6 +205,14 @@ void main() {
195 205
       expect(line.style.get(NotusAttribute.heading), NotusAttribute.h1);
196 206
     });
197 207
 
208
+    test('delete which results in an empty change', () {
209
+      // This test relies on a delete rule which ensures line-breaks around
210
+      // and embed.
211
+      NotusDocument doc = dartconfEmbedDoc();
212
+      doc.delete(8, 1);
213
+      expect(doc.toPlainText(), 'DartConf\n${kZeroWidthSpace}\nLos Angeles\n');
214
+    });
215
+
198 216
     test('checks for closed state', () {
199 217
       final doc = dartconfDoc();
200 218
       expect(doc.isClosed, isFalse);
@@ -220,7 +238,7 @@ void main() {
220 238
       expect(style, isNotNull);
221 239
     });
222 240
 
223
-    test('insert embed after newline', () {
241
+    test('insert embed after line-break', () {
224 242
       final doc = dartconfDoc();
225 243
       doc.insert(9, const HorizontalRuleEmbed());
226 244
       expect(doc.root.children, hasLength(3));
@@ -233,7 +251,7 @@ void main() {
233 251
       expect(embed.style, style);
234 252
     });
235 253
 
236
-    test('insert embed before newline', () {
254
+    test('insert embed before line-break', () {
237 255
       final doc = dartconfDoc();
238 256
       doc.insert(8, const HorizontalRuleEmbed());
239 257
       expect(doc.root.children, hasLength(3));

+ 57
- 0
packages/notus/test/heuristics/delete_rules_test.dart View File

@@ -54,4 +54,61 @@ void main() {
54 54
       expect(actual, expected);
55 55
     });
56 56
   });
57
+
58
+  group('$EnsureEmbedLineRule', () {
59
+    final rule = new EnsureEmbedLineRule();
60
+
61
+    test('ensures line-break before embed', () {
62
+      final hr = NotusAttribute.embed.horizontalRule;
63
+      final doc = new Delta()
64
+        ..insert('Document\n')
65
+        ..insert(kZeroWidthSpace, hr.toJson())
66
+        ..insert('\n');
67
+      final actual = rule.apply(doc, 8, 1);
68
+      final expected = new Delta()..retain(8);
69
+      expect(actual, expected);
70
+    });
71
+
72
+    test('ensures line-break after embed', () {
73
+      final hr = NotusAttribute.embed.horizontalRule;
74
+      final doc = new Delta()
75
+        ..insert('Document\n')
76
+        ..insert(kZeroWidthSpace, hr.toJson())
77
+        ..insert('\n');
78
+      final actual = rule.apply(doc, 10, 1);
79
+      final expected = new Delta()..retain(11);
80
+      expect(actual, expected);
81
+    });
82
+
83
+    test('still deletes everything between embeds', () {
84
+      final hr = NotusAttribute.embed.horizontalRule;
85
+      final doc = new Delta()
86
+        ..insert('Document\n')
87
+        ..insert(kZeroWidthSpace, hr.toJson())
88
+        ..insert('\nSome text\n')
89
+        ..insert(kZeroWidthSpace, hr.toJson())
90
+        ..insert('\n');
91
+      final actual = rule.apply(doc, 10, 11);
92
+      final expected = new Delta()
93
+        ..retain(11)
94
+        ..delete(9);
95
+      expect(actual, expected);
96
+    });
97
+
98
+    test('allows deleting empty line after embed', () {
99
+      final hr = NotusAttribute.embed.horizontalRule;
100
+      final doc = new Delta()
101
+        ..insert('Document\n')
102
+        ..insert(kZeroWidthSpace, hr.toJson())
103
+        ..insert('\n')
104
+        ..insert('\n', NotusAttribute.block.bulletList.toJson())
105
+        ..insert('Text')
106
+        ..insert('\n');
107
+      final actual = rule.apply(doc, 10, 1);
108
+      final expected = new Delta()
109
+        ..retain(11)
110
+        ..delete(1);
111
+      expect(actual, expected);
112
+    });
113
+  });
57 114
 }

+ 1
- 1
packages/notus/test/heuristics/insert_rules_test.dart View File

@@ -113,7 +113,7 @@ void main() {
113 113
   group('$AutoExitBlockRule', () {
114 114
     final rule = new AutoExitBlockRule();
115 115
 
116
-    test('applies for new line-break on empty line in a block', () {
116
+    test('applies when line-break is inserted on empty line in a block', () {
117 117
       final ul = NotusAttribute.ul.toJson();
118 118
       final doc = new Delta()
119 119
         ..insert('Item 1')