Ver Fonte

add auto format extension

appflowy há 3 anos atrás
pai
commit
41eacb7000
30 ficheiros alterados com 698 adições e 440 exclusões
  1. 3 3
      app_flowy/assets/images/home/Close.svg
  2. 3 0
      app_flowy/assets/images/home/Favorite/Active.svg
  3. 3 0
      app_flowy/assets/images/home/Favorite/Inactive.svg
  4. 5 0
      app_flowy/assets/images/home/Image.svg
  5. 3 3
      app_flowy/assets/images/home/Page.svg
  6. 1 1
      app_flowy/packages/flowy_editor/lib/src/model/heuristic/rule.dart
  7. 2 1
      app_flowy/packages/flowy_editor/lib/src/service/controller.dart
  8. 1 1
      rust-lib/flowy-ot/src/client/extensions/delete/default_delete.rs
  9. 3 0
      rust-lib/flowy-ot/src/client/extensions/delete/mod.rs
  10. 49 0
      rust-lib/flowy-ot/src/client/extensions/format/format_at_position.rs
  11. 39 0
      rust-lib/flowy-ot/src/client/extensions/format/helper.rs
  12. 8 0
      rust-lib/flowy-ot/src/client/extensions/format/mod.rs
  13. 62 0
      rust-lib/flowy-ot/src/client/extensions/format/resolve_block_format.rs
  14. 39 0
      rust-lib/flowy-ot/src/client/extensions/format/resolve_inline_format.rs
  15. 26 0
      rust-lib/flowy-ot/src/client/extensions/insert/auto_format.rs
  16. 37 0
      rust-lib/flowy-ot/src/client/extensions/insert/default_insert.rs
  17. 90 0
      rust-lib/flowy-ot/src/client/extensions/insert/mod.rs
  18. 51 0
      rust-lib/flowy-ot/src/client/extensions/insert/preserve_inline_style.rs
  19. 40 0
      rust-lib/flowy-ot/src/client/extensions/insert/reset_format_on_new_line.rs
  20. 10 0
      rust-lib/flowy-ot/src/client/extensions/mod.rs
  21. 4 0
      rust-lib/flowy-ot/src/client/mod.rs
  22. 66 0
      rust-lib/flowy-ot/src/client/util.rs
  23. 2 2
      rust-lib/flowy-ot/src/client/view.rs
  24. 0 173
      rust-lib/flowy-ot/src/client/view/format_ext.rs
  25. 0 198
      rust-lib/flowy-ot/src/client/view/insert_ext.rs
  26. 0 12
      rust-lib/flowy-ot/src/client/view/mod.rs
  27. 0 8
      rust-lib/flowy-ot/src/client/view/util.rs
  28. 94 10
      rust-lib/flowy-ot/tests/attribute_test.rs
  29. 24 13
      rust-lib/flowy-ot/tests/helper/mod.rs
  30. 33 15
      rust-lib/flowy-ot/tests/undo_redo_test.rs

+ 3 - 3
app_flowy/assets/images/home/Close.svg

@@ -1,4 +1,4 @@
-<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
-<rect x="15.7124" y="7.22705" width="1.5" height="12" rx="0.75" transform="rotate(45 15.7124 7.22705)" fill="#333333"/>
-<rect x="16.7729" y="15.7124" width="1.5" height="12" rx="0.75" transform="rotate(135 16.7729 15.7124)" fill="#333333"/>
+<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
+<rect x="20.9497" y="9.63599" width="2" height="16" rx="1" transform="rotate(45 20.9497 9.63599)" fill="#333333"/>
+<rect x="22.364" y="20.95" width="2" height="16" rx="1" transform="rotate(135 22.364 20.95)" fill="#333333"/>
 </svg>

+ 3 - 0
app_flowy/assets/images/home/Favorite/Active.svg

@@ -0,0 +1,3 @@
+<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M16 6L18.781 11.9243L25 12.8801L20.5 17.489L21.562 24L16 20.9243L10.438 24L11.5 17.489L7 12.8801L13.219 11.9243L16 6Z" fill="#FFD667" stroke="#FFD667" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
+</svg>

+ 3 - 0
app_flowy/assets/images/home/Favorite/Inactive.svg

@@ -0,0 +1,3 @@
+<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M16 6L18.781 11.9243L25 12.8801L20.5 17.489L21.562 24L16 20.9243L10.438 24L11.5 17.489L7 12.8801L13.219 11.9243L16 6Z" stroke="#333333" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
+</svg>

+ 5 - 0
app_flowy/assets/images/home/Image.svg

@@ -0,0 +1,5 @@
+<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
+<rect x="3" y="6" width="26" height="20" rx="3" stroke="#333333" stroke-width="2"/>
+<circle cx="11" cy="13" r="2" stroke="#333333" stroke-width="2"/>
+<path d="M10 26L20.2239 16.9121C20.8422 16.3625 21.7349 16.2503 22.4699 16.6296L29 20" stroke="#333333" stroke-width="2"/>
+</svg>

+ 3 - 3
app_flowy/assets/images/home/Page.svg

@@ -1,4 +1,4 @@
-<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
-<path d="M13.5 3.75V9C13.5 9.82843 14.1716 10.5 15 10.5H18" stroke="#333333" stroke-width="1.5"/>
-<path d="M5.25 5.25C5.25 4.42157 5.92157 3.75 6.75 3.75H12H12.75C13.6943 3.75 14.5834 4.19458 15.15 4.95L18.15 8.95C18.5395 9.46929 18.75 10.1009 18.75 10.75V12V18.75C18.75 19.5784 18.0784 20.25 17.25 20.25H6.75C5.92157 20.25 5.25 19.5784 5.25 18.75V5.25Z" stroke="#333333" stroke-width="1.5"/>
+<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M18 5V12C18 13.1046 18.8954 14 20 14H24" stroke="#333333" stroke-width="2"/>
+<path d="M7 7C7 5.89543 7.89543 5 9 5H16H17C18.259 5 19.4446 5.59278 20.2 6.6L24.2 11.9333C24.7193 12.6257 25 13.4679 25 14.3333V16V25C25 26.1046 24.1046 27 23 27H9C7.89543 27 7 26.1046 7 25V7Z" stroke="#333333" stroke-width="2"/>
 </svg>

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

