瀏覽代碼

update flowy_editor models

appflowy 3 年之前
父節點
當前提交
2a42fd108c

+ 104 - 93
app_flowy/packages/flowy_editor/lib/src/model/document/attribute.dart

@@ -1,3 +1,5 @@
+import 'dart:collection';
+
 import 'package:quiver/core.dart';
 
 enum AttributeScope {
@@ -14,9 +16,10 @@ class Attribute<T> {
   final AttributeScope scope;
   final T value;
 
-  static final Map<String, Attribute> _registry = {
+  static final Map<String, Attribute> _registry = LinkedHashMap.of({
     Attribute.bold.key: Attribute.bold,
     Attribute.italic.key: Attribute.italic,
+    Attribute.small.key: Attribute.small,
     Attribute.underline.key: Attribute.underline,
     Attribute.strikeThrough.key: Attribute.strikeThrough,
     Attribute.font.key: Attribute.font,
@@ -26,23 +29,23 @@ class Attribute<T> {
     Attribute.background.key: Attribute.background,
     Attribute.placeholder.key: Attribute.placeholder,
     Attribute.header.key: Attribute.header,
-    Attribute.indent.key: Attribute.indent,
     Attribute.align.key: Attribute.align,
     Attribute.list.key: Attribute.list,
     Attribute.codeBlock.key: Attribute.codeBlock,
     Attribute.quoteBlock.key: Attribute.quoteBlock,
+    Attribute.indent.key: Attribute.indent,
     Attribute.width.key: Attribute.width,
     Attribute.height.key: Attribute.height,
     Attribute.style.key: Attribute.style,
     Attribute.token.key: Attribute.token,
-  };
-
-  // Attribute Properties
+  });
 
   static final BoldAttribute bold = BoldAttribute();
 
   static final ItalicAttribute italic = ItalicAttribute();
 
+  static final SmallAttribute small = SmallAttribute();
+
   static final UnderlineAttribute underline = UnderlineAttribute();
 
   static final StrikeThroughAttribute strikeThrough = StrikeThroughAttribute();
@@ -79,106 +82,121 @@ class Attribute<T> {
 
   static final TokenAttribute token = TokenAttribute('');
 
-  static Attribute<int?> get h1 => HeaderAttribute(level: 1);
+  static final Set<String> inlineKeys = {
+    Attribute.bold.key,
+    Attribute.italic.key,
+    Attribute.small.key,
+    Attribute.underline.key,
+    Attribute.strikeThrough.key,
+    Attribute.link.key,
+    Attribute.color.key,
+    Attribute.background.key,
+    Attribute.placeholder.key,
+  };
 
-  static Attribute<int?> get h2 => HeaderAttribute(level: 2);
+  static final Set<String> blockKeys = LinkedHashSet.of({
+    Attribute.header.key,
+    Attribute.align.key,
+    Attribute.list.key,
+    Attribute.codeBlock.key,
+    Attribute.quoteBlock.key,
+    Attribute.indent.key,
+  });
 
-  static Attribute<int?> get h3 => HeaderAttribute(level: 3);
+  static final Set<String> blockKeysExceptHeader = LinkedHashSet.of({
+    Attribute.list.key,
+    Attribute.align.key,
+    Attribute.codeBlock.key,
+    Attribute.quoteBlock.key,
+    Attribute.indent.key,
+  });
 
-  static Attribute<int?> get h4 => HeaderAttribute(level: 4);
+  static final Set<String> exclusiveBlockKeys = LinkedHashSet.of({
+    Attribute.header.key,
+    Attribute.list.key,
+    Attribute.codeBlock.key,
+    Attribute.quoteBlock.key,
+  });
 
-  static Attribute<int?> get h5 => HeaderAttribute(level: 5);
+  static Attribute<int?> get h1 => HeaderAttribute(level: 1);
+
+  static Attribute<int?> get h2 => HeaderAttribute(level: 2);
 
-  static Attribute<int?> get h6 => HeaderAttribute(level: 6);
+  static Attribute<int?> get h3 => HeaderAttribute(level: 3);
 
+  // "attributes":{"align":"left"}
   static Attribute<String?> get leftAlignment => AlignAttribute('left');
 
+  // "attributes":{"align":"center"}
   static Attribute<String?> get centerAlignment => AlignAttribute('center');
 
+  // "attributes":{"align":"right"}
   static Attribute<String?> get rightAlignment => AlignAttribute('right');
 
+  // "attributes":{"align":"justify"}
   static Attribute<String?> get justifyAlignment => AlignAttribute('justify');
 
+  // "attributes":{"list":"bullet"}
   static Attribute<String?> get bullet => ListAttribute('bullet');
 
+  // "attributes":{"list":"ordered"}
   static Attribute<String?> get ordered => ListAttribute('ordered');
 
+  // "attributes":{"list":"checked"}
   static Attribute<String?> get checked => ListAttribute('checked');
 
+  // "attributes":{"list":"unchecked"}
   static Attribute<String?> get unchecked => ListAttribute('unchecked');
 
+  // "attributes":{"indent":1"}
   static Attribute<int?> get indentL1 => IndentAttribute(level: 1);
 
+  // "attributes":{"indent":2"}
   static Attribute<int?> get indentL2 => IndentAttribute(level: 2);
 
+  // "attributes":{"indent":3"}
   static Attribute<int?> get indentL3 => IndentAttribute(level: 3);
 
-  static Attribute<int?> get indentL4 => IndentAttribute(level: 4);
-
-  static Attribute<int?> get indentL5 => IndentAttribute(level: 5);
-
-  static Attribute<int?> get indentL6 => IndentAttribute(level: 6);
-
   static Attribute<int?> getIndentLevel(int? level) {
-    switch (level) {
-      case 1:
-        return indentL1;
-      case 2:
-        return indentL2;
-      case 3:
-        return indentL3;
-      case 4:
-        return indentL4;
-      case 5:
-        return indentL5;
-      default:
-        return indentL6;
+    if (level == 1) {
+      return indentL1;
+    }
+    if (level == 2) {
+      return indentL2;
+    }
+    if (level == 3) {
+      return indentL3;
     }
+    return IndentAttribute(level: level);
   }
 
-  // Keys Container
-  static final Set<String> inlineKeys = {
-    Attribute.bold.key,
-    Attribute.italic.key,
-    Attribute.underline.key,
-    Attribute.strikeThrough.key,
-    Attribute.link.key,
-    Attribute.color.key,
-    Attribute.background.key,
-    Attribute.placeholder.key,
-  };
-
-  static final Set<String> blockKeys = {
-    Attribute.header.key,
-    Attribute.indent.key,
-    Attribute.align.key,
-    Attribute.list.key,
-    Attribute.codeBlock.key,
-    Attribute.quoteBlock.key,
-  };
-
-  static final Set<String> blockKeysExceptHeader = blockKeys
-    ..remove(Attribute.header.key);
-
-  // Utils
-
-  bool get isInline => AttributeScope.INLINE == scope;
-
-  bool get isIgnored => AttributeScope.IGNORE == scope;
+  bool get isInline => scope == AttributeScope.INLINE;
 
   bool get isBlockExceptHeader => blockKeysExceptHeader.contains(key);
 
   Map<String, dynamic> toJson() => <String, dynamic>{key: value};
 
-  static Attribute fromKeyValue(String key, dynamic value) {
-    if (!_registry.containsKey(key)) {
-      throw ArgumentError.value(key, 'key "$key" not found.');
+  static Attribute? fromKeyValue(String key, dynamic value) {
+    final origin = _registry[key];
+    if (origin == null) {
+      return null;
     }
-    final origin = _registry[key]!;
     final attribute = clone(origin, value);
     return attribute;
   }
 
+  static int getRegistryOrder(Attribute attribute) {
+    var order = 0;
+    for (final attr in _registry.values) {
+      if (attr.key == attribute.key) {
+        break;
+      }
+      order++;
+    }
+
+    return order;
+  }
+
   static Attribute clone(Attribute origin, dynamic value) {
     return Attribute(origin.key, origin.scope, value);
   }
@@ -186,7 +204,7 @@ class Attribute<T> {
   @override
   bool operator ==(Object other) {
     if (identical(this, other)) return true;
-    if (other is! Attribute<T>) return false;
+    if (other is! Attribute) return false;
     final typedOther = other;
     return key == typedOther.key &&
         scope == typedOther.scope &&
@@ -202,12 +220,6 @@ class Attribute<T> {
   }
 }
 
-/* -------------------------------------------------------------------------- */
-/*                               Attributes Impl                              */
-/* -------------------------------------------------------------------------- */
-
-/* --------------------------------- INLINE --------------------------------- */
-
 class BoldAttribute extends Attribute<bool> {
   BoldAttribute() : super('bold', AttributeScope.INLINE, true);
 }
@@ -216,42 +228,44 @@ class ItalicAttribute extends Attribute<bool> {
   ItalicAttribute() : super('italic', AttributeScope.INLINE, true);
 }
 
+class SmallAttribute extends Attribute<bool> {
+  SmallAttribute() : super('small', AttributeScope.INLINE, true);
+}
+
 class UnderlineAttribute extends Attribute<bool> {
   UnderlineAttribute() : super('underline', AttributeScope.INLINE, true);
 }
 
 class StrikeThroughAttribute extends Attribute<bool> {
-  StrikeThroughAttribute()
-      : super('strikethrough', AttributeScope.INLINE, true);
+  StrikeThroughAttribute() : super('strike', AttributeScope.INLINE, true);
 }
 
 class FontAttribute extends Attribute<String?> {
-  FontAttribute(String? value) : super('font', AttributeScope.INLINE, value);
+  FontAttribute(String? val) : super('font', AttributeScope.INLINE, val);
 }
 
 class SizeAttribute extends Attribute<String?> {
-  SizeAttribute(String? value) : super('size', AttributeScope.INLINE, value);
+  SizeAttribute(String? val) : super('size', AttributeScope.INLINE, val);
 }
 
 class LinkAttribute extends Attribute<String?> {
-  LinkAttribute(String? value) : super('link', AttributeScope.INLINE, value);
+  LinkAttribute(String? val) : super('link', AttributeScope.INLINE, val);
 }
 
 class ColorAttribute extends Attribute<String?> {
-  ColorAttribute(String? value) : super('color', AttributeScope.INLINE, value);
+  ColorAttribute(String? val) : super('color', AttributeScope.INLINE, val);
 }
 
 class BackgroundAttribute extends Attribute<String?> {
-  BackgroundAttribute(String? value)
-      : super('background', AttributeScope.INLINE, value);
+  BackgroundAttribute(String? val)
+      : super('background', AttributeScope.INLINE, val);
 }
 
-class PlaceholderAttribute extends Attribute<bool?> {
+/// This is custom attribute for hint
+class PlaceholderAttribute extends Attribute<bool> {
   PlaceholderAttribute() : super('placeholder', AttributeScope.INLINE, true);
 }
 
-/* ---------------------------------- BLOCK --------------------------------- */
-
 class HeaderAttribute extends Attribute<int?> {
   HeaderAttribute({int? level}) : super('header', AttributeScope.BLOCK, level);
 }
@@ -261,36 +275,33 @@ class IndentAttribute extends Attribute<int?> {
 }
 
 class AlignAttribute extends Attribute<String?> {
-  AlignAttribute(String? value) : super('align', AttributeScope.BLOCK, value);
+  AlignAttribute(String? val) : super('align', AttributeScope.BLOCK, val);
 }
 
 class ListAttribute extends Attribute<String?> {
-  ListAttribute(String? value) : super('list', AttributeScope.BLOCK, value);
+  ListAttribute(String? val) : super('list', AttributeScope.BLOCK, val);
 }
 
-class CodeBlockAttribute extends Attribute<bool?> {
-  CodeBlockAttribute() : super('code_block', AttributeScope.BLOCK, true);
+class CodeBlockAttribute extends Attribute<bool> {
+  CodeBlockAttribute() : super('code-block', AttributeScope.BLOCK, true);
 }
 
 class QuoteBlockAttribute extends Attribute<bool?> {
   QuoteBlockAttribute() : super('quote_block', AttributeScope.BLOCK, true);
 }
 
-/* --------------------------------- IGNORE --------------------------------- */
-
 class WidthAttribute extends Attribute<String?> {
-  WidthAttribute(String? value) : super('width', AttributeScope.IGNORE, value);
+  WidthAttribute(String? val) : super('width', AttributeScope.IGNORE, val);
 }
 
 class HeightAttribute extends Attribute<String?> {
-  HeightAttribute(String? value)
-      : super('height', AttributeScope.IGNORE, value);
+  HeightAttribute(String? val) : super('height', AttributeScope.IGNORE, val);
 }
 
 class StyleAttribute extends Attribute<String?> {
-  StyleAttribute(String? value) : super('style', AttributeScope.IGNORE, value);
+  StyleAttribute(String? val) : super('style', AttributeScope.IGNORE, val);
 }
 
-class TokenAttribute extends Attribute<String?> {
-  TokenAttribute(String? value) : super('token', AttributeScope.IGNORE, value);
+class TokenAttribute extends Attribute<String> {
+  TokenAttribute(String val) : super('token', AttributeScope.IGNORE, val);
 }

+ 74 - 69
app_flowy/packages/flowy_editor/lib/src/model/document/document.dart

@@ -21,7 +21,6 @@ abstract class EditorChangesetSender {
 /// The rich text document
 class Document {
   EditorChangesetSender? sender;
-
   Document({this.sender}) : _delta = Delta()..insert('\n') {
     _loadDocument(_delta);
   }
@@ -31,7 +30,7 @@ class Document {
   }
 
   Document.fromDelta(Delta delta) : _delta = delta {
-    _loadDocument(_delta);
+    _loadDocument(delta);
   }
 
   /// The root node of the document tree
@@ -47,6 +46,10 @@ class Document {
 
   final Rules _rules = Rules.getInstance();
 
+  void setCustomRules(List<Rule> customRules) {
+    _rules.setCustomRules(customRules);
+  }
+
   final StreamController<Tuple3<Delta, Delta, ChangeSource>> _observer =
       StreamController.broadcast();
 
@@ -54,12 +57,7 @@ class Document {
 
   Stream<Tuple3<Delta, Delta, ChangeSource>> get changes => _observer.stream;
 
-  bool get hasUndo => _history.hasUndo;
-
-  bool get hasRedo => _history.hasRedo;
-
   Delta insert(int index, Object? data, {int replaceLength = 0}) {
-    Log.trace('insert $data at $index');
     assert(index >= 0);
     assert(data is String || data is Embeddable);
     if (data is Embeddable) {
@@ -68,79 +66,71 @@ class Document {
       return Delta();
     }
 
-    final delta = _rules.apply(
-      RuleType.INSERT,
-      this,
-      index,
-      data: data,
-      length: replaceLength,
-    );
-
+    final delta = _rules.apply(RuleType.INSERT, this, index,
+        data: data, len: replaceLength);
     compose(delta, ChangeSource.LOCAL);
-    Log.trace('current document $_delta');
     return delta;
   }
 
-  Delta delete(int index, int length) {
-    Log.trace('delete $length at $index');
-    assert(index >= 0 && length > 0);
-    final delta = _rules.apply(RuleType.DELETE, this, index, length: length);
+  Delta delete(int index, int len) {
+    assert(index >= 0 && len > 0);
+    final delta = _rules.apply(RuleType.DELETE, this, index, len: len);
     if (delta.isNotEmpty) {
       compose(delta, ChangeSource.LOCAL);
     }
-    Log.trace('current document $_delta');
     return delta;
   }
 
-  Delta replace(int index, int length, Object? data) {
-    Log.trace('replace $length at $index with $data');
+  Delta replace(int index, int len, Object? data) {
     assert(index >= 0);
     assert(data is String || data is Embeddable);
 
     final dataIsNotEmpty = (data is String) ? data.isNotEmpty : true;
-    assert(dataIsNotEmpty || length > 0);
+
+    assert(dataIsNotEmpty || len > 0);
 
     var delta = Delta();
 
     // We have to insert before applying delete rules
     // Otherwise delete would be operating on stale document snapshot.
     if (dataIsNotEmpty) {
-      delta = insert(index, data, replaceLength: length);
+      delta = insert(index, data, replaceLength: len);
     }
 
-    if (length > 0) {
-      final deleteDelta = delete(index, length);
+    if (len > 0) {
+      final deleteDelta = delete(index, len);
       delta = delta.compose(deleteDelta);
     }
 
-    Log.trace('current document $delta');
     return delta;
   }
 
-  Delta format(int index, int length, Attribute? attribute) {
-    assert(index >= 0 && length >= 0 && attribute != null);
-    Log.trace('format $length at $index with $attribute');
+  Delta format(int index, int len, Attribute? attribute) {
+    assert(index >= 0 && len >= 0 && attribute != null);
+
     var delta = Delta();
 
-    final formatDelta = _rules.apply(
-      RuleType.FORMAT,
-      this,
-      index,
-      length: length,
-      attribute: attribute,
-    );
+    final formatDelta = _rules.apply(RuleType.FORMAT, this, index,
+        len: len, attribute: attribute);
     if (formatDelta.isNotEmpty) {
       compose(formatDelta, ChangeSource.LOCAL);
-      Log.trace('current document $_delta');
       delta = delta.compose(formatDelta);
     }
 
     return delta;
   }
 
-  Style collectStyle(int index, int length) {
+  /// Only attributes applied to all characters within this range are
+  /// included in the result.
+  Style collectStyle(int index, int len) {
+    final res = queryChild(index);
+    return (res.node as Line).collectStyle(res.offset, len);
+  }
+
+  /// Returns all styles for any character within the specified text range.
+  List<Style> collectAllStyles(int index, int len) {
     final res = queryChild(index);
-    return (res.node as Line).collectStyle(res.offset, length);
+    return (res.node as Line).collectAllStyles(res.offset, len);
   }
 
   ChildQuery queryChild(int offset) {
@@ -152,10 +142,6 @@ class Document {
     return block.queryChild(res.offset, true);
   }
 
-  Tuple2 undo() => _history.undo(this);
-
-  Tuple2 redo() => _history.redo(this);
-
   void compose(Delta delta, ChangeSource changeSource) {
     assert(!_observer.isClosed);
     delta.trim();
@@ -163,7 +149,7 @@ class Document {
 
     var offset = 0;
     delta = _transform(delta);
-    final originDelta = toDelta();
+    final originalDelta = toDelta();
     for (final op in delta.toList()) {
       final style =
           op.attributes != null ? Style.fromJson(op.attributes) : null;
@@ -180,7 +166,6 @@ class Document {
         offset += op.length!;
       }
     }
-
     try {
       final changeset = delta;
 
@@ -194,37 +179,55 @@ class Document {
     if (_delta != _root.toDelta()) {
       throw 'Compose failed';
     }
-    final change = Tuple3(originDelta, delta, changeSource);
+    final change = Tuple3(originalDelta, delta, changeSource);
     _observer.add(change);
     _history.handleDocChange(change);
   }
 
+  Tuple2 undo() {
+    return _history.undo(this);
+  }
+
+  Tuple2 redo() {
+    return _history.redo(this);
+  }
+
+  bool get hasUndo => _history.hasUndo;
+
+  bool get hasRedo => _history.hasRedo;
+
   static Delta _transform(Delta delta) {
     final res = Delta();
     final ops = delta.toList();
     for (var i = 0; i < ops.length; i++) {
       final op = ops[i];
       res.push(op);
-      _handleImageInsert(i, ops, op, res);
+      _autoAppendNewlineAfterEmbeddable(i, ops, op, res, 'video');
     }
     return res;
   }
 
-  static void _handleImageInsert(
-      int i, List<Operation> ops, Operation op, Delta res) {
-    final nextOpIsImage =
-        i + 1 < ops.length && ops[i + 1].isInsert && ops[i + 1].data is! String;
-    if (nextOpIsImage && !(op.data as String).endsWith('\n')) {
+  static void _autoAppendNewlineAfterEmbeddable(
+      int i, List<Operation> ops, Operation op, Delta res, String type) {
+    final nextOpIsEmbed = i + 1 < ops.length &&
+        ops[i + 1].isInsert &&
+        ops[i + 1].data is Map &&
+        (ops[i + 1].data as Map).containsKey(type);
+    if (nextOpIsEmbed &&
+        op.data is String &&
+        (op.data as String).isNotEmpty &&
+        !(op.data as String).endsWith('\n')) {
       res.push(Operation.insert('\n'));
     }
-    // Currently embed is equivalent to image and hence `is! String`
-    final opInsertImage = op.isInsert && op.data is! String;
+    // embed could be image or video
+    final opInsertEmbed =
+        op.isInsert && op.data is Map && (op.data as Map).containsKey(type);
     final nextOpIsLineBreak = i + 1 < ops.length &&
         ops[i + 1].isInsert &&
         ops[i + 1].data is String &&
         (ops[i + 1].data as String).startsWith('\n');
-    if (opInsertImage && (i + 1 == ops.length - 1 || !nextOpIsLineBreak)) {
-      // automatically append '\n' for image
+    if (opInsertEmbed && (i + 1 == ops.length - 1 || !nextOpIsLineBreak)) {
+      // automatically append '\n' for embeddable
       res.push(Operation.insert('\n'));
     }
   }
@@ -245,14 +248,20 @@ class Document {
     _history.clear();
   }
 
-  void _loadDocument(Delta delta) {
-    assert((delta.last.data as String).endsWith('\n'),
-        'Delta must ends with a line break.');
+  String toPlainText() => _root.children.map((e) => e.toPlainText()).join();
+
+  void _loadDocument(Delta doc) {
+    if (doc.isEmpty) {
+      throw ArgumentError.value(doc, 'Document Delta cannot be empty.');
+    }
+
+    assert((doc.last.data as String).endsWith('\n'));
+
     var offset = 0;
-    for (final op in delta.toList()) {
+    for (final op in doc.toList()) {
       if (!op.isInsert) {
-        throw ArgumentError.value(delta,
-            'Document Delta can only contain insert operations but ${op.key} found.');
+        throw ArgumentError.value(doc,
+            'Document can only contain insert operations but ${op.key} found.');
       }
       final style =
           op.attributes != null ? Style.fromJson(op.attributes) : null;
@@ -282,11 +291,7 @@ class Document {
     final delta = node.toDelta();
     return delta.length == 1 &&
         delta.first.data == '\n' &&
-        delta.first.key == Operation.insertKey;
-  }
-
-  String toPlainText() {
-    return root.children.map((child) => child.toPlainText()).join();
+        delta.first.key == 'insert';
   }
 }
 

+ 5 - 7
app_flowy/packages/flowy_editor/lib/src/model/document/node/block.dart

@@ -6,7 +6,7 @@ import 'node.dart';
 /// Represents a group of adjacent [Line]s with the same block style.
 ///
 /// Block elements are:
-/// - Quoteblock
+/// - Blockquote
 /// - Header
 /// - Indent
 /// - List
@@ -14,14 +14,12 @@ import 'node.dart';
 /// - Text Direction
 /// - Code Block
 class Block extends Container<Line?> {
+  /// Creates new unmounted [Block].
   @override
-  Line get defaultChild => Line();
+  Node newInstance() => Block();
 
-  /// Creates new unmounted [Block].
   @override
-  Node newInstance() {
-    return Block();
-  }
+  Line get defaultChild => Line();
 
   @override
   Delta toDelta() {
@@ -63,7 +61,7 @@ class Block extends Container<Line?> {
   @override
   String toString() {
     final block = style.attributes.toString();
-    final buffer = StringBuffer(' {$block}\n');
+    final buffer = StringBuffer('§ {$block}\n');
     for (final child in children) {
       final tree = child.isLast ? '└' : '├';
       buffer.write('  $tree $child');

+ 18 - 17
app_flowy/packages/flowy_editor/lib/src/model/document/node/container.dart

@@ -40,12 +40,6 @@ abstract class Container<T extends Node?> extends Node {
   /// Always returns fresh instance.
   T get defaultChild;
 
-  /// Content length of this node's children.
-  ///
-  /// To get number of children in this node use [childCount].
-  @override
-  int get length => _children.fold(0, (curr, node) => curr + node.length);
-
   /// Adds [node] to the end of this container children list.
   void add(T node) {
     assert(node?.parent == null);
@@ -69,7 +63,9 @@ abstract class Container<T extends Node?> extends Node {
 
   /// Moves children of this node to [newParent].
   void moveChildToNewParent(Container? newParent) {
-    if (isEmpty) return;
+    if (isEmpty) {
+      return;
+    }
 
     final last = newParent!.isEmpty ? null : newParent.last as T?;
     while (isNotEmpty) {
@@ -83,7 +79,7 @@ abstract class Container<T extends Node?> extends Node {
     if (last != null) last.adjust();
   }
 
-  /// Queries the child [Node] at specified character [offset] in this container.
+  /// Queries the child [Node] at [offset] in this container.
   ///
   /// The result may contain the found node or `null` if no node is found
   /// at specified offset.
@@ -96,17 +92,25 @@ abstract class Container<T extends Node?> extends Node {
       return ChildQuery(null, 0);
     }
 
-    for (final child in children) {
-      final childLen = child.length;
-      if (offset < childLen ||
-          (inclusive && offset == childLen && (child.isLast))) {
-        return ChildQuery(child, offset);
+    for (final node in children) {
+      final len = node.length;
+      if (offset < len || (inclusive && offset == len && node.isLast)) {
+        return ChildQuery(node, offset);
       }
-      offset -= childLen;
+      offset -= len;
     }
     return ChildQuery(null, 0);
   }
 
+  @override
+  String toPlainText() => children.map((child) => child.toPlainText()).join();
+
+  /// Content length of this node's children.
+  ///
+  /// To get number of children in this node use [childCount].
+  @override
+  int get length => _children.fold(0, (cur, node) => cur + node.length);
+
   @override
   void insert(int index, Object data, Style? style) {
     assert(index == 0 || (index > 0 && index < length));
@@ -138,9 +142,6 @@ abstract class Container<T extends Node?> extends Node {
     child.node!.delete(child.offset, length);
   }
 
-  @override
-  String toPlainText() => children.map((child) => child.toPlainText()).join();
-
   @override
   String toString() => _children.join('\n');
 }

+ 21 - 12
app_flowy/packages/flowy_editor/lib/src/model/document/node/embed.dart

@@ -4,21 +4,25 @@
 ///
 /// * [BlockEmbed] which represents a block embed.
 class Embeddable {
-  Map<String, dynamic> toJson() => <String, String>{type: data};
-
-  static Embeddable fromJson(Map<String, dynamic> json) {
-    final mp = Map<String, dynamic>.from(json);
-    assert(mp.length == 1, 'Embeddable map has one key');
-    return BlockEmbed(mp.keys.first, mp.values.first);
-  }
+  const Embeddable(this.type, this.data);
 
   /// The type of this object.
   final String type;
 
-  /// The data payload of this object
+  /// The data payload of this object.
   final dynamic data;
 
-  Embeddable(this.type, this.data);
+  Map<String, dynamic> toJson() {
+    final m = <String, String>{type: data};
+    return m;
+  }
+
+  static Embeddable fromJson(Map<String, dynamic> json) {
+    final m = Map<String, dynamic>.from(json);
+    assert(m.length == 1, 'Embeddable map has one key');
+
+    return BlockEmbed(m.keys.first, m.values.first);
+  }
 }
 
 /// An object which occupies an entire line in a document and cannot co-exist
@@ -28,9 +32,14 @@ class Embeddable {
 /// the document model itself does not make any assumptions about the types
 /// of embedded objects and allows users to define their own types.
 class BlockEmbed extends Embeddable {
-  BlockEmbed(String type, String data) : super(type, data);
+  const BlockEmbed(String type, String data) : super(type, data);
+
+  static const String horizontalRuleType = 'divider';
+  static BlockEmbed horizontalRule = const BlockEmbed(horizontalRuleType, 'hr');
 
-  static BlockEmbed horizontalRule = BlockEmbed('divider', 'hr');
+  static const String imageType = 'image';
+  static BlockEmbed image(String imageUrl) => BlockEmbed(imageType, imageUrl);
 
-  static BlockEmbed image(String imageUrl) => BlockEmbed('image', imageUrl);
+  static const String videoType = 'video';
+  static BlockEmbed video(String videoUrl) => BlockEmbed(videoType, videoUrl);
 }

+ 1 - 4
app_flowy/packages/flowy_editor/lib/src/model/document/node/leaf.dart

@@ -6,7 +6,6 @@ import 'embed.dart';
 import 'line.dart';
 import 'node.dart';
 
-/// A leaf in Quill document tree.
 abstract class Leaf extends Node {
   /// Creates a new [Leaf] with specified [data].
   factory Leaf(Object data) {
@@ -194,8 +193,6 @@ abstract class Leaf extends Node {
   }
 }
 
-/* ---------------------------------- Impl ---------------------------------- */
-
 /// A span of formatted text within a line in a Quill document.
 ///
 /// Text is a leaf node of a document tree.
@@ -213,7 +210,7 @@ class Text extends Leaf {
         super.val(text);
 
   @override
-  Node newInstance() => Text();
+  Node newInstance() => Text(value);
 
   @override
   String get value => _value as String;

+ 64 - 14
app_flowy/packages/flowy_editor/lib/src/model/document/node/line.dart

@@ -1,5 +1,5 @@
 import 'dart:math' as math;
-
+import 'package:collection/collection.dart';
 import '../../quill_delta.dart';
 import '../attribute.dart';
 import '../style.dart';
@@ -24,10 +24,7 @@ class Line extends Container<Leaf?> {
 
   /// Returns `true` if this line contains an embedded object.
   bool get hasEmbed {
-    if (childCount != 1) {
-      return false;
-    }
-    return children.single is Embed;
+    return children.any((child) => child is Embed);
   }
 
   /// Returns next [Line] or `null` if this is the last line in the document.
@@ -202,23 +199,44 @@ class Line extends Container<Leaf?> {
     } // No block-level changes
 
     if (parent is Block) {
-      final parentStyle = (parent as Block).style.getBlockExceptHeader();
-      if (blockStyle.value == null) {
+      final parentStyle = (parent as Block).style.getBlocksExceptHeader();
+      // Ensure that we're only unwrapping the block only if we unset a single
+      // block format in the `parentStyle` and there are no more block formats
+      // left to unset.
+      if (blockStyle.value == null &&
+          parentStyle.containsKey(blockStyle.key) &&
+          parentStyle.length == 1) {
         _unwrap();
-      } else if (blockStyle != parentStyle) {
+      } else if (!const MapEquality()
+          .equals(newStyle.getBlocksExceptHeader(), parentStyle)) {
         _unwrap();
-        final block = Block()..applyAttribute(blockStyle);
-        _wrap(block);
-        block.adjust();
+        // Block style now can contain multiple attributes
+        if (newStyle.attributes.keys
+            .any(Attribute.exclusiveBlockKeys.contains)) {
+          parentStyle.removeWhere(
+              (key, attr) => Attribute.exclusiveBlockKeys.contains(key));
+        }
+        parentStyle.removeWhere(
+            (key, attr) => newStyle?.attributes.keys.contains(key) ?? false);
+        final parentStyleToMerge = Style.attr(parentStyle);
+        newStyle = newStyle.mergeAll(parentStyleToMerge);
+        _applyBlockStyles(newStyle);
       } // else the same style, no-op.
     } else if (blockStyle.value != null) {
       // Only wrap with a new block if this is not an unset
-      final block = Block()..applyAttribute(blockStyle);
-      _wrap(block);
-      block.adjust();
+      _applyBlockStyles(newStyle);
     }
   }
 
+  void _applyBlockStyles(Style newStyle) {
+    var block = Block();
+    for (final style in newStyle.getBlocksExceptHeader().values) {
+      block = block..applyAttribute(style);
+    }
+    _wrap(block);
+    block.adjust();
+  }
+
   /// Wraps this line with new parent [block].
   ///
   /// This line can not be in a [Block] when this method is called.
@@ -359,4 +377,36 @@ class Line extends Container<Leaf?> {
 
     return result;
   }
+
+  /// Returns all styles for any character within the specified text range.
+  List<Style> collectAllStyles(int offset, int len) {
+    final local = math.min(length - offset, len);
+    final result = <Style>[];
+
+    final data = queryChild(offset, true);
+    var node = data.node as Leaf?;
+    if (node != null) {
+      result.add(node.style);
+      var pos = node.length - data.offset;
+      while (!node!.isLast && pos < local) {
+        node = node.next as Leaf?;
+        result.add(node!.style);
+        pos += node.length;
+      }
+    }
+
+    result.add(style);
+    if (parent is Block) {
+      final block = parent as Block;
+      result.add(block.style);
+    }
+
+    final remaining = len - local;
+    if (remaining > 0) {
+      final rest = nextLine!.collectAllStyles(0, remaining);
+      result.addAll(rest);
+    }
+
+    return result;
+  }
 }

+ 18 - 11
app_flowy/packages/flowy_editor/lib/src/model/document/node/node.dart

@@ -38,19 +38,24 @@ abstract class Node extends LinkedListEntry<Node> {
   /// To get offset of this node in the document see [documentOffset].
   int get offset {
     var offset = 0;
+
     if (list == null || isFirst) {
       return offset;
     }
-    var curr = this;
+
+    var cur = this;
     do {
-      curr = curr.previous!;
-      offset += curr.length;
-    } while (!curr.isFirst);
+      cur = cur.previous!;
+      offset += cur.length;
+    } while (!cur.isFirst);
     return offset;
   }
 
   /// Offset in characters of this node in the document.
   int get documentOffset {
+    if (parent == null) {
+      return offset;
+    }
     final parentOffset = (parent is! Root) ? parent!.documentOffset : 0;
     return parentOffset + offset;
   }
@@ -58,16 +63,16 @@ abstract class Node extends LinkedListEntry<Node> {
   /// Returns `true` if this node contains character at specified [offset] in
   /// the document.
   bool containsOffset(int offset) {
-    final docOffset = documentOffset;
-    return docOffset <= offset && offset < docOffset + length;
+    final o = documentOffset;
+    return o <= offset && offset < o + length;
   }
 
   void applyAttribute(Attribute attribute) {
     _style = _style.merge(attribute);
   }
 
-  void applyStyle(Style otherStyle) {
-    _style = _style.mergeAll(otherStyle);
+  void applyStyle(Style value) {
+    _style = _style.mergeAll(value);
   }
 
   void clearStyle() {
@@ -97,7 +102,7 @@ abstract class Node extends LinkedListEntry<Node> {
 
   void adjust() {/* no-op */}
 
-  // Subclass overridden method
+  /// abstract methods begin
 
   Node newInstance();
 
@@ -107,9 +112,11 @@ abstract class Node extends LinkedListEntry<Node> {
 
   void insert(int index, Object data, Style? style);
 
-  void retain(int index, int? length, Style? style);
+  void retain(int index, int? len, Style? style);
+
+  void delete(int index, int? len);
 
-  void delete(int index, int? length);
+  /// abstract methods end
 }
 
 /// Root node of document tree.

+ 39 - 24
app_flowy/packages/flowy_editor/lib/src/model/document/style.dart

@@ -3,6 +3,7 @@ import 'package:quiver/core.dart';
 
 import 'attribute.dart';
 
+/* Collection of style attributes */
 class Style {
   Style() : _attributes = <String, Attribute>{};
 
@@ -14,49 +15,63 @@ class Style {
     if (attributes == null) {
       return Style();
     }
-    final result = attributes.map((key, value) {
+
+    final result = attributes.map((key, dynamic value) {
       final attr = Attribute.fromKeyValue(key, value);
-      return MapEntry<String, Attribute>(key, attr);
+      return MapEntry<String, Attribute>(
+          key, attr ?? Attribute(key, AttributeScope.IGNORE, value));
     });
     return Style.attr(result);
   }
 
   Map<String, dynamic>? toJson() => _attributes.isEmpty
       ? null
-      : _attributes.map<String, dynamic>((_, attr) {
-          return MapEntry<String, dynamic>(attr.key, attr.value);
-        });
-
-  // Properties
-
-  Map<String, Attribute> get attributes => _attributes;
+      : _attributes.map<String, dynamic>((_, attribute) =>
+          MapEntry<String, dynamic>(attribute.key, attribute.value));
 
   Iterable<String> get keys => _attributes.keys;
 
-  Iterable<Attribute> get values => _attributes.values;
+  Iterable<Attribute> get values => _attributes.values.sorted(
+      (a, b) => Attribute.getRegistryOrder(a) - Attribute.getRegistryOrder(b));
+
+  Map<String, Attribute> get attributes => _attributes;
 
   bool get isEmpty => _attributes.isEmpty;
 
   bool get isNotEmpty => _attributes.isNotEmpty;
 
-  bool get isInline => isNotEmpty && values.every((ele) => ele.isInline);
+  bool get isInline => isNotEmpty && values.every((item) => item.isInline);
 
-  bool get isIgnored => isNotEmpty && values.every((ele) => ele.isIgnored);
+  bool get isIgnored =>
+      isNotEmpty && values.every((item) => item.scope == AttributeScope.IGNORE);
 
-  Attribute get single => values.single;
+  Attribute get single => _attributes.values.single;
 
   bool containsKey(String key) => _attributes.containsKey(key);
 
   Attribute? getBlockExceptHeader() {
-    for (final value in values) {
-      if (value.isBlockExceptHeader) {
-        return value;
+    for (final val in values) {
+      if (val.isBlockExceptHeader && val.value != null) {
+        return val;
+      }
+    }
+    for (final val in values) {
+      if (val.isBlockExceptHeader) {
+        return val;
       }
     }
     return null;
   }
 
-  // Operators
+  Map<String, Attribute> getBlocksExceptHeader() {
+    final m = <String, Attribute>{};
+    attributes.forEach((key, value) {
+      if (Attribute.blockKeysExceptHeader.contains(key)) {
+        m[key] = value;
+      }
+    });
+    return m;
+  }
 
   Style merge(Attribute attribute) {
     final merged = Map<String, Attribute>.from(_attributes);
@@ -70,22 +85,22 @@ class Style {
 
   Style mergeAll(Style other) {
     var result = Style.attr(_attributes);
-    other.values.forEach((attr) {
-      result = result.merge(attr);
-    });
+    for (final attribute in other.values) {
+      result = result.merge(attribute);
+    }
     return result;
   }
 
   Style removeAll(Set<Attribute> attributes) {
     final merged = Map<String, Attribute>.from(_attributes);
-    attributes.map((ele) => ele.key).forEach(merged.remove);
+    attributes.map((item) => item.key).forEach(merged.remove);
     return Style.attr(merged);
   }
 
   Style put(Attribute attribute) {
-    final merged = Map<String, Attribute>.from(_attributes);
-    merged[attribute.key] = attribute;
-    return Style.attr(merged);
+    final m = Map<String, Attribute>.from(attributes);
+    m[attribute.key] = attribute;
+    return Style.attr(m);
   }
 
   @override

+ 23 - 21
app_flowy/packages/flowy_editor/lib/src/model/heuristic/delete.dart

@@ -9,8 +9,8 @@ abstract class DeleteRule extends Rule {
   RuleType get type => RuleType.DELETE;
 
   @override
-  void validateArgs(int? length, Object? data, Attribute? attribute) {
-    assert(length != null);
+  void validateArgs(int? len, Object? data, Attribute? attribute) {
+    assert(len != null);
     assert(data == null);
     assert(attribute == null);
   }
@@ -21,10 +21,10 @@ class CatchAllDeleteRule extends DeleteRule {
 
   @override
   Delta applyRule(Delta document, int index,
-      {int? length, Object? data, Attribute? attribute}) {
+      {int? len, Object? data, Attribute? attribute}) {
     return Delta()
       ..retain(index)
-      ..delete(length!);
+      ..delete(len!);
   }
 }
 
@@ -33,9 +33,9 @@ class PreserveLineStyleOnMergeRule extends DeleteRule {
 
   @override
   Delta? applyRule(Delta document, int index,
-      {int? length, Object? data, Attribute? attribute}) {
-    final it = DeltaIterator(document)..skip(index);
-    var op = it.next(1);
+      {int? len, Object? data, Attribute? attribute}) {
+    final itr = DeltaIterator(document)..skip(index);
+    var op = itr.next(1);
     if (op.data != '\n') {
       return null;
     }
@@ -43,13 +43,13 @@ class PreserveLineStyleOnMergeRule extends DeleteRule {
     final isNotPlain = op.isNotPlain;
     final attrs = op.attributes;
 
-    it.skip(length! - 1);
+    itr.skip(len! - 1);
     final delta = Delta()
       ..retain(index)
-      ..delete(length);
+      ..delete(len);
 
-    while (it.hasNext) {
-      op = it.next();
+    while (itr.hasNext) {
+      op = itr.next();
       final text = op.data is String ? (op.data as String?)! : '';
       final lineBreak = text.indexOf('\n');
       if (lineBreak == -1) {
@@ -66,7 +66,9 @@ class PreserveLineStyleOnMergeRule extends DeleteRule {
         attributes ??= <String, dynamic>{};
         attributes.addAll(attrs!);
       }
-      delta..retain(lineBreak)..retain(1, attributes);
+      delta
+        ..retain(lineBreak)
+        ..retain(1, attributes);
       break;
     }
     return delta;
@@ -78,23 +80,23 @@ class EnsureEmbedLineRule extends DeleteRule {
 
   @override
   Delta? applyRule(Delta document, int index,
-      {int? length, Object? data, Attribute? attribute}) {
-    final it = DeltaIterator(document);
+      {int? len, Object? data, Attribute? attribute}) {
+    final itr = DeltaIterator(document);
 
-    var op = it.skip(index);
-    int? indexDelta = 0, lengthDelta = 0, remain = length;
+    var op = itr.skip(index);
+    int? indexDelta = 0, lengthDelta = 0, remain = len;
     var embedFound = op != null && op.data is! String;
     final hasLineBreakBefore =
         !embedFound && (op == null || (op.data as String).endsWith('\n'));
     if (embedFound) {
-      var candidate = it.next(1);
+      var candidate = itr.next(1);
       if (remain != null) {
         remain--;
         if (candidate.data == '\n') {
           indexDelta++;
           lengthDelta--;
 
-          candidate = it.next(1);
+          candidate = itr.next(1);
           remain--;
           if (candidate.data == '\n') {
             lengthDelta++;
@@ -103,10 +105,10 @@ class EnsureEmbedLineRule extends DeleteRule {
       }
     }
 
-    op = it.skip(remain!);
+    op = itr.skip(remain!);
     if (op != null &&
         (op.data is String ? op.data as String? : '')!.endsWith('\n')) {
-      final candidate = it.next(1);
+      final candidate = itr.next(1);
       if (candidate.data is! String && !hasLineBreakBefore) {
         embedFound = true;
         lengthDelta--;
@@ -119,6 +121,6 @@ class EnsureEmbedLineRule extends DeleteRule {
 
     return Delta()
       ..retain(index + indexDelta)
-      ..delete(length! + lengthDelta);
+      ..delete(len! + lengthDelta);
   }
 }

+ 39 - 20
app_flowy/packages/flowy_editor/lib/src/model/heuristic/format.dart

@@ -9,30 +9,28 @@ abstract class FormatRule extends Rule {
   RuleType get type => RuleType.FORMAT;
 
   @override
-  void validateArgs(int? length, Object? data, Attribute? attribute) {
-    assert(length != null);
+  void validateArgs(int? len, Object? data, Attribute? attribute) {
+    assert(len != null);
     assert(data == null);
     assert(attribute != null);
   }
 }
 
-/* -------------------------------- Rule Impl ------------------------------- */
-
 class ResolveLineFormatRule extends FormatRule {
   const ResolveLineFormatRule();
 
   @override
   Delta? applyRule(Delta document, int index,
-      {int? length, Object? data, Attribute? attribute}) {
+      {int? len, Object? data, Attribute? attribute}) {
     if (attribute!.scope != AttributeScope.BLOCK) {
       return null;
     }
 
     var delta = Delta()..retain(index);
-    final it = DeltaIterator(document)..skip(index);
+    final itr = DeltaIterator(document)..skip(index);
     Operation op;
-    for (var cur = 0; cur < length! && it.hasNext; cur += op.length!) {
-      op = it.next(length - cur);
+    for (var cur = 0; cur < len! && itr.hasNext; cur += op.length!) {
+      op = itr.next(len - cur);
       if (op.data is! String || !(op.data as String).contains('\n')) {
         delta.retain(op.length!);
         continue;
@@ -41,29 +39,50 @@ class ResolveLineFormatRule extends FormatRule {
       final tmp = Delta();
       var offset = 0;
 
+      // Enforce Block Format exclusivity by rule
+      final removedBlocks = Attribute.exclusiveBlockKeys.contains(attribute.key)
+          ? op.attributes?.keys
+                  .where((key) =>
+                      Attribute.exclusiveBlockKeys.contains(key) &&
+                      attribute.key != key &&
+                      attribute.value != null)
+                  .map((key) => MapEntry<String, dynamic>(key, null)) ??
+              []
+          : <MapEntry<String, dynamic>>[];
+
       for (var lineBreak = text.indexOf('\n');
           lineBreak >= 0;
           lineBreak = text.indexOf('\n', offset)) {
         tmp
           ..retain(lineBreak - offset)
-          ..retain(1, attribute.toJson());
+          ..retain(1, attribute.toJson()..addEntries(removedBlocks));
         offset = lineBreak + 1;
       }
       tmp.retain(text.length - offset);
       delta = delta.concat(tmp);
     }
 
-    while (it.hasNext) {
-      op = it.next();
+    while (itr.hasNext) {
+      op = itr.next();
       final text = op.data is String ? (op.data as String?)! : '';
       final lineBreak = text.indexOf('\n');
       if (lineBreak < 0) {
         delta.retain(op.length!);
         continue;
       }
+      // Enforce Block Format exclusivity by rule
+      final removedBlocks = Attribute.exclusiveBlockKeys.contains(attribute.key)
+          ? op.attributes?.keys
+                  .where((key) =>
+                      Attribute.exclusiveBlockKeys.contains(key) &&
+                      attribute.key != key &&
+                      attribute.value != null)
+                  .map((key) => MapEntry<String, dynamic>(key, null)) ??
+              []
+          : <MapEntry<String, dynamic>>[];
       delta
         ..retain(lineBreak)
-        ..retain(1, attribute.toJson());
+        ..retain(1, attribute.toJson()..addEntries(removedBlocks));
       break;
     }
     return delta;
@@ -75,14 +94,14 @@ class FormatLinkAtCaretPositionRule extends FormatRule {
 
   @override
   Delta? applyRule(Delta document, int index,
-      {int? length, Object? data, Attribute? attribute}) {
-    if (attribute!.key != Attribute.link.key || length! > 0) {
+      {int? len, Object? data, Attribute? attribute}) {
+    if (attribute!.key != Attribute.link.key || len! > 0) {
       return null;
     }
 
     final delta = Delta();
-    final it = DeltaIterator(document);
-    final before = it.skip(index), after = it.next();
+    final itr = DeltaIterator(document);
+    final before = itr.skip(index), after = itr.next();
     int? beg = index, retain = 0;
     if (before != null && before.hasAttribute(attribute.key)) {
       beg -= before.length!;
@@ -107,17 +126,17 @@ class ResolveInlineFormatRule extends FormatRule {
 
   @override
   Delta? applyRule(Delta document, int index,
-      {int? length, Object? data, Attribute? attribute}) {
+      {int? len, Object? data, Attribute? attribute}) {
     if (attribute!.scope != AttributeScope.INLINE) {
       return null;
     }
 
     final delta = Delta()..retain(index);
-    final it = DeltaIterator(document)..skip(index);
+    final itr = DeltaIterator(document)..skip(index);
 
     Operation op;
-    for (var cur = 0; cur < length! && it.hasNext; cur += op.length!) {
-      op = it.next(length - cur);
+    for (var cur = 0; cur < len! && itr.hasNext; cur += op.length!) {
+      op = itr.next(len - cur);
       final text = op.data is String ? (op.data as String?)! : '';
       var lineBreak = text.indexOf('\n');
       if (lineBreak < 0) {

+ 83 - 86
app_flowy/packages/flowy_editor/lib/src/model/heuristic/insert.dart

@@ -12,95 +12,107 @@ abstract class InsertRule extends Rule {
   RuleType get type => RuleType.INSERT;
 
   @override
-  void validateArgs(int? length, Object? data, Attribute? attribute) {
+  void validateArgs(int? len, Object? data, Attribute? attribute) {
     assert(data != null);
     assert(attribute == null);
   }
 }
 
-/* -------------------------------- Rule Impl ------------------------------- */
-
 class PreserveLineStyleOnSplitRule extends InsertRule {
   const PreserveLineStyleOnSplitRule();
 
   @override
   Delta? applyRule(Delta document, int index,
-      {int? length, Object? data, Attribute? attribute}) {
+      {int? len, Object? data, Attribute? attribute}) {
     if (data is! String || data != '\n') {
       return null;
     }
 
-    final it = DeltaIterator(document);
-    final before = it.skip(index);
+    final itr = DeltaIterator(document);
+    final before = itr.skip(index);
     if (before == null ||
         before.data is! String ||
         (before.data as String).endsWith('\n')) {
       return null;
     }
-    final after = it.next();
+    final after = itr.next();
     if (after.data is! String || (after.data as String).startsWith('\n')) {
       return null;
     }
 
     final text = after.data as String;
-    final delta = Delta()..retain(index + (length ?? 0));
+
+    final delta = Delta()..retain(index + (len ?? 0));
     if (text.contains('\n')) {
       assert(after.isPlain);
       delta.insert('\n');
       return delta;
     }
-
-    final nextNewLine = _getNextNewLine(it);
+    final nextNewLine = _getNextNewLine(itr);
     final attributes = nextNewLine.item1?.attributes;
 
     return delta..insert('\n', attributes);
   }
 }
 
+/// Preserves block style when user inserts text containing newlines.
+///
+/// This rule handles:
+///
+///   * inserting a new line in a block
+///   * pasting text containing multiple lines of text in a block
+///
+/// This rule may also be activated for changes triggered by auto-correct.
 class PreserveBlockStyleOnInsertRule extends InsertRule {
   const PreserveBlockStyleOnInsertRule();
 
   @override
   Delta? applyRule(Delta document, int index,
-      {int? length, Object? data, Attribute? attribute}) {
+      {int? len, Object? data, Attribute? attribute}) {
     if (data is! String || !data.contains('\n')) {
+      // Only interested in text containing at least one newline character.
       return null;
     }
 
-    final it = DeltaIterator(document)..skip(index);
+    final itr = DeltaIterator(document)..skip(index);
 
-    final nextNewLine = _getNextNewLine(it);
-    final lineStyle = Style.fromJson(
-      nextNewLine.item1?.attributes ?? <String, dynamic>{},
-    );
+    // Look for the next newline.
+    final nextNewLine = _getNextNewLine(itr);
+    final lineStyle =
+        Style.fromJson(nextNewLine.item1?.attributes ?? <String, dynamic>{});
 
-    final attribute = lineStyle.getBlockExceptHeader();
-    if (attribute == null) {
+    final blockStyle = lineStyle.getBlocksExceptHeader();
+    // Are we currently in a block? If not then ignore.
+    if (blockStyle.isEmpty) {
       return null;
     }
 
-    final blockStyle = <String, dynamic>{attribute.key: attribute.value};
-
     Map<String, dynamic>? resetStyle;
-
+    // If current line had heading style applied to it we'll need to move this
+    // style to the newly inserted line before it and reset style of the
+    // original line.
     if (lineStyle.containsKey(Attribute.header.key)) {
       resetStyle = Attribute.header.toJson();
     }
 
+    // Go over each inserted line and ensure block style is applied.
     final lines = data.split('\n');
-    final delta = Delta()..retain(index + (length ?? 0));
+    final delta = Delta()..retain(index + (len ?? 0));
     for (var i = 0; i < lines.length; i++) {
       final line = lines[i];
       if (line.isNotEmpty) {
         delta.insert(line);
       }
       if (i == 0) {
+        // The first line should inherit the lineStyle entirely.
         delta.insert('\n', lineStyle.toJson());
       } else if (i < lines.length - 1) {
+        // we don't want to insert a newline after the last chunk of text, so -1
         delta.insert('\n', blockStyle);
       }
     }
 
+    // Reset style of the original newline character if needed.
     if (resetStyle != null) {
       delta
         ..retain(nextNewLine.item2!)
@@ -112,6 +124,12 @@ class PreserveBlockStyleOnInsertRule extends InsertRule {
   }
 }
 
+/// Heuristic rule to exit current block when user inserts two consecutive
+/// newlines.
+///
+/// This rule is only applied when the cursor is on the last line of a block.
+/// When the cursor is in the middle of a block we allow adding empty lines
+/// and preserving the block's style.
 class AutoExitBlockRule extends InsertRule {
   const AutoExitBlockRule();
 
@@ -127,40 +145,55 @@ class AutoExitBlockRule extends InsertRule {
 
   @override
   Delta? applyRule(Delta document, int index,
-      {int? length, Object? data, Attribute? attribute}) {
+      {int? len, Object? data, Attribute? attribute}) {
     if (data is! String || data != '\n') {
       return null;
     }
 
-    final it = DeltaIterator(document);
-    final prev = it.skip(index), cur = it.next();
+    final itr = DeltaIterator(document);
+    final prev = itr.skip(index), cur = itr.next();
     final blockStyle = Style.fromJson(cur.attributes).getBlockExceptHeader();
+    // We are not in a block, ignore.
     if (cur.isPlain || blockStyle == null) {
       return null;
     }
+    // We are not on an empty line, ignore.
     if (!_isEmptyLine(prev, cur)) {
       return null;
     }
 
+    // We are on an empty line. Now we need to determine if we are on the
+    // last line of a block.
+    // First check if `cur` length is greater than 1, this would indicate
+    // that it contains multiple newline characters which share the same style.
+    // This would mean we are not on the last line yet.
+    // `cur.value as String` is safe since we already called isEmptyLine and
+    // know it contains a newline
     if ((cur.value as String).length > 1) {
+      // We are not on the last line of this block, ignore.
       return null;
     }
 
-    final nextNewLine = _getNextNewLine(it);
+    // Keep looking for the next newline character to see if it shares the same
+    // block style as `cur`.
+    final nextNewLine = _getNextNewLine(itr);
     if (nextNewLine.item1 != null &&
         nextNewLine.item1!.attributes != null &&
         Style.fromJson(nextNewLine.item1!.attributes).getBlockExceptHeader() ==
             blockStyle) {
+      // We are not at the end of this block, ignore.
       return null;
     }
 
+    // Here we now know that the line after `cur` is not in the same block
+    // therefore we can exit this block.
     final attributes = cur.attributes ?? <String, dynamic>{};
-    final k = attributes.keys
-        .firstWhere((k) => Attribute.blockKeysExceptHeader.contains(k));
+    final k =
+        attributes.keys.firstWhere(Attribute.blockKeysExceptHeader.contains);
     attributes[k] = null;
     // retain(1) should be '\n', set it with no attribute
     return Delta()
-      ..retain(index + (length ?? 0))
+      ..retain(index + (len ?? 0))
       ..retain(1, attributes);
   }
 }
@@ -170,7 +203,7 @@ class ResetLineFormatOnNewLineRule extends InsertRule {
 
   @override
   Delta? applyRule(Delta document, int index,
-      {int? length, Object? data, Attribute? attribute}) {
+      {int? len, Object? data, Attribute? attribute}) {
     if (data is! String || data != '\n') {
       return null;
     }
@@ -187,7 +220,7 @@ class ResetLineFormatOnNewLineRule extends InsertRule {
       resetStyle = Attribute.header.toJson();
     }
     return Delta()
-      ..retain(index + (length ?? 0))
+      ..retain(index + (len ?? 0))
       ..insert('\n', cur.attributes)
       ..retain(1, resetStyle)
       ..trim();
@@ -199,14 +232,14 @@ class InsertEmbedsRule extends InsertRule {
 
   @override
   Delta? applyRule(Delta document, int index,
-      {int? length, Object? data, Attribute? attribute}) {
+      {int? len, Object? data, Attribute? attribute}) {
     if (data is String) {
       return null;
     }
 
-    final delta = Delta()..retain(index + (length ?? 0));
-    final it = DeltaIterator(document);
-    final prev = it.skip(index), cur = it.next();
+    final delta = Delta()..retain(index + (len ?? 0));
+    final itr = DeltaIterator(document);
+    final prev = itr.skip(index), cur = itr.next();
 
     final textBefore = prev?.data is String ? prev!.data as String? : '';
     final textAfter = cur.data is String ? (cur.data as String?)! : '';
@@ -222,8 +255,8 @@ class InsertEmbedsRule extends InsertRule {
     if (textAfter.contains('\n')) {
       lineStyle = cur.attributes;
     } else {
-      while (it.hasNext) {
-        final op = it.next();
+      while (itr.hasNext) {
+        final op = itr.next();
         if ((op.data is String ? op.data as String? : '')!.contains('\n')) {
           lineStyle = op.attributes;
           break;
@@ -242,52 +275,18 @@ class InsertEmbedsRule extends InsertRule {
   }
 }
 
-class ForceNewlineForInsertsAroundEmbedRule extends InsertRule {
-  const ForceNewlineForInsertsAroundEmbedRule();
-
-  @override
-  Delta? applyRule(Delta document, int index,
-      {int? length, Object? data, Attribute? attribute}) {
-    if (data is! String) {
-      return null;
-    }
-
-    final text = data;
-    final it = DeltaIterator(document);
-    final prev = it.skip(index), cur = it.next();
-    final cursorBeforeEmbed = cur.data is! String;
-    final cursorAfterEmbed = prev != null && prev.data is! String;
-
-    if (!cursorBeforeEmbed && !cursorAfterEmbed) {
-      return null;
-    }
-    final delta = Delta()..retain(index + (length ?? 0));
-    if (cursorBeforeEmbed && !text.endsWith('\n')) {
-      return delta
-        ..insert(text)
-        ..insert('\n');
-    }
-    if (cursorAfterEmbed && !text.startsWith('\n')) {
-      return delta
-        ..insert('\n')
-        ..insert(text);
-    }
-    return delta..insert(text);
-  }
-}
-
 class AutoFormatLinksRule extends InsertRule {
   const AutoFormatLinksRule();
 
   @override
   Delta? applyRule(Delta document, int index,
-      {int? length, Object? data, Attribute? attribute}) {
+      {int? len, Object? data, Attribute? attribute}) {
     if (data is! String || data != ' ') {
       return null;
     }
 
-    final it = DeltaIterator(document);
-    final prev = it.skip(index);
+    final itr = DeltaIterator(document);
+    final prev = itr.skip(index);
     if (prev == null || prev.data is! String) {
       return null;
     }
@@ -306,7 +305,7 @@ class AutoFormatLinksRule extends InsertRule {
 
       attributes.addAll(LinkAttribute(link.toString()).toJson());
       return Delta()
-        ..retain(index + (length ?? 0) - cand.length)
+        ..retain(index + (len ?? 0) - cand.length)
         ..retain(cand.length, attributes)
         ..insert(data, prev.attributes);
     } on FormatException {
@@ -320,13 +319,13 @@ class PreserveInlineStylesRule extends InsertRule {
 
   @override
   Delta? applyRule(Delta document, int index,
-      {int? length, Object? data, Attribute? attribute}) {
+      {int? len, Object? data, Attribute? attribute}) {
     if (data is! String || data.contains('\n')) {
       return null;
     }
 
-    final it = DeltaIterator(document);
-    final prev = it.skip(index);
+    final itr = DeltaIterator(document);
+    final prev = itr.skip(index);
     if (prev == null ||
         prev.data is! String ||
         (prev.data as String).contains('\n')) {
@@ -337,15 +336,15 @@ class PreserveInlineStylesRule extends InsertRule {
     final text = data;
     if (attributes == null || !attributes.containsKey(Attribute.link.key)) {
       return Delta()
-        ..retain(index + (length ?? 0))
+        ..retain(index + (len ?? 0))
         ..insert(text, attributes);
     }
 
     attributes.remove(Attribute.link.key);
     final delta = Delta()
-      ..retain(index + (length ?? 0))
+      ..retain(index + (len ?? 0))
       ..insert(text, attributes.isEmpty ? null : attributes);
-    final next = it.next();
+    final next = itr.next();
 
     final nextAttributes = next.attributes ?? const <String, dynamic>{};
     if (!nextAttributes.containsKey(Attribute.link.key)) {
@@ -353,7 +352,7 @@ class PreserveInlineStylesRule extends InsertRule {
     }
     if (attributes[Attribute.link.key] == nextAttributes[Attribute.link.key]) {
       return Delta()
-        ..retain(index + (length ?? 0))
+        ..retain(index + (len ?? 0))
         ..insert(text, attributes);
     }
     return delta;
@@ -365,15 +364,13 @@ class CatchAllInsertRule extends InsertRule {
 
   @override
   Delta applyRule(Delta document, int index,
-      {int? length, Object? data, Attribute? attribute}) {
+      {int? len, Object? data, Attribute? attribute}) {
     return Delta()
-      ..retain(index + (length ?? 0))
+      ..retain(index + (len ?? 0))
       ..insert(data);
   }
 }
 
-/* --------------------------------- Helper --------------------------------- */
-
 Tuple2<Operation?, int?> _getNextNewLine(DeltaIterator iterator) {
   Operation op;
   for (var skipped = 0; iterator.hasNext; skipped += op.length!) {

+ 19 - 24
app_flowy/packages/flowy_editor/lib/src/model/heuristic/rule.dart

@@ -6,46 +6,37 @@ import 'delete.dart';
 import 'format.dart';
 import 'package:flowy_log/flowy_log.dart';
 
-enum RuleType {
-  INSERT,
-  DELETE,
-  FORMAT,
-}
+enum RuleType { INSERT, DELETE, FORMAT }
 
 abstract class Rule {
   const Rule();
 
-  RuleType get type;
-
   Delta? apply(Delta document, int index,
-      {int? length, Object? data, Attribute? attribute}) {
-    validateArgs(length, data, attribute);
-    return applyRule(
-      document,
-      index,
-      length: length,
-      data: data,
-      attribute: attribute,
-    );
+      {int? len, Object? data, Attribute? attribute}) {
+    validateArgs(len, data, attribute);
+    return applyRule(document, index,
+        len: len, data: data, attribute: attribute);
   }
 
+  void validateArgs(int? len, Object? data, Attribute? attribute);
+
   Delta? applyRule(Delta document, int index,
-      {int? length, Object? data, Attribute? attribute});
+      {int? len, Object? data, Attribute? attribute});
 
-  void validateArgs(int? length, Object? data, Attribute? attribute);
+  RuleType get type;
 }
 
 class Rules {
   Rules(this._rules);
 
-  final List<Rule> _rules;
+  List<Rule> _customRules = [];
 
+  final List<Rule> _rules;
   static final Rules _instance = Rules([
     const FormatLinkAtCaretPositionRule(),
     const ResolveLineFormatRule(),
     const ResolveInlineFormatRule(),
     const InsertEmbedsRule(),
-    // const ForceNewlineForInsertsAroundEmbedRule(),
     const AutoExitBlockRule(),
     const PreserveBlockStyleOnInsertRule(),
     const PreserveLineStyleOnSplitRule(),
@@ -53,23 +44,27 @@ class Rules {
     const AutoFormatLinksRule(),
     const PreserveInlineStylesRule(),
     const CatchAllInsertRule(),
-    // const EnsureEmbedLineRule(),
+    const EnsureEmbedLineRule(),
     const PreserveLineStyleOnMergeRule(),
     const CatchAllDeleteRule(),
   ]);
 
   static Rules getInstance() => _instance;
 
+  void setCustomRules(List<Rule> customRules) {
+    _customRules = customRules;
+  }
+
   Delta apply(RuleType ruleType, Document document, int index,
-      {int? length, Object? data, Attribute? attribute}) {
+      {int? len, Object? data, Attribute? attribute}) {
     final delta = document.toDelta();
-    for (final rule in _rules) {
+    for (final rule in _customRules + _rules) {
       if (rule.type != ruleType) {
         continue;
       }
       try {
         final result = rule.apply(delta, index,
-            length: length, data: data, attribute: attribute);
+            len: len, data: data, attribute: attribute);
         if (result != null) {
           Log.trace('apply rule: $rule, result: $result');
           return result..trim();

+ 0 - 3
app_flowy/packages/flowy_editor/lib/src/widget/flowy_toolbar.dart

@@ -529,9 +529,6 @@ class _HeaderStyleButtonState extends State<HeaderStyleButton> {
       Attribute.h1: 'H1',
       Attribute.h2: 'H2',
       Attribute.h3: 'H3',
-      Attribute.h4: 'H4',
-      Attribute.h5: 'H5',
-      Attribute.h6: 'H6',
     };
     final headerStyles = headerTextMapping.keys.toList(growable: false);
     final headerTexts = headerTextMapping.values.toList(growable: false);

+ 0 - 3
app_flowy/packages/flowy_editor/lib/src/widget/text_line.dart

@@ -97,9 +97,6 @@ class TextLine extends StatelessWidget {
       Attribute.h1: defaultStyles.h1!.style,
       Attribute.h2: defaultStyles.h2!.style,
       Attribute.h3: defaultStyles.h3!.style,
-      Attribute.h4: defaultStyles.h4!.style,
-      Attribute.h5: defaultStyles.h5!.style,
-      Attribute.h6: defaultStyles.h6!.style,
     };
     textStyle =
         textStyle.merge(headerStyles[header] ?? defaultStyles.paragraph!.style);