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

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

10
 import 'leaf.dart';
10
 import 'leaf.dart';
11
 import 'node.dart';
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
 /// LineNode serves as a container for [LeafNode]s, like [TextNode] and
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
 class LineNode extends ContainerNode<LeafNode>
20
 class LineNode extends ContainerNode<LeafNode>
18
     with StyledNodeMixin
21
     with StyledNodeMixin
19
     implements StyledNode {
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
   /// Returns next [LineNode] or `null` if this is the last line in the document.
32
   /// Returns next [LineNode] or `null` if this is the last line in the document.
21
   LineNode get nextLine {
33
   LineNode get nextLine {
22
     if (isLast) {
34
     if (isLast) {
112
     }
124
     }
113
 
125
 
114
     final data = lookup(offset, inclusive: true);
126
     final data = lookup(offset, inclusive: true);
115
-    TextNode node = data.node;
127
+    LeafNode node = data.node;
116
     if (node != null) {
128
     if (node != null) {
117
       result = result.mergeAll(node.style);
129
       result = result.mergeAll(node.style);
118
       int pos = node.length - data.offset;
130
       int pos = node.length - data.offset;

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

32
       CatchAllInsertRule(),
32
       CatchAllInsertRule(),
33
     ],
33
     ],
34
     deleteRules: [
34
     deleteRules: [
35
+      EnsureEmbedLineRule(),
35
       PreserveLineStyleOnMergeRule(),
36
       PreserveLineStyleOnMergeRule(),
36
       CatchAllDeleteRule(),
37
       CatchAllDeleteRule(),
37
     ],
38
     ],

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

3
 // BSD-style license that can be found in the LICENSE file.
3
 // BSD-style license that can be found in the LICENSE file.
4
 
4
 
5
 import 'package:quill_delta/quill_delta.dart';
5
 import 'package:quill_delta/quill_delta.dart';
6
+import 'package:notus/notus.dart';
6
 
7
 
7
 /// A heuristic rule for delete operations.
8
 /// A heuristic rule for delete operations.
8
 abstract class DeleteRule {
9
 abstract class DeleteRule {
71
     return attributes.map((key, value) => new MapEntry(key, null));
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
 homepage: https://github.com/memspace/notus
5
 homepage: https://github.com/memspace/notus
6
 
6
 
7
 environment:
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
 dependencies:
10
 dependencies:
11
   collection: ^1.14.6
11
   collection: ^1.14.6

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

26
       expect(node.toDelta().toList(), [new Operation.insert('\n')]);
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
     test('nextLine', () {
36
     test('nextLine', () {
30
       root.insert(
37
       root.insert(
31
           0, 'Hello world\nThis is my first multiline\nItem\ndocument.', null);
38
           0, 'Hello world\nThis is my first multiline\nItem\ndocument.', null);
50
       expect('$node', '¶ ⟨London "Grammar"⟩b → ⟨ - Hey Now⟩ ⏎ {heading: 1}');
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
       root.insert(0, 'This house is a circus', null);
61
       root.insert(0, 'This house is a circus', null);
55
       root.retain(0, 4, boldStyle);
62
       root.retain(0, 4, boldStyle);
56
       root.retain(16, 6, boldStyle);
63
       root.retain(16, 6, boldStyle);
92
       expect(node.toDelta(), new Delta()..insert('Hello world!!!\n'));
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
       root.insert(0, 'Hello world', null);
103
       root.insert(0, 'Hello world', null);
97
       root.insert(11, '!!!\n', null);
104
       root.insert(11, '!!!\n', null);
98
       expect(root.childCount, 2);
105
       expect(root.childCount, 2);
260
       var attrs = line.collectStyle(result.offset, 5);
267
       var attrs = line.collectStyle(result.offset, 5);
261
       expect(attrs, h2Style);
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
   return new NotusDocument.fromDelta(delta);
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
 final ul = NotusAttribute.ul.toJson();
26
 final ul = NotusAttribute.ul.toJson();
17
 final h1 = NotusAttribute.h1.toJson();
27
 final h1 = NotusAttribute.h1.toJson();
18
 
28
 
56
       expect(doc.toPlainText(), 'DartConf\nLos Angeles\n');
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
       Delta delta = new Delta()..insert('DartConf\nLos Angeles');
70
       Delta delta = new Delta()..insert('DartConf\nLos Angeles');
61
       expect(() {
71
       expect(() {
62
         new NotusDocument.fromDelta(delta);
72
         new NotusDocument.fromDelta(delta);
195
       expect(line.style.get(NotusAttribute.heading), NotusAttribute.h1);
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
     test('checks for closed state', () {
216
     test('checks for closed state', () {
199
       final doc = dartconfDoc();
217
       final doc = dartconfDoc();
200
       expect(doc.isClosed, isFalse);
218
       expect(doc.isClosed, isFalse);
220
       expect(style, isNotNull);
238
       expect(style, isNotNull);
221
     });
239
     });
222
 
240
 
223
-    test('insert embed after newline', () {
241
+    test('insert embed after line-break', () {
224
       final doc = dartconfDoc();
242
       final doc = dartconfDoc();
225
       doc.insert(9, const HorizontalRuleEmbed());
243
       doc.insert(9, const HorizontalRuleEmbed());
226
       expect(doc.root.children, hasLength(3));
244
       expect(doc.root.children, hasLength(3));
233
       expect(embed.style, style);
251
       expect(embed.style, style);
234
     });
252
     });
235
 
253
 
236
-    test('insert embed before newline', () {
254
+    test('insert embed before line-break', () {
237
       final doc = dartconfDoc();
255
       final doc = dartconfDoc();
238
       doc.insert(8, const HorizontalRuleEmbed());
256
       doc.insert(8, const HorizontalRuleEmbed());
239
       expect(doc.root.children, hasLength(3));
257
       expect(doc.root.children, hasLength(3));

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

54
       expect(actual, expected);
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
   group('$AutoExitBlockRule', () {
113
   group('$AutoExitBlockRule', () {
114
     final rule = new AutoExitBlockRule();
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
       final ul = NotusAttribute.ul.toJson();
117
       final ul = NotusAttribute.ul.toJson();
118
       final doc = new Delta()
118
       final doc = new Delta()
119
         ..insert('Item 1')
119
         ..insert('Item 1')