@@ -49,7 +49,7 @@ class Rules {
     // const PreserveBlockStyleOnInsertRule(),
     // const PreserveLineStyleOnSplitRule(),
     const ResetLineFormatOnNewLineRule(),
-    // const AutoFormatLinksRule(),
+    const AutoFormatLinksRule(),
     const PreserveInlineStylesRule(),
     const CatchAllInsertRule(),
     // const EnsureEmbedLineRule(),

+ 2 - 1
app_flowy/packages/flowy_editor/lib/src/service/controller.dart

@@ -93,7 +93,8 @@ class EditorController extends ChangeNotifier {
       toggledStyle = toggledStyle.put(attribute);
     }
 
-    final change = document.format(index, length, attribute);
+    final change =
+        document.format(index, length, LinkAttribute("www.baidu.com"));
     final adjustedSelection = selection.copyWith(
       baseOffset: change.transformPosition(selection.baseOffset),
       extentOffset: change.transformPosition(selection.extentOffset),

+ 1 - 1
rust-lib/flowy-ot/src/client/view/delete_ext.rs → rust-lib/flowy-ot/src/client/extensions/delete/default_delete.rs

@@ -1,5 +1,5 @@
 use crate::{
-    client::view::DeleteExt,
+    client::extensions::DeleteExt,
     core::{Delta, DeltaBuilder, Interval},
 };
 

+ 3 - 0
rust-lib/flowy-ot/src/client/extensions/delete/mod.rs

@@ -0,0 +1,3 @@
+mod default_delete;
+
+pub use default_delete::*;

+ 49 - 0
rust-lib/flowy-ot/src/client/extensions/format/format_at_position.rs

@@ -0,0 +1,49 @@
+use crate::{
+    client::extensions::FormatExt,
+    core::{Attribute, AttributeKey, CharMetric, Delta, DeltaBuilder, DeltaIter, Interval},
+};
+
+pub struct FormatLinkAtCaretPositionExt {}
+
+impl FormatExt for FormatLinkAtCaretPositionExt {
+    fn ext_name(&self) -> &str { "FormatLinkAtCaretPositionExt" }
+
+    fn apply(&self, delta: &Delta, interval: Interval, attribute: &Attribute) -> Option<Delta> {
+        if attribute.key != AttributeKey::Link || interval.size() != 0 {
+            return None;
+        }
+
+        let mut iter = DeltaIter::new(delta);
+        iter.seek::<CharMetric>(interval.start);
+
+        let (before, after) = (iter.next_op_before(interval.size()), iter.next());
+        let mut start = interval.end;
+        let mut retain = 0;
+
+        if let Some(before) = before {
+            if before.contain_attribute(attribute) {
+                start -= before.len();
+                retain += before.len();
+            }
+        }
+
+        if let Some(after) = after {
+            if after.contain_attribute(attribute) {
+                if retain != 0 {
+                    retain += after.len();
+                }
+            }
+        }
+
+        if retain == 0 {
+            return None;
+        }
+
+        Some(
+            DeltaBuilder::new()
+                .retain(start)
+                .retain_with_attributes(retain, (attribute.clone()).into())
+                .build(),
+        )
+    }
+}

+ 39 - 0
rust-lib/flowy-ot/src/client/extensions/format/helper.rs

@@ -0,0 +1,39 @@
+use crate::{
+    client::util::find_newline,
+    core::{Attribute, AttributeScope, Attributes, Delta, Operation},
+};
+
+pub(crate) fn line_break(op: &Operation, attribute: &Attribute, scope: AttributeScope) -> Delta {
+    let mut new_delta = Delta::new();
+    let mut start = 0;
+    let end = op.len();
+    let mut s = op.get_data();
+
+    while let Some(line_break) = find_newline(s) {
+        match scope {
+            AttributeScope::Inline => {
+                new_delta.retain(line_break - start, attribute.clone().into());
+                new_delta.retain(1, Attributes::empty());
+            },
+            AttributeScope::Block => {
+                new_delta.retain(line_break - start, Attributes::empty());
+                new_delta.retain(1, attribute.clone().into());
+            },
+            _ => {
+                log::error!("Unsupported parser line break for {:?}", scope);
+            },
+        }
+
+        start = line_break + 1;
+        s = &s[start..s.len()];
+    }
+
+    if start < end {
+        match scope {
+            AttributeScope::Inline => new_delta.retain(end - start, attribute.clone().into()),
+            AttributeScope::Block => new_delta.retain(end - start, Attributes::empty()),
+            _ => log::error!("Unsupported parser line break for {:?}", scope),
+        }
+    }
+    new_delta
+}

+ 8 - 0
rust-lib/flowy-ot/src/client/extensions/format/mod.rs

@@ -0,0 +1,8 @@
+mod format_at_position;
+mod helper;
+mod resolve_block_format;
+mod resolve_inline_format;
+
+pub use format_at_position::*;
+pub use resolve_block_format::*;
+pub use resolve_inline_format::*;

+ 62 - 0
rust-lib/flowy-ot/src/client/extensions/format/resolve_block_format.rs

@@ -0,0 +1,62 @@
+use crate::{
+    client::{
+        extensions::{format::helper::line_break, FormatExt},
+        util::find_newline,
+    },
+    core::{
+        Attribute,
+        AttributeScope,
+        Attributes,
+        CharMetric,
+        Delta,
+        DeltaBuilder,
+        DeltaIter,
+        Interval,
+    },
+};
+
+pub struct ResolveBlockFormatExt {}
+impl FormatExt for ResolveBlockFormatExt {
+    fn ext_name(&self) -> &str { "ResolveBlockFormatExt" }
+
+    fn apply(&self, delta: &Delta, interval: Interval, attribute: &Attribute) -> Option<Delta> {
+        if attribute.scope != AttributeScope::Block {
+            return None;
+        }
+
+        let mut new_delta = DeltaBuilder::new().retain(interval.start).build();
+        let mut iter = DeltaIter::new(delta);
+        iter.seek::<CharMetric>(interval.start);
+        let mut start = 0;
+        let end = interval.size();
+        while start < end && iter.has_next() {
+            let next_op = iter.next_op_before(end - start).unwrap();
+            match find_newline(next_op.get_data()) {
+                None => new_delta.retain(next_op.len(), Attributes::empty()),
+                Some(_) => {
+                    let tmp_delta = line_break(&next_op, attribute, AttributeScope::Block);
+                    new_delta.extend(tmp_delta);
+                },
+            }
+
+            start += next_op.len();
+        }
+
+        while iter.has_next() {
+            let op = iter
+                .next_op()
+                .expect("Unexpected None, iter.has_next() must return op");
+
+            match find_newline(op.get_data()) {
+                None => new_delta.retain(op.len(), Attributes::empty()),
+                Some(line_break) => {
+                    debug_assert_eq!(line_break, 0);
+                    new_delta.retain(1, attribute.clone().into());
+                    break;
+                },
+            }
+        }
+
+        Some(new_delta)
+    }
+}

+ 39 - 0
rust-lib/flowy-ot/src/client/extensions/format/resolve_inline_format.rs

@@ -0,0 +1,39 @@
+use crate::{
+    client::{
+        extensions::{format::helper::line_break, FormatExt},
+        util::find_newline,
+    },
+    core::{Attribute, AttributeScope, CharMetric, Delta, DeltaBuilder, DeltaIter, Interval},
+};
+
+pub struct ResolveInlineFormatExt {}
+impl FormatExt for ResolveInlineFormatExt {
+    fn ext_name(&self) -> &str { "ResolveInlineFormatExt" }
+
+    fn apply(&self, delta: &Delta, interval: Interval, attribute: &Attribute) -> Option<Delta> {
+        if attribute.scope != AttributeScope::Inline {
+            return None;
+        }
+        let mut new_delta = DeltaBuilder::new().retain(interval.start).build();
+        let mut iter = DeltaIter::new(delta);
+        iter.seek::<CharMetric>(interval.start);
+
+        let mut start = 0;
+        let end = interval.size();
+
+        while start < end && iter.has_next() {
+            let next_op = iter.next_op_before(end - start).unwrap();
+            match find_newline(next_op.get_data()) {
+                None => new_delta.retain(next_op.len(), attribute.clone().into()),
+                Some(_) => {
+                    let tmp_delta = line_break(&next_op, attribute, AttributeScope::Inline);
+                    new_delta.extend(tmp_delta);
+                },
+            }
+
+            start += next_op.len();
+        }
+
+        Some(new_delta)
+    }
+}

+ 26 - 0
rust-lib/flowy-ot/src/client/extensions/insert/auto_format.rs

@@ -0,0 +1,26 @@
+use crate::{
+    client::{extensions::InsertExt, util::is_whitespace},
+    core::{CharMetric, Delta, DeltaIter},
+};
+
+pub struct AutoFormatExt {}
+impl InsertExt for AutoFormatExt {
+    fn ext_name(&self) -> &str { "AutoFormatExt" }
+
+    fn apply(&self, delta: &Delta, _replace_len: usize, text: &str, index: usize) -> Option<Delta> {
+        // enter whitespace to trigger auto format
+        if !is_whitespace(text) {
+            return None;
+        }
+        let mut iter = DeltaIter::new(delta);
+        iter.seek::<CharMetric>(index);
+        let prev = iter.next_op();
+        if prev.is_none() {
+            return None;
+        }
+
+        let _prev = prev.unwrap();
+
+        None
+    }
+}

+ 37 - 0
rust-lib/flowy-ot/src/client/extensions/insert/default_insert.rs

@@ -0,0 +1,37 @@
+use crate::{
+    client::extensions::{InsertExt, NEW_LINE},
+    core::{AttributeKey, Attributes, Delta, DeltaBuilder, DeltaIter},
+};
+
+pub struct DefaultInsertExt {}
+impl InsertExt for DefaultInsertExt {
+    fn ext_name(&self) -> &str { "DefaultInsertExt" }
+
+    fn apply(&self, delta: &Delta, replace_len: usize, text: &str, index: usize) -> Option<Delta> {
+        let iter = DeltaIter::new(delta);
+        let mut attributes = Attributes::new();
+
+        // Enable each line split by "\n" remains the block attributes. for example:
+        // insert "\n" to "123456" at index 3
+        //
+        // [{"insert":"123"},{"insert":"\n","attributes":{"header":"1"}},
+        // {"insert":"456"},{"insert":"\n","attributes":{"header":"1"}}]
+        if text.ends_with(NEW_LINE) {
+            match iter.last() {
+                None => {},
+                Some(op) => {
+                    if op.get_attributes().contains_key(&AttributeKey::Header) {
+                        attributes.extend(op.get_attributes());
+                    }
+                },
+            }
+        }
+
+        Some(
+            DeltaBuilder::new()
+                .retain(index + replace_len)
+                .insert_with_attributes(text, attributes)
+                .build(),
+        )
+    }
+}

+ 90 - 0
rust-lib/flowy-ot/src/client/extensions/insert/mod.rs

@@ -0,0 +1,90 @@
+pub use auto_format::*;
+pub use default_insert::*;
+pub use preserve_inline_style::*;
+pub use reset_format_on_new_line::*;
+
+mod auto_format;
+mod default_insert;
+mod preserve_inline_style;
+mod reset_format_on_new_line;
+
+use crate::{
+    client::extensions::InsertExt,
+    core::{Delta, DeltaBuilder, DeltaIter, Operation},
+};
+
+pub struct PreserveBlockStyleOnInsertExt {}
+impl InsertExt for PreserveBlockStyleOnInsertExt {
+    fn ext_name(&self) -> &str { "PreserveBlockStyleOnInsertExt" }
+
+    fn apply(
+        &self,
+        _delta: &Delta,
+        _replace_len: usize,
+        _text: &str,
+        _index: usize,
+    ) -> Option<Delta> {
+        None
+    }
+}
+
+pub struct PreserveLineStyleOnSplitExt {}
+impl InsertExt for PreserveLineStyleOnSplitExt {
+    fn ext_name(&self) -> &str { "PreserveLineStyleOnSplitExt" }
+
+    fn apply(
+        &self,
+        _delta: &Delta,
+        _replace_len: usize,
+        _text: &str,
+        _index: usize,
+    ) -> Option<Delta> {
+        None
+    }
+}
+
+pub struct AutoExitBlockExt {}
+
+impl InsertExt for AutoExitBlockExt {
+    fn ext_name(&self) -> &str { "AutoExitBlockExt" }
+
+    fn apply(
+        &self,
+        _delta: &Delta,
+        _replace_len: usize,
+        _text: &str,
+        _index: usize,
+    ) -> Option<Delta> {
+        None
+    }
+}
+
+pub struct InsertEmbedsExt {}
+impl InsertExt for InsertEmbedsExt {
+    fn ext_name(&self) -> &str { "InsertEmbedsExt" }
+
+    fn apply(
+        &self,
+        _delta: &Delta,
+        _replace_len: usize,
+        _text: &str,
+        _index: usize,
+    ) -> Option<Delta> {
+        None
+    }
+}
+
+pub struct ForceNewlineForInsertsAroundEmbedExt {}
+impl InsertExt for ForceNewlineForInsertsAroundEmbedExt {
+    fn ext_name(&self) -> &str { "ForceNewlineForInsertsAroundEmbedExt" }
+
+    fn apply(
+        &self,
+        _delta: &Delta,
+        _replace_len: usize,
+        _text: &str,
+        _index: usize,
+    ) -> Option<Delta> {
+        None
+    }
+}

+ 51 - 0
rust-lib/flowy-ot/src/client/extensions/insert/preserve_inline_style.rs

@@ -0,0 +1,51 @@
+use crate::{
+    client::{
+        extensions::InsertExt,
+        util::{contain_newline, OpNewline},
+    },
+    core::{AttributeKey, Attributes, CharMetric, Delta, DeltaBuilder, DeltaIter},
+};
+
+pub struct PreserveInlineStylesExt {}
+impl InsertExt for PreserveInlineStylesExt {
+    fn ext_name(&self) -> &str { "PreserveInlineStylesExt" }
+
+    fn apply(&self, delta: &Delta, replace_len: usize, text: &str, index: usize) -> Option<Delta> {
+        if contain_newline(text) {
+            return None;
+        }
+
+        let mut iter = DeltaIter::new(delta);
+        let prev = iter.next_op_before(index)?;
+        if OpNewline::parse(&prev).is_contain() {
+            return None;
+        }
+
+        let mut attributes = prev.get_attributes();
+        if attributes.is_empty() || !attributes.contains_key(&AttributeKey::Link) {
+            return Some(
+                DeltaBuilder::new()
+                    .retain(index + replace_len)
+                    .insert_with_attributes(text, attributes)
+                    .build(),
+            );
+        }
+
+        let next = iter.next_op();
+        match &next {
+            None => attributes = Attributes::empty(),
+            Some(next) => {
+                if OpNewline::parse(&next).is_equal() {
+                    attributes = Attributes::empty();
+                }
+            },
+        }
+
+        let new_delta = DeltaBuilder::new()
+            .retain(index + replace_len)
+            .insert_with_attributes(text, attributes)
+            .build();
+
+        return Some(new_delta);
+    }
+}

+ 40 - 0
rust-lib/flowy-ot/src/client/extensions/insert/reset_format_on_new_line.rs

@@ -0,0 +1,40 @@
+use crate::{
+    client::{
+        extensions::{InsertExt, NEW_LINE},
+        util::is_newline,
+    },
+    core::{AttributeKey, Attributes, CharMetric, Delta, DeltaBuilder, DeltaIter},
+};
+
+pub struct ResetLineFormatOnNewLineExt {}
+impl InsertExt for ResetLineFormatOnNewLineExt {
+    fn ext_name(&self) -> &str { "ResetLineFormatOnNewLineExt" }
+
+    fn apply(&self, delta: &Delta, replace_len: usize, text: &str, index: usize) -> Option<Delta> {
+        if !is_newline(text) {
+            return None;
+        }
+
+        let mut iter = DeltaIter::new(delta);
+        iter.seek::<CharMetric>(index);
+        let next_op = iter.next()?;
+        if !next_op.get_data().starts_with(NEW_LINE) {
+            return None;
+        }
+
+        let mut reset_attribute = Attributes::new();
+        if next_op.get_attributes().contains_key(&AttributeKey::Header) {
+            reset_attribute.add(AttributeKey::Header.value(""));
+        }
+
+        let len = index + replace_len;
+        Some(
+            DeltaBuilder::new()
+                .retain(len)
+                .insert_with_attributes(NEW_LINE, next_op.get_attributes())
+                .retain_with_attributes(1, reset_attribute)
+                .trim()
+                .build(),
+        )
+    }
+}

+ 10 - 0
rust-lib/flowy-ot/src/client/view/extension.rs → rust-lib/flowy-ot/src/client/extensions/mod.rs

@@ -1,5 +1,15 @@
+pub use delete::*;
+pub use format::*;
+pub use insert::*;
+
 use crate::core::{Attribute, Delta, Interval};
 
+mod delete;
+mod format;
+mod insert;
+
+pub const NEW_LINE: &'static str = "\n";
+
 pub type InsertExtension = Box<dyn InsertExt>;
 pub type FormatExtension = Box<dyn FormatExt>;
 pub type DeleteExtension = Box<dyn DeleteExt>;

+ 4 - 0
rust-lib/flowy-ot/src/client/mod.rs

@@ -2,5 +2,9 @@ mod document;
 mod history;
 mod view;
 
+pub mod extensions;
+mod util;
+
 pub use document::*;
 pub use history::*;
+pub use view::*;

+ 66 - 0
rust-lib/flowy-ot/src/client/util.rs

@@ -0,0 +1,66 @@
+use crate::{client::extensions::NEW_LINE, core::Operation};
+
+#[inline]
+pub fn find_newline(s: &str) -> Option<usize> {
+    match s.find(NEW_LINE) {
+        None => None,
+        Some(line_break) => Some(line_break),
+    }
+}
+
+#[derive(PartialEq, Eq)]
+pub enum OpNewline {
+    Start,
+    End,
+    Contain,
+    Equal,
+    NotFound,
+}
+
+impl OpNewline {
+    pub fn parse(op: &Operation) -> OpNewline {
+        let s = op.get_data();
+
+        if s == NEW_LINE {
+            return OpNewline::Equal;
+        }
+
+        if s.starts_with(NEW_LINE) {
+            return OpNewline::Start;
+        }
+
+        if s.ends_with(NEW_LINE) {
+            return OpNewline::End;
+        }
+
+        if s.contains(NEW_LINE) {
+            return OpNewline::Contain;
+        }
+
+        OpNewline::NotFound
+    }
+
+    pub fn is_start(&self) -> bool { self == &OpNewline::Start }
+
+    pub fn is_end(&self) -> bool { self == &OpNewline::End }
+
+    pub fn is_not_found(&self) -> bool { self == &OpNewline::NotFound }
+
+    pub fn is_contain(&self) -> bool {
+        self.is_start() || self.is_end() || self == &OpNewline::Contain
+    }
+
+    pub fn is_equal(&self) -> bool { self == &OpNewline::Equal }
+}
+
+#[inline]
+pub fn is_op_contains_newline(op: &Operation) -> bool { contain_newline(op.get_data()) }
+
+#[inline]
+pub fn is_newline(s: &str) -> bool { s == NEW_LINE }
+
+#[inline]
+pub fn is_whitespace(s: &str) -> bool { s == " " }
+
+#[inline]
+pub fn contain_newline(s: &str) -> bool { s.contains(NEW_LINE) }

+ 2 - 2
rust-lib/flowy-ot/src/client/view/view.rs → rust-lib/flowy-ot/src/client/view.rs

@@ -1,5 +1,5 @@
+use super::extensions::*;
 use crate::{
-    client::view::*,
     core::{Attribute, Delta, Interval},
     errors::{ErrorBuilder, OTError, OTErrorCode},
 };
@@ -86,7 +86,7 @@ fn construct_insert_exts() -> Vec<InsertExtension> {
         Box::new(PreserveBlockStyleOnInsertExt {}),
         Box::new(PreserveLineStyleOnSplitExt {}),
         Box::new(ResetLineFormatOnNewLineExt {}),
-        Box::new(AutoFormatLinksExt {}),
+        Box::new(AutoFormatExt {}),
         Box::new(PreserveInlineStylesExt {}),
         Box::new(DefaultInsertExt {}),
     ]

+ 0 - 173
rust-lib/flowy-ot/src/client/view/format_ext.rs

@@ -1,173 +0,0 @@
-use crate::{
-    client::view::{util::find_newline, FormatExt},
-    core::{
-        Attribute,
-        AttributeKey,
-        AttributeScope,
-        Attributes,
-        CharMetric,
-        Delta,
-        DeltaBuilder,
-        DeltaIter,
-        Interval,
-        Operation,
-    },
-};
-
-pub struct FormatLinkAtCaretPositionExt {}
-
-impl FormatExt for FormatLinkAtCaretPositionExt {
-    fn ext_name(&self) -> &str { "FormatLinkAtCaretPositionExt" }
-
-    fn apply(&self, delta: &Delta, interval: Interval, attribute: &Attribute) -> Option<Delta> {
-        if attribute.key != AttributeKey::Link || interval.size() != 0 {
-            return None;
-        }
-
-        let mut iter = DeltaIter::new(delta);
-        iter.seek::<CharMetric>(interval.start);
-
-        let (before, after) = (iter.next_op_before(interval.size()), iter.next());
-        let mut start = interval.end;
-        let mut retain = 0;
-
-        if let Some(before) = before {
-            if before.contain_attribute(attribute) {
-                start -= before.len();
-                retain += before.len();
-            }
-        }
-
-        if let Some(after) = after {
-            if after.contain_attribute(attribute) {
-                if retain != 0 {
-                    retain += after.len();
-                }
-            }
-        }
-
-        if retain == 0 {
-            return None;
-        }
-
-        Some(
-            DeltaBuilder::new()
-                .retain(start)
-                .retain_with_attributes(retain, (attribute.clone()).into())
-                .build(),
-        )
-    }
-}
-
-pub struct ResolveBlockFormatExt {}
-impl FormatExt for ResolveBlockFormatExt {
-    fn ext_name(&self) -> &str { "ResolveBlockFormatExt" }
-
-    fn apply(&self, delta: &Delta, interval: Interval, attribute: &Attribute) -> Option<Delta> {
-        if attribute.scope != AttributeScope::Block {
-            return None;
-        }
-
-        let mut new_delta = DeltaBuilder::new().retain(interval.start).build();
-        let mut iter = DeltaIter::new(delta);
-        iter.seek::<CharMetric>(interval.start);
-        let mut start = 0;
-        let end = interval.size();
-        while start < end && iter.has_next() {
-            let next_op = iter.next_op_before(end - start).unwrap();
-            match find_newline(next_op.get_data()) {
-                None => new_delta.retain(next_op.len(), Attributes::empty()),
-                Some(_) => {
-                    let tmp_delta = line_break(&next_op, attribute, AttributeScope::Block);
-                    new_delta.extend(tmp_delta);
-                },
-            }
-
-            start += next_op.len();
-        }
-
-        while iter.has_next() {
-            let op = iter
-                .next_op()
-                .expect("Unexpected None, iter.has_next() must return op");
-
-            match find_newline(op.get_data()) {
-                None => new_delta.retain(op.len(), Attributes::empty()),
-                Some(line_break) => {
-                    debug_assert_eq!(line_break, 0);
-                    new_delta.retain(1, attribute.clone().into());
-                    break;
-                },
-            }
-        }
-
-        Some(new_delta)
-    }
-}
-
-pub struct ResolveInlineFormatExt {}
-impl FormatExt for ResolveInlineFormatExt {
-    fn ext_name(&self) -> &str { "ResolveInlineFormatExt" }
-
-    fn apply(&self, delta: &Delta, interval: Interval, attribute: &Attribute) -> Option<Delta> {
-        if attribute.scope != AttributeScope::Inline {
-            return None;
-        }
-        let mut new_delta = DeltaBuilder::new().retain(interval.start).build();
-        let mut iter = DeltaIter::new(delta);
-        iter.seek::<CharMetric>(interval.start);
-
-        let mut start = 0;
-        let end = interval.size();
-
-        while start < end && iter.has_next() {
-            let next_op = iter.next_op_before(end - start).unwrap();
-            match find_newline(next_op.get_data()) {
-                None => new_delta.retain(next_op.len(), attribute.clone().into()),
-                Some(_) => {
-                    let tmp_delta = line_break(&next_op, attribute, AttributeScope::Inline);
-                    new_delta.extend(tmp_delta);
-                },
-            }
-
-            start += next_op.len();
-        }
-
-        Some(new_delta)
-    }
-}
-
-fn line_break(op: &Operation, attribute: &Attribute, scope: AttributeScope) -> Delta {
-    let mut new_delta = Delta::new();
-    let mut start = 0;
-    let end = op.len();
-    let mut s = op.get_data();
-
-    while let Some(line_break) = find_newline(s) {
-        match scope {
-            AttributeScope::Inline => {
-                new_delta.retain(line_break - start, attribute.clone().into());
-                new_delta.retain(1, Attributes::empty());
-            },
-            AttributeScope::Block => {
-                new_delta.retain(line_break - start, Attributes::empty());
-                new_delta.retain(1, attribute.clone().into());
-            },
-            _ => {
-                log::error!("Unsupported parser line break for {:?}", scope);
-            },
-        }
-
-        start = line_break + 1;
-        s = &s[start..s.len()];
-    }
-
-    if start < end {
-        match scope {
-            AttributeScope::Inline => new_delta.retain(end - start, attribute.clone().into()),
-            AttributeScope::Block => new_delta.retain(end - start, Attributes::empty()),
-            _ => log::error!("Unsupported parser line break for {:?}", scope),
-        }
-    }
-    new_delta
-}

+ 0 - 198
rust-lib/flowy-ot/src/client/view/insert_ext.rs

@@ -1,198 +0,0 @@
-use crate::{
-    client::view::InsertExt,
-    core::{AttributeKey, Attributes, CharMetric, Delta, DeltaBuilder, DeltaIter},
-};
-
-pub const NEW_LINE: &'static str = "\n";
-
-pub struct PreserveBlockStyleOnInsertExt {}
-impl InsertExt for PreserveBlockStyleOnInsertExt {
-    fn ext_name(&self) -> &str { "PreserveBlockStyleOnInsertExt" }
-
-    fn apply(
-        &self,
-        _delta: &Delta,
-        _replace_len: usize,
-        _text: &str,
-        _index: usize,
-    ) -> Option<Delta> {
-        None
-    }
-}
-
-pub struct PreserveLineStyleOnSplitExt {}
-impl InsertExt for PreserveLineStyleOnSplitExt {
-    fn ext_name(&self) -> &str { "PreserveLineStyleOnSplitExt" }
-
-    fn apply(
-        &self,
-        _delta: &Delta,
-        _replace_len: usize,
-        _text: &str,
-        _index: usize,
-    ) -> Option<Delta> {
-        None
-    }
-}
-
-pub struct AutoExitBlockExt {}
-
-impl InsertExt for AutoExitBlockExt {
-    fn ext_name(&self) -> &str { "AutoExitBlockExt" }
-
-    fn apply(
-        &self,
-        _delta: &Delta,
-        _replace_len: usize,
-        _text: &str,
-        _index: usize,
-    ) -> Option<Delta> {
-        None
-    }
-}
-
-pub struct InsertEmbedsExt {}
-impl InsertExt for InsertEmbedsExt {
-    fn ext_name(&self) -> &str { "InsertEmbedsExt" }
-
-    fn apply(
-        &self,
-        _delta: &Delta,
-        _replace_len: usize,
-        _text: &str,
-        _index: usize,
-    ) -> Option<Delta> {
-        None
-    }
-}
-
-pub struct ForceNewlineForInsertsAroundEmbedExt {}
-impl InsertExt for ForceNewlineForInsertsAroundEmbedExt {
-    fn ext_name(&self) -> &str { "ForceNewlineForInsertsAroundEmbedExt" }
-
-    fn apply(
-        &self,
-        _delta: &Delta,
-        _replace_len: usize,
-        _text: &str,
-        _index: usize,
-    ) -> Option<Delta> {
-        None
-    }
-}
-
-pub struct AutoFormatLinksExt {}
-impl InsertExt for AutoFormatLinksExt {
-    fn ext_name(&self) -> &str { "AutoFormatLinksExt" }
-
-    fn apply(
-        &self,
-        _delta: &Delta,
-        _replace_len: usize,
-        _text: &str,
-        _index: usize,
-    ) -> Option<Delta> {
-        None
-    }
-}
-
-pub struct PreserveInlineStylesExt {}
-impl InsertExt for PreserveInlineStylesExt {
-    fn ext_name(&self) -> &str { "PreserveInlineStylesExt" }
-
-    fn apply(&self, delta: &Delta, replace_len: usize, text: &str, index: usize) -> Option<Delta> {
-        if text.contains(NEW_LINE) {
-            return None;
-        }
-
-        let mut iter = DeltaIter::new(delta);
-        let prev = iter.next_op_before(index)?;
-        if prev.get_data().contains(NEW_LINE) {
-            return None;
-        }
-
-        let mut attributes = prev.get_attributes();
-        if attributes.is_empty() || !attributes.contains_key(&AttributeKey::Link) {
-            return Some(
-                DeltaBuilder::new()
-                    .retain(index + replace_len)
-                    .insert_with_attributes(text, attributes)
-                    .build(),
-            );
-        }
-
-        attributes.remove(&AttributeKey::Link);
-        let new_delta = DeltaBuilder::new()
-            .retain(index + replace_len)
-            .insert_with_attributes(text, attributes)
-            .build();
-
-        return Some(new_delta);
-    }
-}
-
-pub struct ResetLineFormatOnNewLineExt {}
-impl InsertExt for ResetLineFormatOnNewLineExt {
-    fn ext_name(&self) -> &str { "ResetLineFormatOnNewLineExt" }
-
-    fn apply(&self, delta: &Delta, replace_len: usize, text: &str, index: usize) -> Option<Delta> {
-        if text != NEW_LINE {
-            return None;
-        }
-
-        let mut iter = DeltaIter::new(delta);
-        iter.seek::<CharMetric>(index);
-        let next_op = iter.next()?;
-        if !next_op.get_data().starts_with(NEW_LINE) {
-            return None;
-        }
-
-        let mut reset_attribute = Attributes::new();
-        if next_op.get_attributes().contains_key(&AttributeKey::Header) {
-            reset_attribute.add(AttributeKey::Header.value(""));
-        }
-
-        let len = index + replace_len;
-        Some(
-            DeltaBuilder::new()
-                .retain(len)
-                .insert_with_attributes(NEW_LINE, next_op.get_attributes())
-                .retain_with_attributes(1, reset_attribute)
-                .trim()
-                .build(),
-        )
-    }
-}
-
-pub struct DefaultInsertExt {}
-impl InsertExt for DefaultInsertExt {
-    fn ext_name(&self) -> &str { "DefaultInsertExt" }
-
-    fn apply(&self, delta: &Delta, replace_len: usize, text: &str, index: usize) -> Option<Delta> {
-        let iter = DeltaIter::new(delta);
-        let mut attributes = Attributes::new();
-
-        // Enable each line split by "\n" remains the block attributes. for example:
-        // insert "\n" to "123456" at index 3
-        //
-        // [{"insert":"123"},{"insert":"\n","attributes":{"header":"1"}},
-        // {"insert":"456"},{"insert":"\n","attributes":{"header":"1"}}]
-        if text.ends_with(NEW_LINE) {
-            match iter.last() {
-                None => {},
-                Some(op) => {
-                    if op.get_attributes().contains_key(&AttributeKey::Header) {
-                        attributes.extend(op.get_attributes());
-                    }
-                },
-            }
-        }
-
-        Some(
-            DeltaBuilder::new()
-                .retain(index + replace_len)
-                .insert_with_attributes(text, attributes)
-                .build(),
-        )
-    }
-}

+ 0 - 12
rust-lib/flowy-ot/src/client/view/mod.rs

@@ -1,12 +0,0 @@
-mod delete_ext;
-mod extension;
-mod format_ext;
-mod insert_ext;
-mod util;
-mod view;
-
-pub use delete_ext::*;
-pub use extension::*;
-pub use format_ext::*;
-pub use insert_ext::*;
-pub use view::*;

+ 0 - 8
rust-lib/flowy-ot/src/client/view/util.rs

@@ -1,8 +0,0 @@
-use crate::client::view::NEW_LINE;
-
-pub fn find_newline(s: &str) -> Option<usize> {
-    match s.find(NEW_LINE) {
-        None => None,
-        Some(line_break) => Some(line_break),
-    }
-}

+ 94 - 10
rust-lib/flowy-ot/tests/attribute_test.rs

@@ -3,6 +3,8 @@ pub mod helper;
 use crate::helper::{TestOp::*, *};
 use flowy_ot::core::Interval;
 
+use flowy_ot::client::extensions::NEW_LINE;
+
 #[test]
 fn attributes_insert_text() {
     let ops = vec![
@@ -446,7 +448,7 @@ fn attributes_replace_with_text() {
 }
 
 #[test]
-fn attributes_add_header() {
+fn attributes_header_insert_newline_at_middle() {
     let ops = vec![
         Insert(0, "123456", 0),
         Header(0, Interval::new(0, 6), 1, true),
@@ -465,7 +467,32 @@ fn attributes_add_header() {
 }
 
 #[test]
-fn attributes_header_add_newline() {
+fn attributes_header_insert_newline_at_middle2() {
+    let ops = vec![
+        Insert(0, "123456", 0),
+        Header(0, Interval::new(0, 6), 1, true),
+        Insert(0, "\n", 3),
+        AssertOpsJson(
+            0,
+            r#"[{"insert":"123"},{"insert":"\n","attributes":{"header":"1"}},{"insert":"456"},{"insert":"\n","attributes":{"header":"1"}}]"#,
+        ),
+        Insert(0, "\n", 4),
+        AssertOpsJson(
+            0,
+            r#"[{"insert":"123"},{"insert":"\n\n","attributes":{"header":"1"}},{"insert":"456"},{"insert":"\n","attributes":{"header":"1"}}]"#,
+        ),
+        Insert(0, "\n", 4),
+        AssertOpsJson(
+            0,
+            r#"[{"insert":"123"},{"insert":"\n\n","attributes":{"header":"1"}},{"insert":"\n456"},{"insert":"\n","attributes":{"header":"1"}}]"#,
+        ),
+    ];
+
+    OpTester::new().run_script_with_newline(ops);
+}
+
+#[test]
+fn attributes_header_insert_newline_at_trailing() {
     let ops = vec![
         Insert(0, "123456", 0),
         Header(0, Interval::new(0, 6), 1, true),
@@ -480,24 +507,81 @@ fn attributes_header_add_newline() {
 }
 
 #[test]
-fn attributes_header_add_newline_2() {
+fn attributes_add_link() {
     let ops = vec![
         Insert(0, "123456", 0),
-        Header(0, Interval::new(0, 6), 1, true),
-        Insert(0, "\n", 3),
+        Link(0, Interval::new(0, 6), "https://appflowy.io", true),
         AssertOpsJson(
             0,
-            r#"[{"insert":"123"},{"insert":"\n","attributes":{"header":"1"}},{"insert":"456"},{"insert":"\n","attributes":{"header":"1"}}]"#,
+            r#"[{"insert":"123456","attributes":{"link":"https://appflowy.io"}},{"insert":"\n"}]"#,
         ),
-        Insert(0, "\n", 4),
+    ];
+
+    OpTester::new().run_script_with_newline(ops);
+}
+
+#[test]
+fn attributes_link_insert_char_at_head() {
+    let ops = vec![
+        Insert(0, "123456", 0),
+        Link(0, Interval::new(0, 6), "https://appflowy.io", true),
         AssertOpsJson(
             0,
-            r#"[{"insert":"123"},{"insert":"\n\n","attributes":{"header":"1"}},{"insert":"456"},{"insert":"\n","attributes":{"header":"1"}}]"#,
+            r#"[{"insert":"123456","attributes":{"link":"https://appflowy.io"}},{"insert":"\n"}]"#,
         ),
-        Insert(0, "\n", 4),
+        Insert(0, "a", 0),
         AssertOpsJson(
             0,
-            r#"[{"insert":"123"},{"insert":"\n\n","attributes":{"header":"1"}},{"insert":"\n456"},{"insert":"\n","attributes":{"header":"1"}}]"#,
+            r#"[{"insert":"a"},{"insert":"123456","attributes":{"link":"https://appflowy.io"}},{"insert":"\n"}]"#,
+        ),
+    ];
+
+    OpTester::new().run_script_with_newline(ops);
+}
+
+#[test]
+fn attributes_link_insert_char_at_middle() {
+    let ops = vec![
+        Insert(0, "1256", 0),
+        Link(0, Interval::new(0, 4), "https://appflowy.io", true),
+        Insert(0, "34", 2),
+        AssertOpsJson(
+            0,
+            r#"[{"insert":"123456","attributes":{"link":"https://appflowy.io"}},{"insert":"\n"}]"#,
+        ),
+    ];
+
+    OpTester::new().run_script_with_newline(ops);
+}
+
+#[test]
+fn attributes_link_insert_char_at_trailing() {
+    let ops = vec![
+        Insert(0, "123456", 0),
+        Link(0, Interval::new(0, 6), "https://appflowy.io", true),
+        AssertOpsJson(
+            0,
+            r#"[{"insert":"123456","attributes":{"link":"https://appflowy.io"}},{"insert":"\n"}]"#,
+        ),
+        Insert(0, "a", 6),
+        AssertOpsJson(
+            0,
+            r#"[{"insert":"123456","attributes":{"link":"https://appflowy.io"}},{"insert":"a\n"}]"#,
+        ),
+    ];
+
+    OpTester::new().run_script_with_newline(ops);
+}
+
+#[test]
+fn attributes_link_insert_newline_at_middle() {
+    let ops = vec![
+        Insert(0, "123456", 0),
+        Link(0, Interval::new(0, 6), "https://appflowy.io", true),
+        Insert(0, NEW_LINE, 3),
+        AssertOpsJson(
+            0,
+            r#"[{"insert":"123","attributes":{"link":"https://appflowy.io"}},{"insert":"\n"},{"insert":"456","attributes":{"link":"https://appflowy.io"}},{"insert":"\n"}]"#,
         ),
     ];
 

+ 24 - 13
rust-lib/flowy-ot/tests/helper/mod.rs

@@ -28,6 +28,9 @@ pub enum TestOp {
     #[display(fmt = "Header")]
     Header(usize, Interval, usize, bool),
 
+    #[display(fmt = "Link")]
+    Link(usize, Interval, &'static str, bool),
+
     #[display(fmt = "Transform")]
     Transform(usize, usize),
 
@@ -74,44 +77,52 @@ impl OpTester {
                 let document = &mut self.documents[*delta_i];
                 document.insert(*index, s).unwrap();
             },
-            TestOp::Delete(delta_i, interval) => {
+            TestOp::Delete(delta_i, iv) => {
                 let document = &mut self.documents[*delta_i];
-                document.replace(*interval, "").unwrap();
+                document.replace(*iv, "").unwrap();
             },
-            TestOp::Replace(delta_i, interval, s) => {
+            TestOp::Replace(delta_i, iv, s) => {
                 let document = &mut self.documents[*delta_i];
-                document.replace(*interval, s).unwrap();
+                document.replace(*iv, s).unwrap();
             },
-            TestOp::InsertBold(delta_i, s, interval) => {
+            TestOp::InsertBold(delta_i, s, iv) => {
                 let document = &mut self.documents[*delta_i];
-                document.insert(interval.start, s).unwrap();
+                document.insert(iv.start, s).unwrap();
                 document
-                    .format(*interval, AttributeKey::Bold.value(true))
+                    .format(*iv, AttributeKey::Bold.value(true))
                     .unwrap();
             },
-            TestOp::Bold(delta_i, interval, enable) => {
+            TestOp::Bold(delta_i, iv, enable) => {
                 let document = &mut self.documents[*delta_i];
                 let attribute = match *enable {
                     true => AttributeKey::Bold.value(true),
                     false => AttributeKey::Bold.remove(),
                 };
-                document.format(*interval, attribute).unwrap();
+                document.format(*iv, attribute).unwrap();
             },
-            TestOp::Italic(delta_i, interval, enable) => {
+            TestOp::Italic(delta_i, iv, enable) => {
                 let document = &mut self.documents[*delta_i];
                 let attribute = match *enable {
                     true => AttributeKey::Italic.value("true"),
                     false => AttributeKey::Italic.remove(),
                 };
-                document.format(*interval, attribute).unwrap();
+                document.format(*iv, attribute).unwrap();
             },
-            TestOp::Header(delta_i, interval, level, enable) => {
+            TestOp::Header(delta_i, iv, level, enable) => {
                 let document = &mut self.documents[*delta_i];
                 let attribute = match *enable {
                     true => AttributeKey::Header.value(level),
                     false => AttributeKey::Header.remove(),
                 };
-                document.format(*interval, attribute).unwrap();
+                document.format(*iv, attribute).unwrap();
+            },
+            TestOp::Link(delta_i, iv, link, enable) => {
+                let document = &mut self.documents[*delta_i];
+                let attribute = match *enable {
+                    true => AttributeKey::Link.value(link.to_owned()),
+                    false => AttributeKey::Link.remove(),
+                };
+                document.format(*iv, attribute).unwrap();
             },
             TestOp::Transform(delta_a_i, delta_b_i) => {
                 let (a_prime, b_prime) = self.documents[*delta_a_i]

+ 33 - 15
rust-lib/flowy-ot/tests/undo_redo_test.rs

@@ -4,7 +4,7 @@ use crate::helper::{TestOp::*, *};
 use flowy_ot::{client::RECORD_THRESHOLD, core::Interval};
 
 #[test]
-fn delta_undo_insert() {
+fn history_undo_insert() {
     let ops = vec![
         Insert(0, "123", 0),
         Undo(0),
@@ -14,7 +14,7 @@ fn delta_undo_insert() {
 }
 
 #[test]
-fn delta_undo_insert2() {
+fn history_undo_insert2() {
     let ops = vec![
         Insert(0, "123", 0),
         Wait(RECORD_THRESHOLD),
@@ -28,7 +28,7 @@ fn delta_undo_insert2() {
 }
 
 #[test]
-fn delta_redo_insert() {
+fn history_redo_insert() {
     let ops = vec![
         Insert(0, "123", 0),
         AssertOpsJson(0, r#"[{"insert":"123\n"}]"#),
@@ -41,7 +41,7 @@ fn delta_redo_insert() {
 }
 
 #[test]
-fn delta_redo_insert_with_lagging() {
+fn history_redo_insert_with_lagging() {
     let ops = vec![
         Insert(0, "123", 0),
         Wait(RECORD_THRESHOLD),
@@ -60,7 +60,7 @@ fn delta_redo_insert_with_lagging() {
 }
 
 #[test]
-fn delta_undo_attributes() {
+fn history_undo_attributes() {
     let ops = vec![
         Insert(0, "123", 0),
         Bold(0, Interval::new(0, 3), true),
@@ -71,7 +71,7 @@ fn delta_undo_attributes() {
 }
 
 #[test]
-fn delta_undo_attributes_with_lagging() {
+fn history_undo_attributes_with_lagging() {
     let ops = vec![
         Insert(0, "123", 0),
         Wait(RECORD_THRESHOLD),
@@ -83,7 +83,7 @@ fn delta_undo_attributes_with_lagging() {
 }
 
 #[test]
-fn delta_redo_attributes() {
+fn history_redo_attributes() {
     let ops = vec![
         Insert(0, "123", 0),
         Bold(0, Interval::new(0, 3), true),
@@ -99,7 +99,7 @@ fn delta_redo_attributes() {
 }
 
 #[test]
-fn delta_redo_attributes_with_lagging() {
+fn history_redo_attributes_with_lagging() {
     let ops = vec![
         Insert(0, "123", 0),
         Wait(RECORD_THRESHOLD),
@@ -116,7 +116,7 @@ fn delta_redo_attributes_with_lagging() {
 }
 
 #[test]
-fn delta_undo_delete() {
+fn history_undo_delete() {
     let ops = vec![
         Insert(0, "123", 0),
         AssertOpsJson(0, r#"[{"insert":"123"}]"#),
@@ -129,7 +129,7 @@ fn delta_undo_delete() {
 }
 
 #[test]
-fn delta_undo_delete2() {
+fn history_undo_delete2() {
     let ops = vec![
         Insert(0, "123", 0),
         Bold(0, Interval::new(0, 3), true),
@@ -148,7 +148,7 @@ fn delta_undo_delete2() {
 }
 
 #[test]
-fn delta_undo_delete2_with_lagging() {
+fn history_undo_delete2_with_lagging() {
     let ops = vec![
         Insert(0, "123", 0),
         Wait(RECORD_THRESHOLD),
@@ -175,7 +175,7 @@ fn delta_undo_delete2_with_lagging() {
 }
 
 #[test]
-fn delta_redo_delete() {
+fn history_redo_delete() {
     let ops = vec![
         Insert(0, "123", 0),
         Wait(RECORD_THRESHOLD),
@@ -189,7 +189,7 @@ fn delta_redo_delete() {
 }
 
 #[test]
-fn delta_undo_replace() {
+fn history_undo_replace() {
     let ops = vec![
         Insert(0, "123", 0),
         Bold(0, Interval::new(0, 3), true),
@@ -208,7 +208,7 @@ fn delta_undo_replace() {
 }
 
 #[test]
-fn delta_undo_replace_with_lagging() {
+fn history_undo_replace_with_lagging() {
     let ops = vec![
         Insert(0, "123", 0),
         Wait(RECORD_THRESHOLD),
@@ -232,7 +232,7 @@ fn delta_undo_replace_with_lagging() {
 }
 
 #[test]
-fn delta_redo_replace() {
+fn history_redo_replace() {
     let ops = vec![
         Insert(0, "123", 0),
         Bold(0, Interval::new(0, 3), true),
@@ -249,3 +249,21 @@ fn delta_redo_replace() {
     ];
     OpTester::new().run_script_with_newline(ops);
 }
+
+#[test]
+fn history_undo_add_header() {
+    let ops = vec![
+        Insert(0, "123456", 0),
+        Header(0, Interval::new(0, 6), 1, true),
+        Insert(0, "\n", 3),
+        Insert(0, "\n", 4),
+        Undo(0),
+        Redo(0),
+        AssertOpsJson(
+            0,
+            r#"[{"insert":"123"},{"insert":"\n\n","attributes":{"header":"1"}},{"insert":"456"},{"insert":"\n","attributes":{"header":"1"}}]"#,
+        ),
+    ];
+
+    OpTester::new().run_script_with_newline(ops);
+}