Sfoglia il codice sorgente

feat: Support ui update when receive doc changes (#2270)

* fix: add  method

* fix: update text block and doc title

* fix: support ui update when receive doc changes

* fix: modify the subscribe change

* chore: add test for document manager

* chore: add test for document manager

* chore: add insert and update test for document manager

* fix: load document data

* fix: add update page block test

* fix: try fix again

* fix: node can not rerender when the node data change

* fix: it should cover all content when the text delta updated

* fix: add insert and delete operation in left menu

* fix: put the UI Actions in async thunks

* fix: remove log

* fix: split text block

* fix: review code

---------

Co-authored-by: Lucas.Xu <[email protected]>
Co-authored-by: nathan <[email protected]>
qinluhe 2 anni fa
parent
commit
973cd9194d
43 ha cambiato i file con 1364 aggiunte e 269 eliminazioni
  1. 71 57
      frontend/appflowy_tauri/src-tauri/Cargo.lock
  2. 30 0
      frontend/appflowy_tauri/src/appflowy_app/components/document/BlockMenu/BlockMenu.hooks.ts
  3. 31 0
      frontend/appflowy_tauri/src/appflowy_app/components/document/BlockMenu/MenuItem.hooks.ts
  4. 32 0
      frontend/appflowy_tauri/src/appflowy_app/components/document/BlockMenu/MenuItem.tsx
  5. 42 0
      frontend/appflowy_tauri/src/appflowy_app/components/document/BlockMenu/index.tsx
  6. 20 30
      frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSideTools/BlockSideTools.hooks.tsx
  7. 27 23
      frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSideTools/index.tsx
  8. 1 1
      frontend/appflowy_tauri/src/appflowy_app/components/document/DocumentTitle/index.tsx
  9. 8 10
      frontend/appflowy_tauri/src/appflowy_app/components/document/Node/index.tsx
  10. 102 6
      frontend/appflowy_tauri/src/appflowy_app/components/document/TextBlock/TextBlock.hooks.ts
  11. 14 12
      frontend/appflowy_tauri/src/appflowy_app/components/document/TextBlock/index.tsx
  12. 6 1
      frontend/appflowy_tauri/src/appflowy_app/components/document/VirtualizedList/index.tsx
  13. 2 2
      frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/HoveringToolbar/index.hooks.ts
  14. 1 1
      frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/HoveringToolbar/index.tsx
  15. 46 10
      frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/TextInput.hooks.ts
  16. 9 8
      frontend/appflowy_tauri/src/appflowy_app/components/layout/NavigationPanel/FolderItem.hooks.ts
  17. 9 1
      frontend/appflowy_tauri/src/appflowy_app/interfaces/document.ts
  18. 118 5
      frontend/appflowy_tauri/src/appflowy_app/stores/effects/document/document_controller.ts
  19. 96 0
      frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async_actions/backspace.ts
  20. 32 0
      frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async_actions/delete.ts
  21. 37 0
      frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async_actions/indent.ts
  22. 6 0
      frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async_actions/index.ts
  23. 41 0
      frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async_actions/insert.ts
  24. 26 0
      frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async_actions/outdent.ts
  25. 54 0
      frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async_actions/split.ts
  26. 1 1
      frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/region_grid.ts
  27. 98 34
      frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/slice.ts
  28. 5 0
      frontend/appflowy_tauri/src/appflowy_app/utils/block.ts
  29. 20 18
      frontend/appflowy_tauri/src/appflowy_app/views/DocumentPage.hooks.ts
  30. 22 0
      frontend/rust-lib/Cargo.lock
  31. 2 0
      frontend/rust-lib/flowy-core/src/lib.rs
  32. 7 1
      frontend/rust-lib/flowy-document2/Cargo.toml
  33. 6 0
      frontend/rust-lib/flowy-document2/src/document.rs
  34. 31 7
      frontend/rust-lib/flowy-document2/src/entities.rs
  35. 35 10
      frontend/rust-lib/flowy-document2/src/event_handler.rs
  36. 1 1
      frontend/rust-lib/flowy-document2/src/lib.rs
  37. 15 29
      frontend/rust-lib/flowy-document2/src/manager.rs
  38. 210 0
      frontend/rust-lib/flowy-document2/tests/document/document_test.rs
  39. 2 0
      frontend/rust-lib/flowy-document2/tests/document/mod.rs
  40. 47 0
      frontend/rust-lib/flowy-document2/tests/document/util.rs
  41. 0 0
      frontend/rust-lib/flowy-document2/tests/document_test.rs
  42. 1 0
      frontend/rust-lib/flowy-document2/tests/main.rs
  43. 0 1
      frontend/rust-lib/flowy-folder2/src/manager.rs

+ 71 - 57
frontend/appflowy_tauri/src-tauri/Cargo.lock

@@ -48,6 +48,15 @@ dependencies = [
  "memchr",
  "memchr",
 ]
 ]
 
 
+[[package]]
+name = "aho-corasick"
+version = "1.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "67fc08ce920c31afb70f013dcce1bfc3a3195de6a228474e45e1f145b36f8d04"
+dependencies = [
+ "memchr",
+]
+
 [[package]]
 [[package]]
 name = "alloc-no-stdlib"
 name = "alloc-no-stdlib"
 version = "2.0.4"
 version = "2.0.4"
@@ -169,7 +178,7 @@ dependencies = [
  "glib-sys",
  "glib-sys",
  "gobject-sys",
  "gobject-sys",
  "libc",
  "libc",
- "system-deps 6.0.4",
+ "system-deps 6.0.5",
 ]
 ]
 
 
 [[package]]
 [[package]]
@@ -363,9 +372,9 @@ dependencies = [
 
 
 [[package]]
 [[package]]
 name = "bumpalo"
 name = "bumpalo"
-version = "3.12.0"
+version = "3.12.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0d261e256854913907f67ed06efbc3338dfe6179796deefc1ff763fc1aee5535"
+checksum = "9b1ce199063694f33ffb7dd4e0ee620741495c32833cde5aa08f02a0bf96f0c8"
 
 
 [[package]]
 [[package]]
 name = "bytecheck"
 name = "bytecheck"
@@ -442,7 +451,7 @@ checksum = "3c55d429bef56ac9172d25fecb85dc8068307d17acd74b377866b7a1ef25d3c8"
 dependencies = [
 dependencies = [
  "glib-sys",
  "glib-sys",
  "libc",
  "libc",
- "system-deps 6.0.4",
+ "system-deps 6.0.5",
 ]
 ]
 
 
 [[package]]
 [[package]]
@@ -500,11 +509,12 @@ dependencies = [
 
 
 [[package]]
 [[package]]
 name = "cfg-expr"
 name = "cfg-expr"
-version = "0.14.0"
+version = "0.15.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a35b255461940a32985c627ce82900867c61db1659764d3675ea81963f72a4c6"
+checksum = "c8790cf1286da485c72cf5fc7aeba308438800036ec67d89425924c4807268c9"
 dependencies = [
 dependencies = [
  "smallvec",
  "smallvec",
+ "target-lexicon",
 ]
 ]
 
 
 [[package]]
 [[package]]
@@ -804,9 +814,9 @@ dependencies = [
 
 
 [[package]]
 [[package]]
 name = "cpufeatures"
 name = "cpufeatures"
-version = "0.2.6"
+version = "0.2.7"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "280a9f2d8b3a38871a3c8a46fb80db65e5e5ed97da80c4d08bf27fb63e35e181"
+checksum = "3e4c1eaa2012c47becbbad2ab175484c2a84d1185b566fb2cc5b8707343dfe58"
 dependencies = [
 dependencies = [
  "libc",
  "libc",
 ]
 ]
@@ -1154,9 +1164,9 @@ dependencies = [
 
 
 [[package]]
 [[package]]
 name = "dunce"
 name = "dunce"
-version = "1.0.3"
+version = "1.0.4"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0bd4b30a6560bbd9b4620f4de34c3f14f60848e58a9b7216801afcb4c7b31c3c"
+checksum = "56ce8c6da7551ec6c462cbaf3bfbc75131ebbfa1c944aeaa9dab51ca1c5f0c3b"
 
 
 [[package]]
 [[package]]
 name = "dyn-clone"
 name = "dyn-clone"
@@ -1539,6 +1549,7 @@ dependencies = [
  "serde_json",
  "serde_json",
  "strum",
  "strum",
  "strum_macros",
  "strum_macros",
+ "tokio",
  "tracing",
  "tracing",
 ]
 ]
 
 
@@ -2012,7 +2023,7 @@ dependencies = [
  "glib-sys",
  "glib-sys",
  "gobject-sys",
  "gobject-sys",
  "libc",
  "libc",
- "system-deps 6.0.4",
+ "system-deps 6.0.5",
 ]
 ]
 
 
 [[package]]
 [[package]]
@@ -2029,7 +2040,7 @@ dependencies = [
  "libc",
  "libc",
  "pango-sys",
  "pango-sys",
  "pkg-config",
  "pkg-config",
- "system-deps 6.0.4",
+ "system-deps 6.0.5",
 ]
 ]
 
 
 [[package]]
 [[package]]
@@ -2041,21 +2052,21 @@ dependencies = [
  "gdk-sys",
  "gdk-sys",
  "glib-sys",
  "glib-sys",
  "libc",
  "libc",
- "system-deps 6.0.4",
+ "system-deps 6.0.5",
  "x11",
  "x11",
 ]
 ]
 
 
 [[package]]
 [[package]]
 name = "generator"
 name = "generator"
-version = "0.7.3"
+version = "0.7.4"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "33a20a288a94683f5f4da0adecdbe095c94a77c295e514cc6484e9394dd8376e"
+checksum = "f3e123d9ae7c02966b4d892e550bdc32164f05853cd40ab570650ad600596a8a"
 dependencies = [
 dependencies = [
  "cc",
  "cc",
  "libc",
  "libc",
  "log",
  "log",
  "rustversion",
  "rustversion",
- "windows 0.44.0",
+ "windows 0.48.0",
 ]
 ]
 
 
 [[package]]
 [[package]]
@@ -2134,7 +2145,7 @@ dependencies = [
  "glib-sys",
  "glib-sys",
  "gobject-sys",
  "gobject-sys",
  "libc",
  "libc",
- "system-deps 6.0.4",
+ "system-deps 6.0.5",
  "winapi",
  "winapi",
 ]
 ]
 
 
@@ -2180,7 +2191,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "ef4b192f8e65e9cf76cbf4ea71fa8e3be4a0e18ffe3d68b8da6836974cc5bad4"
 checksum = "ef4b192f8e65e9cf76cbf4ea71fa8e3be4a0e18ffe3d68b8da6836974cc5bad4"
 dependencies = [
 dependencies = [
  "libc",
  "libc",
- "system-deps 6.0.4",
+ "system-deps 6.0.5",
 ]
 ]
 
 
 [[package]]
 [[package]]
@@ -2195,7 +2206,7 @@ version = "0.4.10"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "029d74589adefde59de1a0c4f4732695c32805624aec7b68d91503d4dba79afc"
 checksum = "029d74589adefde59de1a0c4f4732695c32805624aec7b68d91503d4dba79afc"
 dependencies = [
 dependencies = [
- "aho-corasick",
+ "aho-corasick 0.7.20",
  "bstr",
  "bstr",
  "fnv",
  "fnv",
  "log",
  "log",
@@ -2221,7 +2232,7 @@ checksum = "0d57ce44246becd17153bd035ab4d32cfee096a657fc01f2231c9278378d1e0a"
 dependencies = [
 dependencies = [
  "glib-sys",
  "glib-sys",
  "libc",
  "libc",
- "system-deps 6.0.4",
+ "system-deps 6.0.5",
 ]
 ]
 
 
 [[package]]
 [[package]]
@@ -2262,7 +2273,7 @@ dependencies = [
  "gobject-sys",
  "gobject-sys",
  "libc",
  "libc",
  "pango-sys",
  "pango-sys",
- "system-deps 6.0.4",
+ "system-deps 6.0.5",
 ]
 ]
 
 
 [[package]]
 [[package]]
@@ -2281,9 +2292,9 @@ dependencies = [
 
 
 [[package]]
 [[package]]
 name = "h2"
 name = "h2"
-version = "0.3.17"
+version = "0.3.18"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "66b91535aa35fea1523ad1b86cb6b53c28e0ae566ba4a460f4457e936cad7c6f"
+checksum = "17f8a914c2987b688368b5138aa05321db91f4090cf26118185672ad588bce21"
 dependencies = [
 dependencies = [
  "bytes",
  "bytes",
  "fnv",
  "fnv",
@@ -2840,9 +2851,9 @@ dependencies = [
 
 
 [[package]]
 [[package]]
 name = "libc"
 name = "libc"
-version = "0.2.141"
+version = "0.2.142"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3304a64d199bb964be99741b7a14d26972741915b3649639149b2479bb46f4b5"
+checksum = "6a987beff54b60ffa6d51982e1aa1146bc42f19bd26be28b0586f252fccf5317"
 
 
 [[package]]
 [[package]]
 name = "libloading"
 name = "libloading"
@@ -2924,9 +2935,9 @@ checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f"
 
 
 [[package]]
 [[package]]
 name = "linux-raw-sys"
 name = "linux-raw-sys"
-version = "0.3.1"
+version = "0.3.3"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d59d8c75012853d2e872fb56bc8a2e53718e2cafe1a4c823143141c6d90c322f"
+checksum = "9b085a4f2cde5781fc4b1717f2e86c62f5cda49de7ba99a7c2eae02b61c9064c"
 
 
 [[package]]
 [[package]]
 name = "lock_api"
 name = "lock_api"
@@ -2959,7 +2970,7 @@ dependencies = [
  "serde",
  "serde",
  "serde_json",
  "serde_json",
  "tracing",
  "tracing",
- "tracing-subscriber 0.3.16",
+ "tracing-subscriber 0.3.17",
 ]
 ]
 
 
 [[package]]
 [[package]]
@@ -3330,9 +3341,9 @@ dependencies = [
 
 
 [[package]]
 [[package]]
 name = "openssl"
 name = "openssl"
-version = "0.10.50"
+version = "0.10.51"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7e30d8bc91859781f0a943411186324d580f2bbeb71b452fe91ae344806af3f1"
+checksum = "97ea2d98598bf9ada7ea6ee8a30fb74f9156b63bbe495d64ec2b87c269d2dda3"
 dependencies = [
 dependencies = [
  "bitflags",
  "bitflags",
  "cfg-if",
  "cfg-if",
@@ -3362,9 +3373,9 @@ checksum = "ff011a302c396a5197692431fc1948019154afc178baf7d8e37367442a4601cf"
 
 
 [[package]]
 [[package]]
 name = "openssl-sys"
 name = "openssl-sys"
-version = "0.9.85"
+version = "0.9.86"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0d3d193fb1488ad46ffe3aaabc912cc931d02ee8518fe2959aea8ef52718b0c0"
+checksum = "992bac49bdbab4423199c654a5515bd2a6c6a23bf03f2dd3bdb7e5ae6259bc69"
 dependencies = [
 dependencies = [
  "cc",
  "cc",
  "libc",
  "libc",
@@ -3410,7 +3421,7 @@ dependencies = [
  "glib-sys",
  "glib-sys",
  "gobject-sys",
  "gobject-sys",
  "libc",
  "libc",
- "system-deps 6.0.4",
+ "system-deps 6.0.5",
 ]
 ]
 
 
 [[package]]
 [[package]]
@@ -4064,13 +4075,13 @@ dependencies = [
 
 
 [[package]]
 [[package]]
 name = "regex"
 name = "regex"
-version = "1.7.3"
+version = "1.8.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8b1f693b24f6ac912f4893ef08244d70b6067480d2f1a46e950c9691e6749d1d"
+checksum = "af83e617f331cc6ae2da5443c602dfa5af81e517212d9d611a5b3ba1777b5370"
 dependencies = [
 dependencies = [
- "aho-corasick",
+ "aho-corasick 1.0.1",
  "memchr",
  "memchr",
- "regex-syntax",
+ "regex-syntax 0.7.1",
 ]
 ]
 
 
 [[package]]
 [[package]]
@@ -4079,7 +4090,7 @@ version = "0.1.10"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "6c230d73fb8d8c1b9c0b3135c5142a8acee3a0558fb8db5cf1cb65f8d7862132"
 checksum = "6c230d73fb8d8c1b9c0b3135c5142a8acee3a0558fb8db5cf1cb65f8d7862132"
 dependencies = [
 dependencies = [
- "regex-syntax",
+ "regex-syntax 0.6.29",
 ]
 ]
 
 
 [[package]]
 [[package]]
@@ -4088,6 +4099,12 @@ version = "0.6.29"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "f162c6dd7b008981e4d40210aca20b4bd0f9b60ca9271061b07f78537722f2e1"
 checksum = "f162c6dd7b008981e4d40210aca20b4bd0f9b60ca9271061b07f78537722f2e1"
 
 
+[[package]]
+name = "regex-syntax"
+version = "0.7.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a5996294f19bd3aae0453a862ad728f60e6600695733dd5df01da90c54363a3c"
+
 [[package]]
 [[package]]
 name = "rend"
 name = "rend"
 version = "0.4.0"
 version = "0.4.0"
@@ -4209,9 +4226,9 @@ dependencies = [
 
 
 [[package]]
 [[package]]
 name = "rustc-demangle"
 name = "rustc-demangle"
-version = "0.1.22"
+version = "0.1.23"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d4a36c42d1873f9a77c53bde094f9664d9891bc604a45b4798fd2c389ed12e5b"
+checksum = "d626bb9dae77e28219937af045c257c28bfd3f69333c512553507f5f9798cb76"
 
 
 [[package]]
 [[package]]
 name = "rustc-hash"
 name = "rustc-hash"
@@ -4230,9 +4247,9 @@ dependencies = [
 
 
 [[package]]
 [[package]]
 name = "rustix"
 name = "rustix"
-version = "0.37.11"
+version = "0.37.13"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "85597d61f83914ddeba6a47b3b8ffe7365107221c2e557ed94426489fefb5f77"
+checksum = "f79bef90eb6d984c72722595b5b1348ab39275a5e5123faca6863bf07d75a4e0"
 dependencies = [
 dependencies = [
  "bitflags",
  "bitflags",
  "errno",
  "errno",
@@ -4768,11 +4785,11 @@ dependencies = [
 
 
 [[package]]
 [[package]]
 name = "system-deps"
 name = "system-deps"
-version = "6.0.4"
+version = "6.0.5"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "555fc8147af6256f3931a36bb83ad0023240ce9cf2b319dec8236fd1f220b05f"
+checksum = "d0fe581ad25d11420b873cf9aedaca0419c2b411487b134d4d21065f3d092055"
 dependencies = [
 dependencies = [
- "cfg-expr 0.14.0",
+ "cfg-expr 0.15.1",
  "heck 0.4.1",
  "heck 0.4.1",
  "pkg-config",
  "pkg-config",
  "toml 0.7.3",
  "toml 0.7.3",
@@ -4836,6 +4853,12 @@ dependencies = [
  "xattr",
  "xattr",
 ]
 ]
 
 
+[[package]]
+name = "target-lexicon"
+version = "0.12.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8ae9980cab1db3fceee2f6c6f643d5d8de2997c58ee8d25fb0cc8a9e9e7348e5"
+
 [[package]]
 [[package]]
 name = "tauri"
 name = "tauri"
 version = "1.2.4"
 version = "1.2.4"
@@ -5395,9 +5418,9 @@ dependencies = [
 
 
 [[package]]
 [[package]]
 name = "tracing-subscriber"
 name = "tracing-subscriber"
-version = "0.3.16"
+version = "0.3.17"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a6176eae26dd70d0c919749377897b54a9276bd7061339665dd68777926b5a70"
+checksum = "30a651bc37f915e81f087d86e62a18eec5f79550c7faff886f7090b4ea757c77"
 dependencies = [
 dependencies = [
  "matchers 0.1.0",
  "matchers 0.1.0",
  "nu-ansi-term",
  "nu-ansi-term",
@@ -5799,7 +5822,7 @@ dependencies = [
  "pango-sys",
  "pango-sys",
  "pkg-config",
  "pkg-config",
  "soup2-sys",
  "soup2-sys",
- "system-deps 6.0.4",
+ "system-deps 6.0.5",
 ]
 ]
 
 
 [[package]]
 [[package]]
@@ -5896,15 +5919,6 @@ dependencies = [
  "windows_x86_64_msvc 0.39.0",
  "windows_x86_64_msvc 0.39.0",
 ]
 ]
 
 
-[[package]]
-name = "windows"
-version = "0.44.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9e745dab35a0c4c77aa3ce42d595e13d2003d6902d6b08c9ef5fc326d08da12b"
-dependencies = [
- "windows-targets 0.42.2",
-]
-
 [[package]]
 [[package]]
 name = "windows"
 name = "windows"
 version = "0.48.0"
 version = "0.48.0"

+ 30 - 0
frontend/appflowy_tauri/src/appflowy_app/components/document/BlockMenu/BlockMenu.hooks.ts

@@ -0,0 +1,30 @@
+import { documentActions } from '@/appflowy_app/stores/reducers/document/slice';
+import { useAppDispatch } from '@/appflowy_app/stores/store';
+import { useRef, useState, useEffect } from 'react';
+
+export function useBlockMenu(nodeId: string, open: boolean) {
+  const ref = useRef<HTMLDivElement | null>(null);
+  const dispatch = useAppDispatch();
+  const [style, setStyle] = useState({ top: '0px', left: '0px' });
+
+  useEffect(() => {
+    if (!open) {
+      return;
+    }
+    // set selection when open
+    dispatch(documentActions.setSelectionById(nodeId));
+    // get node rect
+    const rect = document.querySelector(`[data-block-id="${nodeId}"]`)?.getBoundingClientRect();
+    if (!rect) return;
+    // set menu position
+    setStyle({
+      top: rect.top + 'px',
+      left: rect.left + 'px',
+    });
+  }, [open, nodeId]);
+
+  return {
+    ref,
+    style,
+  };
+}

+ 31 - 0
frontend/appflowy_tauri/src/appflowy_app/components/document/BlockMenu/MenuItem.hooks.ts

@@ -0,0 +1,31 @@
+import { DocumentControllerContext } from '$app/stores/effects/document/document_controller';
+import { useAppDispatch } from '@/appflowy_app/stores/store';
+import { useCallback, useContext } from 'react';
+import { insertAfterNodeThunk, deleteNodeThunk } from '@/appflowy_app/stores/reducers/document/async_actions';
+// eslint-disable-next-line no-shadow
+export enum ActionType {
+  InsertAfter = 'insertAfter',
+  Remove = 'remove',
+}
+export function useActions(id: string, type: ActionType) {
+  const dispatch = useAppDispatch();
+  const controller = useContext(DocumentControllerContext);
+
+  const insertAfter = useCallback(async () => {
+    if (!controller) return;
+    await dispatch(insertAfterNodeThunk({ id, controller }));
+  }, [id, controller, dispatch]);
+
+  const remove = useCallback(async () => {
+    if (!controller) return;
+    await dispatch(deleteNodeThunk({ id, controller }));
+  }, [id, dispatch]);
+
+  if (type === ActionType.InsertAfter) {
+    return insertAfter;
+  }
+  if (type === ActionType.Remove) {
+    return remove;
+  }
+  return;
+}

+ 32 - 0
frontend/appflowy_tauri/src/appflowy_app/components/document/BlockMenu/MenuItem.tsx

@@ -0,0 +1,32 @@
+import React from 'react';
+import DeleteIcon from '@mui/icons-material/Delete';
+import AddIcon from '@mui/icons-material/Add';
+import Button from '@mui/material/Button';
+import { ActionType, useActions } from './MenuItem.hooks';
+
+const icon: Record<ActionType, React.ReactNode> = {
+  [ActionType.InsertAfter]: <AddIcon />,
+  [ActionType.Remove]: <DeleteIcon />,
+};
+
+function MenuItem({ id, type, onClick }: { id: string; type: ActionType; onClick?: () => void }) {
+  const action = useActions(id, type);
+  return (
+    <Button
+      key={type}
+      className='w-[100%]'
+      variant={'text'}
+      color={'inherit'}
+      startIcon={icon[type]}
+      onClick={() => {
+        void action?.();
+        onClick?.();
+      }}
+      style={{ justifyContent: 'flex-start' }}
+    >
+      {type}
+    </Button>
+  );
+}
+
+export default MenuItem;

+ 42 - 0
frontend/appflowy_tauri/src/appflowy_app/components/document/BlockMenu/index.tsx

@@ -0,0 +1,42 @@
+import React from 'react';
+import { useBlockMenu } from './BlockMenu.hooks';
+import MenuItem from './MenuItem';
+import { ActionType } from '$app/components/document/BlockMenu/MenuItem.hooks';
+
+function BlockMenu({ open, onClose, nodeId }: { open: boolean; onClose: () => void; nodeId: string }) {
+  const { ref, style } = useBlockMenu(nodeId, open);
+
+  return open ? (
+    <div
+      ref={ref}
+      className='appflowy-block-menu-overlay z-1 fixed inset-0 overflow-hidden'
+      onScrollCapture={(e) => {
+        // prevent scrolling of the document when menu is open
+        e.stopPropagation();
+      }}
+      onMouseDown={(e) => {
+        // prevent menu from taking focus away from editor
+        e.preventDefault();
+        e.stopPropagation();
+      }}
+      onClick={(e) => {
+        e.stopPropagation();
+        onClose();
+      }}
+    >
+      <div
+        className='z-99 absolute flex w-[200px] translate-x-[-100%] translate-y-[32px] transform flex-col items-start justify-items-start rounded bg-white p-4 shadow'
+        style={style}
+        onClick={(e) => {
+          // prevent menu close when clicking on menu
+          e.stopPropagation();
+        }}
+      >
+        <MenuItem id={nodeId} type={ActionType.InsertAfter} />
+        <MenuItem id={nodeId} type={ActionType.Remove} onClick={onClose} />
+      </div>
+    </div>
+  ) : null;
+}
+
+export default React.memo(BlockMenu);

+ 20 - 30
frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSideTools/BlockSideTools.hooks.tsx

@@ -1,16 +1,14 @@
 import { BlockType, HeadingBlockData } from '@/appflowy_app/interfaces/document';
 import { BlockType, HeadingBlockData } from '@/appflowy_app/interfaces/document';
 import { useAppSelector } from '@/appflowy_app/stores/store';
 import { useAppSelector } from '@/appflowy_app/stores/store';
 import { debounce } from '@/appflowy_app/utils/tool';
 import { debounce } from '@/appflowy_app/utils/tool';
-import { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
-import { DocumentControllerContext } from '$app/stores/effects/document/document_controller';
-import { Node } from '@/appflowy_app/stores/reducers/document/slice';
-import { v4 } from 'uuid';
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
 
 
 export function useBlockSideTools({ container }: { container: HTMLDivElement }) {
 export function useBlockSideTools({ container }: { container: HTMLDivElement }) {
   const [nodeId, setHoverNodeId] = useState<string>('');
   const [nodeId, setHoverNodeId] = useState<string>('');
+  const [menuOpen, setMenuOpen] = useState(false);
   const ref = useRef<HTMLDivElement | null>(null);
   const ref = useRef<HTMLDivElement | null>(null);
   const nodes = useAppSelector((state) => state.document.nodes);
   const nodes = useAppSelector((state) => state.document.nodes);
-  const { insertAfter } = useController();
+  const nodesRef = useRef(nodes);
 
 
   const handleMouseMove = useCallback((e: MouseEvent) => {
   const handleMouseMove = useCallback((e: MouseEvent) => {
     const { clientX, clientY } = e;
     const { clientX, clientY } = e;
@@ -20,7 +18,7 @@ export function useBlockSideTools({ container }: { container: HTMLDivElement })
     if (!id) {
     if (!id) {
       setHoverNodeId('');
       setHoverNodeId('');
     } else {
     } else {
-      if ([BlockType.ColumnBlock].includes(nodes[id].type)) {
+      if ([BlockType.ColumnBlock].includes(nodesRef.current[id].type)) {
         setHoverNodeId('');
         setHoverNodeId('');
         return;
         return;
       }
       }
@@ -34,13 +32,13 @@ export function useBlockSideTools({ container }: { container: HTMLDivElement })
     const el = ref.current;
     const el = ref.current;
     if (!el || !nodeId) return;
     if (!el || !nodeId) return;
 
 
-    const node = nodes[nodeId];
+    const node = nodesRef.current[nodeId];
     if (!node) {
     if (!node) {
       el.style.opacity = '0';
       el.style.opacity = '0';
-      el.style.zIndex = '-1';
+      el.style.pointerEvents = 'none';
     } else {
     } else {
       el.style.opacity = '1';
       el.style.opacity = '1';
-      el.style.zIndex = '1';
+      el.style.pointerEvents = 'auto';
       el.style.top = '1px';
       el.style.top = '1px';
       if (node?.type === BlockType.HeadingBlock) {
       if (node?.type === BlockType.HeadingBlock) {
         const nodeData = node.data as HeadingBlockData;
         const nodeData = node.data as HeadingBlockData;
@@ -53,12 +51,14 @@ export function useBlockSideTools({ container }: { container: HTMLDivElement })
         }
         }
       }
       }
     }
     }
-  }, [nodeId, nodes]);
+  }, [nodeId]);
 
 
-  const handleAddClick = useCallback(() => {
-    if (!nodeId) return;
-    insertAfter(nodes[nodeId]);
-  }, [nodeId, nodes]);
+  const handleToggleMenu = useCallback((isOpen: boolean) => {
+    setMenuOpen(isOpen);
+    if (!isOpen) {
+      setHoverNodeId('');
+    }
+  }, []);
 
 
   useEffect(() => {
   useEffect(() => {
     container.addEventListener('mousemove', debounceMove);
     container.addEventListener('mousemove', debounceMove);
@@ -67,25 +67,15 @@ export function useBlockSideTools({ container }: { container: HTMLDivElement })
     };
     };
   }, [debounceMove]);
   }, [debounceMove]);
 
 
+  useEffect(() => {
+    nodesRef.current = nodes;
+  }, [nodes]);
+
   return {
   return {
     nodeId,
     nodeId,
     ref,
     ref,
-    handleAddClick,
-  };
-}
-
-function useController() {
-  const controller = useContext(DocumentControllerContext);
-
-  const insertAfter = useCallback((node: Node) => {
-    const parentId = node.parent;
-    if (!parentId || !controller) return;
-
-    //
-  }, []);
-
-  return {
-    insertAfter,
+    handleToggleMenu,
+    menuOpen,
   };
   };
 }
 }
 
 

+ 27 - 23
frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSideTools/index.tsx

@@ -1,36 +1,40 @@
 import React from 'react';
 import React from 'react';
 import { useBlockSideTools } from './BlockSideTools.hooks';
 import { useBlockSideTools } from './BlockSideTools.hooks';
-import AddIcon from '@mui/icons-material/Add';
-import DragIndicatorIcon from '@mui/icons-material/DragIndicator';
+import ExpandCircleDownSharpIcon from '@mui/icons-material/ExpandCircleDownSharp';
+import DragIndicatorRoundedIcon from '@mui/icons-material/DragIndicatorRounded';
 import Portal from '../BlockPortal';
 import Portal from '../BlockPortal';
 import { IconButton } from '@mui/material';
 import { IconButton } from '@mui/material';
+import BlockMenu from '../BlockMenu';
 
 
 const sx = { height: 24, width: 24 };
 const sx = { height: 24, width: 24 };
 
 
 export default function BlockSideTools(props: { container: HTMLDivElement }) {
 export default function BlockSideTools(props: { container: HTMLDivElement }) {
-  const { nodeId, ref, handleAddClick } = useBlockSideTools(props);
+  const { nodeId, ref, menuOpen, handleToggleMenu } = useBlockSideTools(props);
 
 
   if (!nodeId) return null;
   if (!nodeId) return null;
   return (
   return (
-    <Portal blockId={nodeId}>
-      <div
-        ref={ref}
-        style={{
-          opacity: 0,
-        }}
-        className='z-1 absolute left-[-50px] inline-flex h-[calc(1.5em_+_3px)] transition-opacity duration-500'
-        onMouseDown={(e) => {
-          // prevent toolbar from taking focus away from editor
-          e.preventDefault();
-        }}
-      >
-        <IconButton onClick={() => handleAddClick()} sx={sx}>
-          <AddIcon />
-        </IconButton>
-        <IconButton sx={sx}>
-          <DragIndicatorIcon />
-        </IconButton>
-      </div>
-    </Portal>
+    <>
+      <Portal blockId={nodeId}>
+        <div
+          ref={ref}
+          style={{
+            opacity: 0,
+          }}
+          className='absolute left-[-50px] inline-flex h-[calc(1.5em_+_3px)] transition-opacity duration-500'
+          onMouseDown={(e) => {
+            // prevent toolbar from taking focus away from editor
+            e.preventDefault();
+          }}
+        >
+          <IconButton onClick={() => handleToggleMenu(true)} sx={sx}>
+            <ExpandCircleDownSharpIcon />
+          </IconButton>
+          <IconButton sx={sx}>
+            <DragIndicatorRoundedIcon />
+          </IconButton>
+        </div>
+      </Portal>
+      <BlockMenu open={menuOpen} onClose={() => handleToggleMenu(false)} nodeId={nodeId} />
+    </>
   );
   );
 }
 }

+ 1 - 1
frontend/appflowy_tauri/src/appflowy_app/components/document/DocumentTitle/index.tsx

@@ -8,7 +8,7 @@ export default function DocumentTitle({ id }: { id: string }) {
   if (!node) return null;
   if (!node) return null;
   return (
   return (
     <NodeContext.Provider value={node}>
     <NodeContext.Provider value={node}>
-      <div data-block-id={node.id} className='doc-title relative pt-[50px] text-4xl font-bold'>
+      <div data-block-id={node.id} className='doc-title relative mb-2 px-2 pt-[50px] text-4xl font-bold'>
         <TextBlock placeholder='Untitled' childIds={[]} node={node} />
         <TextBlock placeholder='Untitled' childIds={[]} node={node} />
       </div>
       </div>
     </NodeContext.Provider>
     </NodeContext.Provider>

+ 8 - 10
frontend/appflowy_tauri/src/appflowy_app/components/document/Node/index.tsx

@@ -9,28 +9,26 @@ import { NodeContext } from '../_shared/SubscribeNode.hooks';
 function NodeComponent({ id, ...props }: { id: string } & React.HTMLAttributes<HTMLDivElement>) {
 function NodeComponent({ id, ...props }: { id: string } & React.HTMLAttributes<HTMLDivElement>) {
   const { node, childIds, isSelected, ref } = useNode(id);
   const { node, childIds, isSelected, ref } = useNode(id);
 
 
-  console.log('=====', id);
-  const renderBlock = useCallback((_props: { node: Node; childIds?: string[] }) => {
-    switch (_props.node.type) {
+  const renderBlock = useCallback(() => {
+    switch (node.type) {
       case 'text': {
       case 'text': {
         return <TextBlock node={node} childIds={childIds} />;
         return <TextBlock node={node} childIds={childIds} />;
       }
       }
       default:
       default:
         break;
         break;
     }
     }
-  }, []);
+  }, [node, childIds]);
 
 
   if (!node) return null;
   if (!node) return null;
 
 
   return (
   return (
     <NodeContext.Provider value={node}>
     <NodeContext.Provider value={node}>
-      <div {...props} ref={ref} data-block-id={node.id} className={`relative my-[2px] px-[2px] ${props.className}`}>
-        {renderBlock({
-          node,
-          childIds,
-        })}
+      <div {...props} ref={ref} data-block-id={node.id} className={`relative px-2  ${props.className}`}>
+        {renderBlock()}
         <div className='block-overlay' />
         <div className='block-overlay' />
-        {isSelected ? <div className='pointer-events-none absolute inset-0 z-[-1] rounded-[4px] bg-[#E0F8FF]' /> : null}
+        {isSelected ? (
+          <div className='pointer-events-none absolute inset-0 z-[-1] m-[1px] rounded-[4px] bg-[#E0F8FF]' />
+        ) : null}
       </div>
       </div>
     </NodeContext.Provider>
     </NodeContext.Provider>
   );
   );

+ 102 - 6
frontend/appflowy_tauri/src/appflowy_app/components/document/TextBlock/TextBlock.hooks.ts

@@ -1,16 +1,29 @@
 import { triggerHotkey } from '@/appflowy_app/utils/slate/hotkey';
 import { triggerHotkey } from '@/appflowy_app/utils/slate/hotkey';
-import { useCallback, useState } from 'react';
-import { Descendant, Range } from 'slate';
+import { useCallback, useContext, useState } from 'react';
+import { Descendant, Range, Editor, Element, Text, Location } from 'slate';
 import { TextDelta } from '$app/interfaces/document';
 import { TextDelta } from '$app/interfaces/document';
 import { useTextInput } from '../_shared/TextInput.hooks';
 import { useTextInput } from '../_shared/TextInput.hooks';
+import { useAppDispatch } from '@/appflowy_app/stores/store';
+import { DocumentControllerContext } from '@/appflowy_app/stores/effects/document/document_controller';
+import {
+  backspaceNodeThunk,
+  indentNodeThunk,
+  splitNodeThunk,
+} from '@/appflowy_app/stores/reducers/document/async_actions';
+import { TextSelection } from '@/appflowy_app/stores/reducers/document/slice';
 
 
-export function useTextBlock(delta: TextDelta[]) {
-  const { editor } = useTextInput(delta);
+export function useTextBlock(id: string, delta: TextDelta[]) {
+  const { editor, onSelectionChange } = useTextInput(id, delta);
   const [value, setValue] = useState<Descendant[]>([]);
   const [value, setValue] = useState<Descendant[]>([]);
-
+  const { onTab, onBackSpace, onEnter } = useActions(id);
   const onChange = useCallback(
   const onChange = useCallback(
     (e: Descendant[]) => {
     (e: Descendant[]) => {
       setValue(e);
       setValue(e);
+      editor.operations.forEach((op) => {
+        if (op.type === 'set_selection') {
+          onSelectionChange(op.newProperties as TextSelection);
+        }
+      });
     },
     },
     [editor]
     [editor]
   );
   );
@@ -18,19 +31,40 @@ export function useTextBlock(delta: TextDelta[]) {
   const onKeyDownCapture = (event: React.KeyboardEvent<HTMLDivElement>) => {
   const onKeyDownCapture = (event: React.KeyboardEvent<HTMLDivElement>) => {
     switch (event.key) {
     switch (event.key) {
       case 'Enter': {
       case 'Enter': {
+        if (!editor.selection) return;
         event.stopPropagation();
         event.stopPropagation();
         event.preventDefault();
         event.preventDefault();
+        const retainRange = getRetainRangeBy(editor);
+        const retain = getDelta(editor, retainRange);
+        const insertRange = getInsertRangeBy(editor);
+        const insert = getDelta(editor, insertRange);
+        void (async () => {
+          await onEnter(retain, insert);
+        })();
         return;
         return;
       }
       }
       case 'Backspace': {
       case 'Backspace': {
         if (!editor.selection) return;
         if (!editor.selection) return;
+
         const { anchor } = editor.selection;
         const { anchor } = editor.selection;
         const isCollapsed = Range.isCollapsed(editor.selection);
         const isCollapsed = Range.isCollapsed(editor.selection);
         if (isCollapsed && anchor.offset === 0 && anchor.path.toString() === '0,0') {
         if (isCollapsed && anchor.offset === 0 && anchor.path.toString() === '0,0') {
           event.stopPropagation();
           event.stopPropagation();
           event.preventDefault();
           event.preventDefault();
-          return;
+          void (async () => {
+            await onBackSpace();
+          })();
         }
         }
+        return;
+      }
+      case 'Tab': {
+        event.stopPropagation();
+        event.preventDefault();
+        void (async () => {
+          await onTab();
+        })();
+
+        return;
       }
       }
     }
     }
     triggerHotkey(event, editor);
     triggerHotkey(event, editor);
@@ -53,3 +87,65 @@ export function useTextBlock(delta: TextDelta[]) {
     value,
     value,
   };
   };
 }
 }
+
+function useActions(id: string) {
+  const dispatch = useAppDispatch();
+  const controller = useContext(DocumentControllerContext);
+
+  const onTab = useCallback(async () => {
+    if (!controller) return;
+    await dispatch(
+      indentNodeThunk({
+        id,
+        controller,
+      })
+    );
+  }, [id, controller]);
+
+  const onBackSpace = useCallback(async () => {
+    if (!controller) return;
+    await dispatch(backspaceNodeThunk({ id, controller }));
+  }, [controller, id]);
+
+  const onEnter = useCallback(
+    async (retain: TextDelta[], insert: TextDelta[]) => {
+      if (!controller) return;
+      await dispatch(splitNodeThunk({ id, retain, insert, controller }));
+    },
+    [controller, id]
+  );
+
+  return {
+    onTab,
+    onBackSpace,
+    onEnter,
+  };
+}
+
+function getDelta(editor: Editor, at: Location): TextDelta[] {
+  const baseElement = Editor.fragment(editor, at)[0] as Element;
+  return baseElement.children.map((item) => {
+    const { text, ...attributes } = item as Text;
+    return {
+      insert: text,
+      attributes,
+    };
+  });
+}
+
+function getRetainRangeBy(editor: Editor) {
+  const start = Editor.start(editor, editor.selection!);
+  return {
+    anchor: { path: [0, 0], offset: 0 },
+    focus: start,
+  };
+}
+
+function getInsertRangeBy(editor: Editor) {
+  const end = Editor.end(editor, editor.selection!);
+  const fragment = (editor.children[0] as Element).children;
+  return {
+    anchor: end,
+    focus: { path: [0, fragment.length - 1], offset: (fragment[fragment.length - 1] as Text).text.length },
+  };
+}

+ 14 - 12
frontend/appflowy_tauri/src/appflowy_app/components/document/TextBlock/index.tsx

@@ -17,19 +17,21 @@ function TextBlock({
   placeholder?: string;
   placeholder?: string;
 } & React.HTMLAttributes<HTMLDivElement>) {
 } & React.HTMLAttributes<HTMLDivElement>) {
   const delta = useMemo(() => node.data.delta || [], [node.data.delta]);
   const delta = useMemo(() => node.data.delta || [], [node.data.delta]);
-  const { editor, value, onChange, onKeyDownCapture, onDOMBeforeInput } = useTextBlock(delta);
+  const { editor, value, onChange, onKeyDownCapture, onDOMBeforeInput } = useTextBlock(node.id, delta);
 
 
   return (
   return (
-    <div {...props} className={`py-[2px] ${props.className}`}>
-      <Slate editor={editor} onChange={onChange} value={value}>
-        <HoveringToolbar id={node.id} />
-        <Editable
-          onKeyDownCapture={onKeyDownCapture}
-          onDOMBeforeInput={onDOMBeforeInput}
-          renderLeaf={(leafProps) => <Leaf {...leafProps} />}
-          placeholder={placeholder || 'Please enter some text...'}
-        />
-      </Slate>
+    <>
+      <div {...props} className={`py-[2px] ${props.className}`}>
+        <Slate editor={editor} onChange={onChange} value={value}>
+          <HoveringToolbar id={node.id} />
+          <Editable
+            onKeyDownCapture={onKeyDownCapture}
+            onDOMBeforeInput={onDOMBeforeInput}
+            renderLeaf={(leafProps) => <Leaf {...leafProps} />}
+            placeholder={placeholder || 'Please enter some text...'}
+          />
+        </Slate>
+      </div>
       {childIds && childIds.length > 0 ? (
       {childIds && childIds.length > 0 ? (
         <div className='pl-[1.5em]'>
         <div className='pl-[1.5em]'>
           {childIds.map((item) => (
           {childIds.map((item) => (
@@ -37,7 +39,7 @@ function TextBlock({
           ))}
           ))}
         </div>
         </div>
       ) : null}
       ) : null}
-    </div>
+    </>
   );
   );
 }
 }
 
 

+ 6 - 1
frontend/appflowy_tauri/src/appflowy_app/components/document/VirtualizedList/index.tsx

@@ -42,7 +42,12 @@ export default function VirtualizedList({
               {virtualItems.map((virtualRow) => {
               {virtualItems.map((virtualRow) => {
                 const id = childIds[virtualRow.index];
                 const id = childIds[virtualRow.index];
                 return (
                 return (
-                  <div className='p-[1px]' key={id} data-index={virtualRow.index} ref={virtualize.measureElement}>
+                  <div
+                    className='float-left w-[100%]'
+                    key={id}
+                    data-index={virtualRow.index}
+                    ref={virtualize.measureElement}
+                  >
                     {virtualRow.index === 0 ? <DocumentTitle id={node.id} /> : null}
                     {virtualRow.index === 0 ? <DocumentTitle id={node.id} /> : null}
                     {renderNode(id)}
                     {renderNode(id)}
                   </div>
                   </div>

+ 2 - 2
frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/HoveringToolbar/index.hooks.ts

@@ -16,10 +16,10 @@ export function useHoveringToolbar(id: string) {
 
 
     if (!position) {
     if (!position) {
       el.style.opacity = '0';
       el.style.opacity = '0';
-      el.style.zIndex = '-1';
+      el.style.pointerEvents = 'none';
     } else {
     } else {
       el.style.opacity = '1';
       el.style.opacity = '1';
-      el.style.zIndex = '1';
+      el.style.pointerEvents = 'auto';
       el.style.top = position.top;
       el.style.top = position.top;
       el.style.left = position.left;
       el.style.left = position.left;
     }
     }

+ 1 - 1
frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/HoveringToolbar/index.tsx

@@ -13,7 +13,7 @@ const HoveringToolbar = ({ id }: { id: string }) => {
         style={{
         style={{
           opacity: 0,
           opacity: 0,
         }}
         }}
-        className='z-1 absolute mt-[-6px] inline-flex h-[32px] items-stretch overflow-hidden rounded-[8px] bg-[#333] p-2 leading-tight shadow-lg transition-opacity duration-700'
+        className='absolute mt-[-6px] inline-flex h-[32px] items-stretch overflow-hidden rounded-[8px] bg-[#333] p-2 leading-tight shadow-lg transition-opacity duration-700'
         onMouseDown={(e) => {
         onMouseDown={(e) => {
           // prevent toolbar from taking focus away from editor
           // prevent toolbar from taking focus away from editor
           e.preventDefault();
           e.preventDefault();

+ 46 - 10
frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/TextInput.hooks.ts

@@ -1,27 +1,53 @@
 import { useCallback, useContext, useMemo, useRef, useEffect } from 'react';
 import { useCallback, useContext, useMemo, useRef, useEffect } from 'react';
 import { DocumentControllerContext } from '$app/stores/effects/document/document_controller';
 import { DocumentControllerContext } from '$app/stores/effects/document/document_controller';
-import { TextDelta, BlockActionType } from '$app/interfaces/document';
+import { TextDelta } from '$app/interfaces/document';
 import { debounce } from '@/appflowy_app/utils/tool';
 import { debounce } from '@/appflowy_app/utils/tool';
-import { createEditor } from 'slate';
-import { withReact } from 'slate-react';
+import { NodeContext } from './SubscribeNode.hooks';
+import { BlockActionTypePB } from '@/services/backend/models/flowy-document2';
+import { useAppDispatch, useAppSelector } from '@/appflowy_app/stores/store';
+import { documentActions, TextSelection } from '@/appflowy_app/stores/reducers/document/slice';
+
+import { createEditor, Transforms } from 'slate';
+import { withReact, ReactEditor } from 'slate-react';
 
 
 import * as Y from 'yjs';
 import * as Y from 'yjs';
 import { withYjs, YjsEditor, slateNodesToInsertDelta } from '@slate-yjs/core';
 import { withYjs, YjsEditor, slateNodesToInsertDelta } from '@slate-yjs/core';
-import { NodeContext } from './SubscribeNode.hooks';
-import { BlockActionTypePB } from '@/services/backend/models/flowy-document2';
 
 
-export function useTextInput(delta: TextDelta[]) {
+export function useTextInput(id: string, delta: TextDelta[]) {
   const { sendDelta } = useTransact();
   const { sendDelta } = useTransact();
-  const { editor } = useBindYjs(delta, sendDelta);
+  const { editor, yText } = useBindYjs(delta, sendDelta);
+  const dispatch = useAppDispatch();
+  const currentSelection = useAppSelector((state) => state.document.textSelections[id]);
+
+  useEffect(() => {
+    if (!currentSelection || !currentSelection.anchor || !currentSelection.focus) return;
+    ReactEditor.focus(editor);
+    Transforms.select(editor, currentSelection);
+  }, [currentSelection, editor]);
+
+  const onSelectionChange = useCallback(
+    (selection?: TextSelection) => {
+      dispatch(
+        documentActions.setTextSelection({
+          blockId: id,
+          selection,
+        })
+      );
+    },
+    [id]
+  );
 
 
   return {
   return {
     editor,
     editor,
+    yText,
+    onSelectionChange,
   };
   };
 }
 }
 
 
 function useController() {
 function useController() {
   const docController = useContext(DocumentControllerContext);
   const docController = useContext(DocumentControllerContext);
   const node = useContext(NodeContext);
   const node = useContext(NodeContext);
+  const dispatch = useAppDispatch();
 
 
   const update = useCallback(
   const update = useCallback(
     async (delta: TextDelta[]) => {
     async (delta: TextDelta[]) => {
@@ -43,6 +69,14 @@ function useController() {
           },
           },
         },
         },
       ]);
       ]);
+      dispatch(
+        documentActions.setBlockMap({
+          ...node,
+          data: {
+            delta,
+          },
+        })
+      );
     },
     },
     [docController, node]
     [docController, node]
   );
   );
@@ -105,12 +139,14 @@ function useBindYjs(delta: TextDelta[], update: (_delta: TextDelta[]) => void) {
   useEffect(() => {
   useEffect(() => {
     const yText = yTextRef.current;
     const yText = yTextRef.current;
     if (!yText) return;
     if (!yText) return;
-
     const textEventHandler = (event: Y.YTextEvent) => {
     const textEventHandler = (event: Y.YTextEvent) => {
       const textDelta = event.target.toDelta();
       const textDelta = event.target.toDelta();
       update(textDelta);
       update(textDelta);
     };
     };
-    yText.applyDelta(delta);
+    if (JSON.stringify(yText.toDelta()) !== JSON.stringify(delta)) {
+      yText.delete(0, yText.length);
+      yText.applyDelta(delta);
+    }
     yText.observe(textEventHandler);
     yText.observe(textEventHandler);
 
 
     return () => {
     return () => {
@@ -118,5 +154,5 @@ function useBindYjs(delta: TextDelta[], update: (_delta: TextDelta[]) => void) {
     };
     };
   }, [delta]);
   }, [delta]);
 
 
-  return { editor };
+  return { editor, yText: yTextRef.current };
 }
 }

+ 9 - 8
frontend/appflowy_tauri/src/appflowy_app/components/layout/NavigationPanel/FolderItem.hooks.ts

@@ -118,14 +118,16 @@ export const useFolderEvents = (folder: IFolder, pages: IPage[]) => {
       layoutType: ViewLayoutPB.Document,
       layoutType: ViewLayoutPB.Document,
     });
     });
     try {
     try {
-      await new DocumentController(newView.id).create();
+      const c = new DocumentController(newView.id);
+      await c.create();
+      await c.dispose();
       appDispatch(
       appDispatch(
-          pagesActions.addPage({
-            folderId: folder.id,
-            pageType: ViewLayoutPB.Document,
-            title: newView.name,
-            id: newView.id,
-          })
+        pagesActions.addPage({
+          folderId: folder.id,
+          pageType: ViewLayoutPB.Document,
+          title: newView.name,
+          id: newView.id,
+        })
       );
       );
 
 
       setShowPages(true);
       setShowPages(true);
@@ -134,7 +136,6 @@ export const useFolderEvents = (folder: IFolder, pages: IPage[]) => {
     } catch (e) {
     } catch (e) {
       console.error(e);
       console.error(e);
     }
     }
-
   };
   };
 
 
   const onAddNewBoardPage = async () => {
   const onAddNewBoardPage = async () => {

+ 9 - 1
frontend/appflowy_tauri/src/appflowy_app/interfaces/document.ts

@@ -47,5 +47,13 @@ export enum BlockActionType {
   Insert = 0,
   Insert = 0,
   Update = 1,
   Update = 1,
   Delete = 2,
   Delete = 2,
-  Move = 3
+  Move = 3,
+}
+
+export interface DeltaItem {
+  action: 'inserted' | 'removed' | 'updated';
+  payload: {
+    id: string;
+    value?: NestedBlock | string[];
+  };
 }
 }

+ 118 - 5
frontend/appflowy_tauri/src/appflowy_app/stores/effects/document/document_controller.ts

@@ -1,8 +1,10 @@
-import { DocumentData, BlockType } from '@/appflowy_app/interfaces/document';
-import { createContext } from 'react';
+import { DocumentData, BlockType, DeltaItem } from '@/appflowy_app/interfaces/document';
+import { createContext, Dispatch } from 'react';
 import { DocumentBackendService } from './document_bd_svc';
 import { DocumentBackendService } from './document_bd_svc';
-import { FlowyError, BlockActionPB } from '@/services/backend';
+import { FlowyError, BlockActionPB, DocEventPB, DeltaTypePB, BlockActionTypePB } from '@/services/backend';
 import { DocumentObserver } from './document_observer';
 import { DocumentObserver } from './document_observer';
+import { documentActions, Node } from '@/appflowy_app/stores/reducers/document/slice';
+import { Log } from '@/appflowy_app/utils/log';
 
 
 export const DocumentControllerContext = createContext<DocumentController | null>(null);
 export const DocumentControllerContext = createContext<DocumentController | null>(null);
 
 
@@ -10,7 +12,7 @@ export class DocumentController {
   private readonly backendService: DocumentBackendService;
   private readonly backendService: DocumentBackendService;
   private readonly observer: DocumentObserver;
   private readonly observer: DocumentObserver;
 
 
-  constructor(public readonly viewId: string) {
+  constructor(public readonly viewId: string, private dispatch?: Dispatch<any>) {
     this.backendService = new DocumentBackendService(viewId);
     this.backendService = new DocumentBackendService(viewId);
     this.observer = new DocumentObserver(viewId);
     this.observer = new DocumentObserver(viewId);
   }
   }
@@ -66,11 +68,122 @@ export class DocumentController {
     await this.backendService.applyActions(actions);
     await this.backendService.applyActions(actions);
   };
   };
 
 
+  getInsertAction = (node: Node, prevId: string | null) => {
+    return {
+      action: BlockActionTypePB.Insert,
+      payload: this.getActionPayloadByNode(node, prevId),
+    };
+  };
+
+  getUpdateAction = (node: Node) => {
+    return {
+      action: BlockActionTypePB.Update,
+      payload: this.getActionPayloadByNode(node, ''),
+    };
+  };
+
+  getMoveAction = (node: Node, parentId: string, prevId: string | null) => {
+    return {
+      action: BlockActionTypePB.Move,
+      payload: this.getActionPayloadByNode(
+        {
+          ...node,
+          parent: parentId,
+        },
+        prevId
+      ),
+    };
+  };
+
+  getDeleteAction = (node: Node) => {
+    return {
+      action: BlockActionTypePB.Delete,
+      payload: this.getActionPayloadByNode(node, ''),
+    };
+  };
+
   dispose = async () => {
   dispose = async () => {
     await this.backendService.close();
     await this.backendService.close();
   };
   };
 
 
+  private getActionPayloadByNode = (node: Node, prevId: string | null) => {
+    return {
+      block: this.getBlockByNode(node),
+      parent_id: node.parent || '',
+      prev_id: prevId || '',
+    };
+  };
+
+  private getBlockByNode = (node: Node) => {
+    return {
+      id: node.id,
+      parent_id: node.parent || '',
+      children_id: node.children,
+      data: JSON.stringify(node.data),
+      ty: node.type,
+    };
+  };
+
   private updated = (payload: Uint8Array) => {
   private updated = (payload: Uint8Array) => {
-    console.log('didReceiveUpdate', payload);
+    const dispatch = this.dispatch;
+    if (!dispatch) return;
+    const { events, is_remote } = DocEventPB.deserializeBinary(payload);
+    console.log('updated', events, is_remote);
+    if (!is_remote) return;
+    events.forEach((event) => {
+      event.event.forEach((_payload) => {
+        const { path, id, value, command } = _payload;
+        let valueJson;
+        try {
+          valueJson = JSON.parse(value);
+        } catch {
+          console.error('json parse error', value);
+          return;
+        }
+        if (!valueJson) return;
+
+        if (command === DeltaTypePB.Inserted || command === DeltaTypePB.Updated) {
+          // set map key and value ( block map or children map)
+          if (path[0] === 'blocks') {
+            const block = blockChangeValue2Node(valueJson);
+            dispatch(documentActions.setBlockMap(block));
+          } else {
+            dispatch(
+              documentActions.setChildrenMap({
+                id,
+                childIds: valueJson,
+              })
+            );
+          }
+        } else {
+          // remove map key ( block map or children map)
+          if (path[0] === 'blocks') {
+            dispatch(documentActions.removeBlockMapKey(id));
+          } else {
+            dispatch(documentActions.removeChildrenMapKey(id));
+          }
+        }
+      });
+    });
   };
   };
 }
 }
+
+function blockChangeValue2Node(value: { id: string; ty: string; parent: string; children: string; data: string }): Node {
+  const block = {
+    id: value.id,
+    type: value.ty as BlockType,
+    parent: value.parent,
+    children: value.children,
+    data: {},
+  };
+  if ('data' in value && typeof value.data === 'string') {
+    try {
+      Object.assign(block, {
+        data: JSON.parse(value.data),
+      });
+    } catch {
+      Log.error('valueJson data parse error', block.data);
+    }
+  }
+  return block;
+}

+ 96 - 0
frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async_actions/backspace.ts

@@ -0,0 +1,96 @@
+import { BlockType } from '@/appflowy_app/interfaces/document';
+import { DocumentController } from '$app/stores/effects/document/document_controller';
+import { createAsyncThunk } from '@reduxjs/toolkit';
+import { documentActions, DocumentState } from '../slice';
+import { outdentNodeThunk } from './outdent';
+
+const composeParentThunk = createAsyncThunk(
+  'document/composeParent',
+  async (payload: { id: string; controller: DocumentController }, thunkAPI) => {
+    const { id, controller } = payload;
+    const { dispatch, getState } = thunkAPI;
+    const state = (getState() as { document: DocumentState }).document;
+    const node = state.nodes[id];
+    if (!node.parent) return;
+    const parent = state.nodes[node.parent];
+    // merge delta
+    const newParent = {
+      ...parent,
+      data: {
+        ...parent.data,
+        delta: [...parent.data.delta, ...node.data.delta],
+      },
+    };
+    await controller.applyActions([controller.getDeleteAction(node), controller.getUpdateAction(newParent)]);
+
+    dispatch(documentActions.setBlockMap(newParent));
+    dispatch(documentActions.removeBlockMapKey(node.id));
+    dispatch(documentActions.removeChildrenMapKey(node.children));
+  }
+);
+const composePrevNodeThunk = createAsyncThunk(
+  'document/composePrevNode',
+  async (payload: { prevNodeId: string; id: string; controller: DocumentController }, thunkAPI) => {
+    const { id, prevNodeId, controller } = payload;
+    const { dispatch, getState } = thunkAPI;
+    const state = (getState() as { document: DocumentState }).document;
+    const node = state.nodes[id];
+    const prevNode = state.nodes[prevNodeId];
+    // find prev line
+    let prevLineId = prevNode.id;
+    while (prevLineId) {
+      const prevLineChildren = state.children[state.nodes[prevLineId].children];
+      if (prevLineChildren.length === 0) break;
+      prevLineId = prevLineChildren[prevLineChildren.length - 1];
+    }
+    const prevLine = state.nodes[prevLineId];
+    // merge delta
+    const newPrevLine = {
+      ...prevLine,
+      data: {
+        ...prevLine.data,
+        delta: [...prevLine.data.delta, ...node.data.delta],
+      },
+    };
+    await controller.applyActions([controller.getDeleteAction(node), controller.getUpdateAction(newPrevLine)]);
+
+    dispatch(documentActions.setBlockMap(newPrevLine));
+    dispatch(documentActions.removeBlockMapKey(node.id));
+    dispatch(documentActions.removeChildrenMapKey(node.children));
+  }
+);
+
+export const backspaceNodeThunk = createAsyncThunk(
+  'document/backspaceNode',
+  async (payload: { id: string; controller: DocumentController }, thunkAPI) => {
+    const { id, controller } = payload;
+    const { dispatch, getState } = thunkAPI;
+    const state = (getState() as { document: DocumentState }).document;
+    const node = state.nodes[id];
+    if (!node.parent) return;
+    const parent = state.nodes[node.parent];
+    const ancestorId = parent.parent;
+    const children = state.children[parent.children];
+    const index = children.indexOf(id);
+    const prevNodeId = children[index - 1];
+    const nextNodeId = children[index + 1];
+    // transform to text block
+    if (node.type !== BlockType.TextBlock) {
+      // todo: transform to text block
+    }
+    // compose to previous line when it has next sibling or no ancestor
+    if (nextNodeId || !ancestorId) {
+      // compose to parent when it has no previous sibling
+      if (!prevNodeId) {
+        await dispatch(composeParentThunk({ id, controller }));
+        return;
+      }
+      await dispatch(composePrevNodeThunk({ prevNodeId, id, controller }));
+      return;
+    } else {
+      // outdent when it has no next sibling
+      await dispatch(outdentNodeThunk({ id, controller }));
+      return;
+    }
+  }
+);

+ 32 - 0
frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async_actions/delete.ts

@@ -0,0 +1,32 @@
+import { DocumentController } from '$app/stores/effects/document/document_controller';
+import { createAsyncThunk } from '@reduxjs/toolkit';
+import { documentActions, DocumentState } from '../slice';
+
+export const deleteNodeThunk = createAsyncThunk(
+  'document/deleteNode',
+  async (payload: { id: string; controller: DocumentController }, thunkAPI) => {
+    const { id, controller } = payload;
+    const { dispatch, getState } = thunkAPI;
+    const state = getState() as { document: DocumentState };
+    const node = state.document.nodes[id];
+    if (!node) return;
+    await controller.applyActions([controller.getDeleteAction(node)]);
+
+    const deleteNode = (deleteId: string) => {
+      const deleteItem = state.document.nodes[deleteId];
+      const children = state.document.children[deleteItem.children];
+      // delete children
+      if (children.length > 0) {
+        children.forEach((childId) => {
+          deleteNode(childId);
+        });
+      }
+      dispatch(documentActions.removeBlockMapKey(deleteItem.id));
+      dispatch(documentActions.removeChildrenMapKey(deleteItem.children));
+    };
+    deleteNode(node.id);
+
+    if (!node.parent) return;
+    dispatch(documentActions.deleteChild({ id: node.parent, childId: node.id }));
+  }
+);

+ 37 - 0
frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async_actions/indent.ts

@@ -0,0 +1,37 @@
+import { BlockType } from '@/appflowy_app/interfaces/document';
+import { DocumentController } from '$app/stores/effects/document/document_controller';
+import { createAsyncThunk } from '@reduxjs/toolkit';
+import { documentActions, DocumentState } from '../slice';
+
+export const indentNodeThunk = createAsyncThunk(
+  'document/indentNode',
+  async (payload: { id: string; controller: DocumentController }, thunkAPI) => {
+    const { id, controller } = payload;
+    const { dispatch, getState } = thunkAPI;
+    const state = (getState() as { document: DocumentState }).document;
+    const node = state.nodes[id];
+    if (!node.parent) return;
+    // get parent
+    const parent = state.nodes[node.parent];
+    // get prev node
+    const children = state.children[parent.children];
+    const index = children.indexOf(id);
+    if (index === 0) return;
+    const newParentId = children[index - 1];
+    const prevNode = state.nodes[newParentId];
+    // check if prev node is allowed to have children
+    if (prevNode.type !== BlockType.TextBlock) return;
+    // check if prev node has children and get last child for new prev node
+    const prevNodeChildren = state.children[prevNode.children];
+    const newPrevId = prevNodeChildren[prevNodeChildren.length - 1];
+
+    await controller.applyActions([controller.getMoveAction(node, newParentId, newPrevId)]);
+    dispatch(
+      documentActions.moveNode({
+        id,
+        newParentId,
+        newPrevId,
+      })
+    );
+  }
+);

+ 6 - 0
frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async_actions/index.ts

@@ -0,0 +1,6 @@
+export * from './delete';
+export * from './indent';
+export * from './insert';
+export * from './backspace';
+export * from './outdent';
+export * from './split';

+ 41 - 0
frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async_actions/insert.ts

@@ -0,0 +1,41 @@
+import { BlockType, NestedBlock } from '@/appflowy_app/interfaces/document';
+import { DocumentController } from '$app/stores/effects/document/document_controller';
+import { createAsyncThunk } from '@reduxjs/toolkit';
+import { documentActions, DocumentState } from '../slice';
+import { generateId } from '@/appflowy_app/utils/block';
+export const insertAfterNodeThunk = createAsyncThunk(
+  'document/insertAfterNode',
+  async (payload: { id: string; controller: DocumentController }, thunkAPI) => {
+    const { controller } = payload;
+    const { dispatch, getState } = thunkAPI;
+    const state = getState() as { document: DocumentState };
+    const node = state.document.nodes[payload.id];
+    if (!node) return;
+    const parentId = node.parent;
+    if (!parentId) return;
+    // create new node
+    const newNode: NestedBlock = {
+      id: generateId(),
+      parent: parentId,
+      type: BlockType.TextBlock,
+      data: {},
+      children: generateId(),
+    };
+    await controller.applyActions([controller.getInsertAction(newNode, node.id)]);
+    dispatch(documentActions.setBlockMap(newNode));
+    dispatch(
+      documentActions.setChildrenMap({
+        id: newNode.children,
+        childIds: [],
+      })
+    );
+    // insert new node to parent
+    dispatch(
+      documentActions.insertChild({
+        id: parentId,
+        childId: newNode.id,
+        prevId: node.id,
+      })
+    );
+  }
+);

+ 26 - 0
frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async_actions/outdent.ts

@@ -0,0 +1,26 @@
+import { DocumentController } from '$app/stores/effects/document/document_controller';
+import { createAsyncThunk } from '@reduxjs/toolkit';
+import { documentActions, DocumentState } from '../slice';
+
+export const outdentNodeThunk = createAsyncThunk(
+  'document/outdentNode',
+  async (payload: { id: string; controller: DocumentController }, thunkAPI) => {
+    const { id, controller } = payload;
+    const { dispatch, getState } = thunkAPI;
+    const state = (getState() as { document: DocumentState }).document;
+    const node = state.nodes[id];
+    const newPrevId = node.parent;
+    if (!newPrevId) return;
+    const parent = state.nodes[newPrevId];
+    const newParentId = parent.parent;
+    if (!newParentId) return;
+    await controller.applyActions([controller.getMoveAction(node, newParentId, newPrevId)]);
+    dispatch(
+      documentActions.moveNode({
+        id: node.id,
+        newParentId,
+        newPrevId,
+      })
+    );
+  }
+);

+ 54 - 0
frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async_actions/split.ts

@@ -0,0 +1,54 @@
+import { BlockType, TextDelta } from '@/appflowy_app/interfaces/document';
+import { DocumentController } from '@/appflowy_app/stores/effects/document/document_controller';
+import { createAsyncThunk } from '@reduxjs/toolkit';
+import { generateId } from '@/appflowy_app/utils/block';
+import { documentActions, DocumentState } from '../slice';
+
+export const splitNodeThunk = createAsyncThunk(
+  'document/splitNode',
+  async (
+    payload: { id: string; retain: TextDelta[]; insert: TextDelta[]; controller: DocumentController },
+    thunkAPI
+  ) => {
+    const { id, controller, retain, insert } = payload;
+    const { dispatch, getState } = thunkAPI;
+    const state = (getState() as { document: DocumentState }).document;
+    const node = state.nodes[id];
+    if (!node.parent) return;
+    const children = state.children[node.children];
+    const prevId = children.length > 0 ? null : node.id;
+    const parent = children.length > 0 ? node : state.nodes[node.parent];
+    const newNode = {
+      id: generateId(),
+      parent: parent.id,
+      type: BlockType.TextBlock,
+      data: {
+        delta: insert,
+      },
+      children: generateId(),
+    };
+    const retainNode = {
+      ...node,
+      data: {
+        ...node.data,
+        delta: retain,
+      },
+    };
+    await controller.applyActions([controller.getInsertAction(newNode, prevId), controller.getUpdateAction(retainNode)]);
+    dispatch(documentActions.setBlockMap(newNode));
+    dispatch(documentActions.setBlockMap(retainNode));
+    dispatch(
+      documentActions.setChildrenMap({
+        id: newNode.children,
+        childIds: [],
+      })
+    );
+    dispatch(
+      documentActions.insertChild({
+        id: parent.id,
+        childId: newNode.id,
+        prevId,
+      })
+    );
+  }
+);

+ 1 - 1
frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/region_grid.ts

@@ -55,7 +55,7 @@ export class RegionGrid {
   }
   }
   
   
   removeBlock(blockId: string) {
   removeBlock(blockId: string) {
-    for (const rows of this.regions) {
+    for (const rows of this.regions.filter(r => r)) {
       for (const region of rows) {
       for (const region of rows) {
         if (!region) return;
         if (!region) return;
         const blockIndex = region.blocks.findIndex(b => b.id === blockId);
         const blockIndex = region.blocks.findIndex(b => b.id === blockId);

+ 98 - 34
frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/slice.ts

@@ -1,31 +1,51 @@
 import { BlockType, NestedBlock, TextDelta } from '@/appflowy_app/interfaces/document';
 import { BlockType, NestedBlock, TextDelta } from '@/appflowy_app/interfaces/document';
 import { PayloadAction, createSlice } from '@reduxjs/toolkit';
 import { PayloadAction, createSlice } from '@reduxjs/toolkit';
+import { nanoid } from 'nanoid';
+import { DocumentController } from '../../effects/document/document_controller';
 import { RegionGrid } from './region_grid';
 import { RegionGrid } from './region_grid';
 
 
 export type Node = NestedBlock;
 export type Node = NestedBlock;
 
 
-export interface NodeState {
+export interface SelectionPoint {
+  path: [number, number];
+  offset: number;
+}
+
+export interface TextSelection {
+  anchor: SelectionPoint;
+  focus: SelectionPoint;
+}
+
+export interface DocumentState {
+  // map of block id to block
   nodes: Record<string, Node>;
   nodes: Record<string, Node>;
+  // map of block id to children block ids
   children: Record<string, string[]>;
   children: Record<string, string[]>;
+  // selected block ids
   selections: string[];
   selections: string[];
+  // map of block id to text selection
+  textSelections: Record<string, TextSelection>;
 }
 }
 
 
 const regionGrid = new RegionGrid(50);
 const regionGrid = new RegionGrid(50);
 
 
-const initialState: NodeState = {
+const initialState: DocumentState = {
   nodes: {},
   nodes: {},
   children: {},
   children: {},
   selections: [],
   selections: [],
+  textSelections: {},
 };
 };
 
 
 export const documentSlice = createSlice({
 export const documentSlice = createSlice({
   name: 'document',
   name: 'document',
   initialState: initialState,
   initialState: initialState,
   reducers: {
   reducers: {
+    // initialize the document
     clear: () => {
     clear: () => {
       return initialState;
       return initialState;
     },
     },
 
 
+    // set document data
     create: (
     create: (
       state,
       state,
       action: PayloadAction<{
       action: PayloadAction<{
@@ -38,10 +58,18 @@ export const documentSlice = createSlice({
       state.children = children;
       state.children = children;
     },
     },
 
 
+    // update block selections
     updateSelections: (state, action: PayloadAction<string[]>) => {
     updateSelections: (state, action: PayloadAction<string[]>) => {
       state.selections = action.payload;
       state.selections = action.payload;
     },
     },
 
 
+    // set block selected
+    setSelectionById: (state, action: PayloadAction<string>) => {
+      const id = action.payload;
+      state.selections = [id];
+    },
+
+    // set block selected by selection rect
     setSelectionByRect: (
     setSelectionByRect: (
       state,
       state,
       action: PayloadAction<{
       action: PayloadAction<{
@@ -56,6 +84,7 @@ export const documentSlice = createSlice({
       state.selections = blocks.map((block) => block.id);
       state.selections = blocks.map((block) => block.id);
     },
     },
 
 
+    // update block position
     updateNodePosition: (
     updateNodePosition: (
       state,
       state,
       action: PayloadAction<{
       action: PayloadAction<{
@@ -76,50 +105,85 @@ export const documentSlice = createSlice({
       regionGrid.updateBlock(id, position);
       regionGrid.updateBlock(id, position);
     },
     },
 
 
-    addNode: (state, action: PayloadAction<Node>) => {
+    // update text selections
+    setTextSelection: (
+      state,
+      action: PayloadAction<{
+        blockId: string;
+        selection?: TextSelection;
+      }>
+    ) => {
+      const { blockId, selection } = action.payload;
+      if (!selection) {
+        delete state.textSelections[blockId];
+      } else {
+        state.textSelections = {
+          [blockId]: selection,
+        };
+      }
+    },
+
+    // update block
+    setBlockMap: (state, action: PayloadAction<Node>) => {
       state.nodes[action.payload.id] = action.payload;
       state.nodes[action.payload.id] = action.payload;
     },
     },
 
 
-    addChild: (state, action: PayloadAction<{ parentId: string; childId: string; prevId: string }>) => {
-      const { parentId, childId, prevId } = action.payload;
-      const parentChildrenId = state.nodes[parentId].children;
-      const children = state.children[parentChildrenId];
-      const prevIndex = children.indexOf(prevId);
-      if (prevIndex === -1) {
-        children.push(childId);
-      } else {
-        children.splice(prevIndex + 1, 0, childId);
-      }
+    // remove block
+    removeBlockMapKey(state, action: PayloadAction<string>) {
+      if (!state.nodes[action.payload]) return;
+      const { id } = state.nodes[action.payload];
+      regionGrid.removeBlock(id);
+      delete state.nodes[id];
     },
     },
 
 
-    updateChildren: (state, action: PayloadAction<{ id: string; childIds: string[] }>) => {
+    // set block's relationship with its children
+    setChildrenMap: (state, action: PayloadAction<{ id: string; childIds: string[] }>) => {
       const { id, childIds } = action.payload;
       const { id, childIds } = action.payload;
       state.children[id] = childIds;
       state.children[id] = childIds;
     },
     },
 
 
-    updateNode: (state, action: PayloadAction<{ id: string; data: any }>) => {
-      state.nodes[action.payload.id] = {
-        ...state.nodes[action.payload.id],
-        ...action.payload,
-      };
+    // remove block's relationship with its children
+    removeChildrenMapKey(state, action: PayloadAction<string>) {
+      if (state.children[action.payload]) {
+        delete state.children[action.payload];
+      }
     },
     },
 
 
-    removeNode: (state, action: PayloadAction<string>) => {
-      const { children, data, parent } = state.nodes[action.payload];
-      // remove from parent
-      if (parent) {
-        const index = state.children[state.nodes[parent].children].indexOf(action.payload);
-        if (index > -1) {
-          state.children[state.nodes[parent].children].splice(index, 1);
-        }
-      }
-      // remove children
-      if (children) {
-        delete state.children[children];
-      }
+    // set block's relationship with its parent
+    insertChild: (state, action: PayloadAction<{ id: string; childId: string; prevId: string | null }>) => {
+      const { id, childId, prevId } = action.payload;
+      const parent = state.nodes[id];
+      const children = state.children[parent.children];
+      const index = prevId ? children.indexOf(prevId) + 1 : 0;
+      children.splice(index, 0, childId);
+    },
+
+    // remove block's relationship with its parent
+    deleteChild: (state, action: PayloadAction<{ id: string; childId: string }>) => {
+      const { id, childId } = action.payload;
+      const parent = state.nodes[id];
+      const children = state.children[parent.children];
+      const index = children.indexOf(childId);
+      children.splice(index, 1);
+    },
+
+    // move block to another parent
+    moveNode: (state, action: PayloadAction<{ id: string; newParentId: string; newPrevId: string | null }>) => {
+      const { id, newParentId, newPrevId } = action.payload;
+      const newParent = state.nodes[newParentId];
+      const oldParentId = state.nodes[id].parent;
+      if (!oldParentId) return;
+      const oldParent = state.nodes[oldParentId];
+
+      state.nodes[id] = {
+        ...state.nodes[id],
+        parent: newParentId,
+      };
+      const index = state.children[oldParent.children].indexOf(id);
+      state.children[oldParent.children].splice(index, 1);
 
 
-      // remove node
-      delete state.nodes[action.payload];
+      const newIndex = newPrevId ? state.children[newParent.children].indexOf(newPrevId) + 1 : 0;
+      state.children[newParent.children].splice(newIndex, 0, id);
     },
     },
   },
   },
 });
 });

+ 5 - 0
frontend/appflowy_tauri/src/appflowy_app/utils/block.ts

@@ -0,0 +1,5 @@
+import { nanoid } from 'nanoid';
+
+export function generateId() {
+  return nanoid(10);
+}

+ 20 - 18
frontend/appflowy_tauri/src/appflowy_app/views/DocumentPage.hooks.ts

@@ -1,39 +1,41 @@
-import { useEffect, useRef, useState } from 'react';
-import {
-  DocumentEventGetDocument,
-  DocumentVersionPB,
-  OpenDocumentPayloadPB,
-} from '../../services/backend/events/flowy-document';
+import { useEffect, useState } from 'react';
 import { useParams } from 'react-router-dom';
 import { useParams } from 'react-router-dom';
 import { DocumentData } from '../interfaces/document';
 import { DocumentData } from '../interfaces/document';
 import { DocumentController } from '$app/stores/effects/document/document_controller';
 import { DocumentController } from '$app/stores/effects/document/document_controller';
-
+import { useAppDispatch } from '../stores/store';
+import { Log } from '../utils/log';
 
 
 export const useDocument = () => {
 export const useDocument = () => {
   const params = useParams();
   const params = useParams();
-  const [ documentId, setDocumentId ] = useState<string>();
-  const [ documentData, setDocumentData ] = useState<DocumentData>();
-  const [ controller, setController ] = useState<DocumentController | null>(null);
+  const [documentId, setDocumentId] = useState<string>();
+  const [documentData, setDocumentData] = useState<DocumentData>();
+  const [controller, setController] = useState<DocumentController | null>(null);
+  const dispatch = useAppDispatch();
 
 
   useEffect(() => {
   useEffect(() => {
+    let documentController: DocumentController | null = null;
     void (async () => {
     void (async () => {
       if (!params?.id) return;
       if (!params?.id) return;
-      const c = new DocumentController(params.id);
-      setController(c);
+      Log.debug('open document', params.id);
+      documentController = new DocumentController(params.id, dispatch);
+      setController(documentController);
       try {
       try {
-        const res = await c.open();
-        console.log(res)
+        const res = await documentController.open();
         if (!res) return;
         if (!res) return;
         setDocumentData(res);
         setDocumentData(res);
         setDocumentId(params.id);
         setDocumentId(params.id);
       } catch (e) {
       } catch (e) {
-        console.log(e)
+        Log.error(e);
       }
       }
-
     })();
     })();
     return () => {
     return () => {
-      console.log('==== leave ====', params?.id)
-    }
+      void (async () => {
+        if (documentController) {
+          await documentController.dispose();
+        }
+        Log.debug('close document', params.id);
+      })();
+    };
   }, [params.id]);
   }, [params.id]);
   return { documentId, documentData, controller };
   return { documentId, documentData, controller };
 };
 };

+ 22 - 0
frontend/rust-lib/Cargo.lock

@@ -1427,7 +1427,10 @@ dependencies = [
  "serde_json",
  "serde_json",
  "strum",
  "strum",
  "strum_macros",
  "strum_macros",
+ "tempfile",
+ "tokio",
  "tracing",
  "tracing",
+ "tracing-subscriber 0.3.16",
 ]
 ]
 
 
 [[package]]
 [[package]]
@@ -2710,6 +2713,16 @@ dependencies = [
  "minimal-lexical",
  "minimal-lexical",
 ]
 ]
 
 
+[[package]]
+name = "nu-ansi-term"
+version = "0.46.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "77a8165726e8236064dbb45459242600304b42a5ea24ee2948e18e023bf7ba84"
+dependencies = [
+ "overload",
+ "winapi",
+]
+
 [[package]]
 [[package]]
 name = "num-integer"
 name = "num-integer"
 version = "0.1.45"
 version = "0.1.45"
@@ -2830,6 +2843,12 @@ dependencies = [
  "winapi",
  "winapi",
 ]
 ]
 
 
+[[package]]
+name = "overload"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39"
+
 [[package]]
 [[package]]
 name = "owo-colors"
 name = "owo-colors"
 version = "1.3.0"
 version = "1.3.0"
@@ -4434,12 +4453,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "a6176eae26dd70d0c919749377897b54a9276bd7061339665dd68777926b5a70"
 checksum = "a6176eae26dd70d0c919749377897b54a9276bd7061339665dd68777926b5a70"
 dependencies = [
 dependencies = [
  "matchers 0.1.0",
  "matchers 0.1.0",
+ "nu-ansi-term",
  "once_cell",
  "once_cell",
  "regex",
  "regex",
  "sharded-slab",
  "sharded-slab",
+ "smallvec",
  "thread_local",
  "thread_local",
  "tracing",
  "tracing",
  "tracing-core",
  "tracing-core",
+ "tracing-log",
 ]
 ]
 
 
 [[package]]
 [[package]]

+ 2 - 0
frontend/rust-lib/flowy-core/src/lib.rs

@@ -92,6 +92,8 @@ fn create_log_filter(level: String, with_crates: Vec<String>) -> String {
   filters.push(format!("flowy_folder={}", level));
   filters.push(format!("flowy_folder={}", level));
   filters.push(format!("flowy_folder2={}", level));
   filters.push(format!("flowy_folder2={}", level));
   filters.push(format!("collab_folder={}", level));
   filters.push(format!("collab_folder={}", level));
+  filters.push(format!("collab_persistence={}", level));
+  filters.push(format!("collab={}", level));
   filters.push(format!("flowy_user={}", level));
   filters.push(format!("flowy_user={}", level));
   filters.push(format!("flowy_document={}", level));
   filters.push(format!("flowy_document={}", level));
   filters.push(format!("flowy_document2={}", level));
   filters.push(format!("flowy_document2={}", level));

+ 7 - 1
frontend/rust-lib/flowy-document2/Cargo.toml

@@ -25,10 +25,16 @@ strum_macros = "0.21"
 serde = { version = "1.0", features = ["derive"] }
 serde = { version = "1.0", features = ["derive"] }
 serde_json = {version = "1.0"}
 serde_json = {version = "1.0"}
 tracing = { version = "0.1", features = ["log"] }
 tracing = { version = "0.1", features = ["log"] }
+tokio = { version = "1.26", features = ["full"] }
+
+[dev-dependencies]
+tempfile = "3.4.0"
+tracing-subscriber = { version = "0.3.3", features = ["env-filter"] }
 
 
 [build-dependencies]
 [build-dependencies]
 flowy-codegen = { path = "../flowy-codegen"}
 flowy-codegen = { path = "../flowy-codegen"}
 
 
 [features]
 [features]
 dart = ["flowy-codegen/dart", "flowy-notification/dart"]
 dart = ["flowy-codegen/dart", "flowy-notification/dart"]
-ts = ["flowy-codegen/ts", "flowy-notification/ts"]
+ts = ["flowy-codegen/ts", "flowy-notification/ts"]
+

+ 6 - 0
frontend/rust-lib/flowy-document2/src/document.rs

@@ -25,6 +25,12 @@ impl Document {
       .map_err(|_| FlowyError::from(ErrorCode::DocumentDataInvalid))?;
       .map_err(|_| FlowyError::from(ErrorCode::DocumentDataInvalid))?;
     Ok(Self(Arc::new(Mutex::new(inner))))
     Ok(Self(Arc::new(Mutex::new(inner))))
   }
   }
+
+  pub fn create_with_data(collab: Collab, data: DocumentData) -> FlowyResult<Self> {
+    let inner = InnerDocument::create_with_data(collab, data)
+      .map_err(|_| FlowyError::from(ErrorCode::DocumentDataInvalid))?;
+    Ok(Self(Arc::new(Mutex::new(inner))))
+  }
 }
 }
 
 
 unsafe impl Sync for Document {}
 unsafe impl Sync for Document {}

+ 31 - 7
frontend/rust-lib/flowy-document2/src/entities.rs

@@ -23,7 +23,7 @@ pub struct CloseDocumentPayloadPBV2 {
   // Support customize initial data
   // Support customize initial data
 }
 }
 
 
-#[derive(Default, ProtoBuf)]
+#[derive(Default, ProtoBuf, Debug)]
 pub struct ApplyActionPayloadPBV2 {
 pub struct ApplyActionPayloadPBV2 {
   #[pb(index = 1)]
   #[pb(index = 1)]
   pub document_id: String,
   pub document_id: String,
@@ -44,7 +44,7 @@ pub struct DocumentDataPB2 {
   pub meta: MetaPB,
   pub meta: MetaPB,
 }
 }
 
 
-#[derive(Default, ProtoBuf)]
+#[derive(Default, ProtoBuf, Debug)]
 pub struct BlockPB {
 pub struct BlockPB {
   #[pb(index = 1)]
   #[pb(index = 1)]
   pub id: String,
   pub id: String,
@@ -75,7 +75,7 @@ pub struct ChildrenPB {
 }
 }
 
 
 // Actions
 // Actions
-#[derive(Default, ProtoBuf)]
+#[derive(Default, ProtoBuf, Debug)]
 pub struct BlockActionPB {
 pub struct BlockActionPB {
   #[pb(index = 1)]
   #[pb(index = 1)]
   pub action: BlockActionTypePB,
   pub action: BlockActionTypePB,
@@ -84,7 +84,7 @@ pub struct BlockActionPB {
   pub payload: BlockActionPayloadPB,
   pub payload: BlockActionPayloadPB,
 }
 }
 
 
-#[derive(Default, ProtoBuf)]
+#[derive(Default, ProtoBuf, Debug)]
 pub struct BlockActionPayloadPB {
 pub struct BlockActionPayloadPB {
   #[pb(index = 1)]
   #[pb(index = 1)]
   pub block: BlockPB,
   pub block: BlockPB,
@@ -96,7 +96,7 @@ pub struct BlockActionPayloadPB {
   pub parent_id: Option<String>,
   pub parent_id: Option<String>,
 }
 }
 
 
-#[derive(ProtoBuf_Enum)]
+#[derive(ProtoBuf_Enum, Debug)]
 pub enum BlockActionTypePB {
 pub enum BlockActionTypePB {
   Insert = 0,
   Insert = 0,
   Update = 1,
   Update = 1,
@@ -110,6 +110,18 @@ impl Default for BlockActionTypePB {
   }
   }
 }
 }
 
 
+#[derive(ProtoBuf_Enum)]
+pub enum DeltaTypePB {
+  Inserted = 0,
+  Updated = 1,
+  Removed = 2,
+}
+impl Default for DeltaTypePB {
+  fn default() -> Self {
+    Self::Inserted
+  }
+}
+
 #[derive(Default, ProtoBuf)]
 #[derive(Default, ProtoBuf)]
 pub struct DocEventPB {
 pub struct DocEventPB {
   #[pb(index = 1)]
   #[pb(index = 1)]
@@ -122,8 +134,20 @@ pub struct DocEventPB {
 #[derive(Default, ProtoBuf)]
 #[derive(Default, ProtoBuf)]
 pub struct BlockEventPB {
 pub struct BlockEventPB {
   #[pb(index = 1)]
   #[pb(index = 1)]
-  pub path: Vec<String>,
+  pub event: Vec<BlockEventPayloadPB>,
+}
+
+#[derive(Default, ProtoBuf)]
+pub struct BlockEventPayloadPB {
+  #[pb(index = 1)]
+  pub command: DeltaTypePB,
 
 
   #[pb(index = 2)]
   #[pb(index = 2)]
-  pub delta: String,
+  pub path: Vec<String>,
+
+  #[pb(index = 3)]
+  pub id: String,
+
+  #[pb(index = 4)]
+  pub value: String,
 }
 }

+ 35 - 10
frontend/rust-lib/flowy-document2/src/event_handler.rs

@@ -4,14 +4,15 @@ use crate::{
   document::DocumentDataWrapper,
   document::DocumentDataWrapper,
   entities::{
   entities::{
     ApplyActionPayloadPBV2, BlockActionPB, BlockActionPayloadPB, BlockActionTypePB, BlockEventPB,
     ApplyActionPayloadPBV2, BlockActionPB, BlockActionPayloadPB, BlockActionTypePB, BlockEventPB,
-    BlockPB, CloseDocumentPayloadPBV2, CreateDocumentPayloadPBV2, DocumentDataPB2,
-    OpenDocumentPayloadPBV2,
+    BlockEventPayloadPB, BlockPB, CloseDocumentPayloadPBV2, CreateDocumentPayloadPBV2, DeltaTypePB,
+    DocEventPB, DocumentDataPB2, OpenDocumentPayloadPBV2,
   },
   },
   manager::DocumentManager,
   manager::DocumentManager,
 };
 };
 
 
 use collab_document::blocks::{
 use collab_document::blocks::{
   json_str_to_hashmap, Block, BlockAction, BlockActionPayload, BlockActionType, BlockEvent,
   json_str_to_hashmap, Block, BlockAction, BlockActionPayload, BlockActionType, BlockEvent,
+  BlockEventPayload, DeltaType,
 };
 };
 use flowy_error::{FlowyError, FlowyResult};
 use flowy_error::{FlowyError, FlowyResult};
 use lib_dispatch::prelude::{data_result_ok, AFPluginData, AFPluginState, DataResult};
 use lib_dispatch::prelude::{data_result_ok, AFPluginData, AFPluginState, DataResult};
@@ -109,15 +110,39 @@ impl From<BlockPB> for Block {
 }
 }
 
 
 impl From<BlockEvent> for BlockEventPB {
 impl From<BlockEvent> for BlockEventPB {
-  fn from(_block_event: BlockEvent) -> Self {
-    // let delta = serde_json::to_value(&block_event.delta).unwrap();
-    // Self {
-    //   path: block_event.path.into(),
-    //   delta: delta.to_string(),
-    // }
+  fn from(payload: BlockEvent) -> Self {
     Self {
     Self {
-      path: vec![],
-      delta: "".to_string(),
+      event: payload.iter().map(|e| e.to_owned().into()).collect(),
+    }
+  }
+}
+
+impl From<BlockEventPayload> for BlockEventPayloadPB {
+  fn from(payload: BlockEventPayload) -> Self {
+    Self {
+      command: payload.command.into(),
+      path: payload.path,
+      id: payload.id,
+      value: payload.value,
+    }
+  }
+}
+
+impl From<DeltaType> for DeltaTypePB {
+  fn from(action: DeltaType) -> Self {
+    match action {
+      DeltaType::Inserted => Self::Inserted,
+      DeltaType::Updated => Self::Updated,
+      DeltaType::Removed => Self::Removed,
+    }
+  }
+}
+
+impl DocEventPB {
+  pub(crate) fn get_from(events: &Vec<BlockEvent>, is_remote: bool) -> Self {
+    Self {
+      events: events.iter().map(|e| e.to_owned().into()).collect(),
+      is_remote,
     }
     }
   }
   }
 }
 }

+ 1 - 1
frontend/rust-lib/flowy-document2/src/lib.rs

@@ -1,8 +1,8 @@
+pub mod document;
 pub mod entities;
 pub mod entities;
 pub mod event_map;
 pub mod event_map;
 pub mod manager;
 pub mod manager;
 pub mod protobuf;
 pub mod protobuf;
 
 
-mod document;
 mod event_handler;
 mod event_handler;
 mod notification;
 mod notification;

+ 15 - 29
frontend/rust-lib/flowy-document2/src/manager.rs

@@ -7,7 +7,7 @@ use std::{collections::HashMap, sync::Arc};
 
 
 use crate::{
 use crate::{
   document::{Document, DocumentDataWrapper},
   document::{Document, DocumentDataWrapper},
-  entities::{BlockEventPB, DocEventPB},
+  entities::DocEventPB,
   notification::{send_notification, DocumentNotification},
   notification::{send_notification, DocumentNotification},
 };
 };
 
 
@@ -37,25 +37,13 @@ impl DocumentManager {
     &self,
     &self,
     doc_id: String,
     doc_id: String,
     data: DocumentDataWrapper,
     data: DocumentDataWrapper,
-  ) -> FlowyResult<Arc<Document>> {
-    self.get_document(doc_id, Some(data))
-  }
-
-  fn get_document(
-    &self,
-    doc_id: String,
-    data: Option<DocumentDataWrapper>,
   ) -> FlowyResult<Arc<Document>> {
   ) -> FlowyResult<Arc<Document>> {
     let collab = self.get_collab_for_doc_id(&doc_id)?;
     let collab = self.get_collab_for_doc_id(&doc_id)?;
-    let document = Arc::new(Document::new(collab)?);
-    self.documents.write().insert(doc_id, document.clone());
-    if data.is_some() {
-      // Here use unwrap() is safe, because we have checked data.is_some() before.
-      // document
-      //   .lock()
-      //   .create_with_data(data.unwrap().0)
-      //   .map_err(|err| FlowyError::internal().context(err))?;
-    }
+    let document = Arc::new(Document::create_with_data(collab, data.0)?);
+    self
+      .documents
+      .write()
+      .insert(doc_id.clone(), document.clone());
     Ok(document)
     Ok(document)
   }
   }
 
 
@@ -63,25 +51,23 @@ impl DocumentManager {
     if let Some(doc) = self.documents.read().get(&doc_id) {
     if let Some(doc) = self.documents.read().get(&doc_id) {
       return Ok(doc.clone());
       return Ok(doc.clone());
     }
     }
+    tracing::debug!("open_document: {:?}", &doc_id);
+    let collab = self.get_collab_for_doc_id(&doc_id)?;
+    let document = Arc::new(Document::new(collab)?);
 
 
-    let document = self.get_document(doc_id.clone(), None)?;
     let clone_doc_id = doc_id.clone();
     let clone_doc_id = doc_id.clone();
-    let _document_data = document
+    document
       .lock()
       .lock()
       .open(move |events, is_remote| {
       .open(move |events, is_remote| {
-        println!("events: {:?}", events);
-        println!("is_remote: {:?}", is_remote);
         send_notification(&clone_doc_id, DocumentNotification::DidReceiveUpdate)
         send_notification(&clone_doc_id, DocumentNotification::DidReceiveUpdate)
-          .payload(DocEventPB {
-            events: events
-              .iter()
-              .map(|event| event.to_owned().into())
-              .collect::<Vec<BlockEventPB>>(),
-            is_remote: is_remote.to_owned(),
-          })
+          .payload(DocEventPB::get_from(events, is_remote))
           .send();
           .send();
       })
       })
       .map_err(|err| FlowyError::internal().context(err))?;
       .map_err(|err| FlowyError::internal().context(err))?;
+    self
+      .documents
+      .write()
+      .insert(doc_id.clone(), document.clone());
     Ok(document)
     Ok(document)
   }
   }
 
 

+ 210 - 0
frontend/rust-lib/flowy-document2/tests/document/document_test.rs

@@ -0,0 +1,210 @@
+use std::{collections::HashMap, sync::Arc, vec};
+
+use collab_document::blocks::{Block, BlockAction, BlockActionPayload, BlockActionType};
+use flowy_document2::{document::DocumentDataWrapper, manager::DocumentManager};
+use nanoid::nanoid;
+use serde_json::{json, to_value, Value};
+
+use super::util::FakeUser;
+
+#[test]
+fn restore_document() {
+  let user = FakeUser::new();
+  let manager = DocumentManager::new(Arc::new(user));
+
+  // create a document
+  let doc_id: String = nanoid!(10);
+  let data = DocumentDataWrapper::default();
+  let document_a = manager
+    .create_document(doc_id.clone(), data.clone())
+    .unwrap();
+  let data_a = document_a.lock().get_document().unwrap();
+  assert_eq!(data_a, data.0);
+
+  // open a document
+  let data_b = manager
+    .open_document(doc_id.clone())
+    .unwrap()
+    .lock()
+    .get_document()
+    .unwrap();
+  // close a document
+  _ = manager.close_document(doc_id.clone());
+  assert_eq!(data_b, data.0);
+
+  // restore
+  _ = manager.create_document(doc_id.clone(), data.clone());
+  // open a document
+  let data_b = manager
+    .open_document(doc_id.clone())
+    .unwrap()
+    .lock()
+    .get_document()
+    .unwrap();
+  // close a document
+  _ = manager.close_document(doc_id.clone());
+
+  assert_eq!(data_b, data.0);
+}
+
+#[test]
+fn document_apply_insert_action() {
+  let user = FakeUser::new();
+  let manager = DocumentManager::new(Arc::new(user));
+
+  let doc_id: String = nanoid!(10);
+  let data = DocumentDataWrapper::default();
+
+  // create a document
+  _ = manager.create_document(doc_id.clone(), data.clone());
+
+  // open a document
+  let document = manager.open_document(doc_id.clone()).unwrap();
+  let page_block = document.lock().get_block(&data.0.page_id).unwrap();
+
+  // insert a text block
+  let text_block = Block {
+    id: nanoid!(10),
+    ty: "text".to_string(),
+    parent: page_block.id.clone(),
+    children: nanoid!(10),
+    external_id: None,
+    external_type: None,
+    data: HashMap::new(),
+  };
+  let insert_text_action = BlockAction {
+    action: BlockActionType::Insert,
+    payload: BlockActionPayload {
+      block: text_block.clone(),
+      parent_id: None,
+      prev_id: None,
+    },
+  };
+  document.lock().apply_action(vec![insert_text_action]);
+  let data_a = document.lock().get_document().unwrap();
+  // close the original document
+  _ = manager.close_document(doc_id.clone());
+
+  // re-open the document
+  let data_b = manager
+    .open_document(doc_id.clone())
+    .unwrap()
+    .lock()
+    .get_document()
+    .unwrap();
+  // close a document
+  _ = manager.close_document(doc_id.clone());
+
+  assert_eq!(data_b, data_a);
+}
+
+#[test]
+fn document_apply_update_page_action() {
+  let user = FakeUser::new();
+  let manager = DocumentManager::new(Arc::new(user));
+
+  let doc_id: String = nanoid!(10);
+  let data = DocumentDataWrapper::default();
+
+  // create a document
+  _ = manager.create_document(doc_id.clone(), data.clone());
+
+  // open a document
+  let document = manager.open_document(doc_id.clone()).unwrap();
+  let page_block = document.lock().get_block(&data.0.page_id).unwrap();
+
+  let mut page_block_clone = page_block.clone();
+  page_block_clone.data = HashMap::new();
+  page_block_clone.data.insert(
+    "delta".to_string(),
+    to_value(json!([{"insert": "Hello World!"}])).unwrap(),
+  );
+  let action = BlockAction {
+    action: BlockActionType::Update,
+    payload: BlockActionPayload {
+      block: page_block_clone,
+      parent_id: None,
+      prev_id: None,
+    },
+  };
+  let actions = vec![action];
+  tracing::trace!("{:?}", &actions);
+  document.lock().apply_action(actions);
+  let page_block_old = document.lock().get_block(&data.0.page_id).unwrap();
+  _ = manager.close_document(doc_id.clone());
+
+  // re-open the document
+  let document = manager.open_document(doc_id.clone()).unwrap();
+  let page_block_new = document.lock().get_block(&data.0.page_id).unwrap();
+  assert_eq!(page_block_old, page_block_new);
+  assert!(page_block_new.data.contains_key("delta"));
+}
+
+#[test]
+fn document_apply_update_action() {
+  let user = FakeUser::new();
+  let manager = DocumentManager::new(Arc::new(user));
+
+  let doc_id: String = nanoid!(10);
+  let data = DocumentDataWrapper::default();
+
+  // create a document
+  _ = manager.create_document(doc_id.clone(), data.clone());
+
+  // open a document
+  let document = manager.open_document(doc_id.clone()).unwrap();
+  let page_block = document.lock().get_block(&data.0.page_id).unwrap();
+
+  // insert a text block
+  let text_block_id = nanoid!(10);
+  let text_block = Block {
+    id: text_block_id.clone(),
+    ty: "text".to_string(),
+    parent: page_block.id.clone(),
+    children: nanoid!(10),
+    external_id: None,
+    external_type: None,
+    data: HashMap::new(),
+  };
+  let insert_text_action = BlockAction {
+    action: BlockActionType::Insert,
+    payload: BlockActionPayload {
+      block: text_block.clone(),
+      parent_id: None,
+      prev_id: None,
+    },
+  };
+  document.lock().apply_action(vec![insert_text_action]);
+
+  // update the text block
+  let existing_text_block = document.lock().get_block(&text_block_id).unwrap();
+  let mut updated_text_block_data = HashMap::new();
+  updated_text_block_data.insert("delta".to_string(), Value::String("delta".to_string()));
+  let updated_text_block = Block {
+    id: existing_text_block.id,
+    ty: existing_text_block.ty,
+    parent: existing_text_block.parent,
+    children: existing_text_block.children,
+    external_id: None,
+    external_type: None,
+    data: updated_text_block_data.clone(),
+  };
+  let update_text_action = BlockAction {
+    action: BlockActionType::Update,
+    payload: BlockActionPayload {
+      block: updated_text_block.clone(),
+      parent_id: None,
+      prev_id: None,
+    },
+  };
+  document.lock().apply_action(vec![update_text_action]);
+  // close the original document
+  _ = manager.close_document(doc_id.clone());
+
+  // re-open the document
+  let document = manager.open_document(doc_id.clone()).unwrap();
+  let block = document.lock().get_block(&text_block_id).unwrap();
+  assert_eq!(block.data, updated_text_block_data);
+  // close a document
+  _ = manager.close_document(doc_id.clone());
+}

+ 2 - 0
frontend/rust-lib/flowy-document2/tests/document/mod.rs

@@ -0,0 +1,2 @@
+mod document_test;
+mod util;

+ 47 - 0
frontend/rust-lib/flowy-document2/tests/document/util.rs

@@ -0,0 +1,47 @@
+use std::sync::Arc;
+
+use collab_persistence::CollabKV;
+use flowy_document2::manager::DocumentUser;
+use parking_lot::Once;
+use tempfile::TempDir;
+use tracing_subscriber::{fmt::Subscriber, util::SubscriberInitExt, EnvFilter};
+
+pub struct FakeUser {
+  kv: Arc<CollabKV>,
+}
+
+impl FakeUser {
+  pub fn new() -> Self {
+    Self { kv: db() }
+  }
+}
+
+impl DocumentUser for FakeUser {
+  fn user_id(&self) -> Result<i64, flowy_error::FlowyError> {
+    Ok(1)
+  }
+
+  fn token(&self) -> Result<String, flowy_error::FlowyError> {
+    Ok("1".to_string())
+  }
+
+  fn kv_db(&self) -> Result<std::sync::Arc<CollabKV>, flowy_error::FlowyError> {
+    Ok(self.kv.clone())
+  }
+}
+
+pub fn db() -> Arc<CollabKV> {
+  static START: Once = Once::new();
+  START.call_once(|| {
+    std::env::set_var("RUST_LOG", "collab_persistence=trace");
+    let subscriber = Subscriber::builder()
+      .with_env_filter(EnvFilter::from_default_env())
+      .with_ansi(true)
+      .finish();
+    subscriber.try_init().unwrap();
+  });
+
+  let tempdir = TempDir::new().unwrap();
+  let path = tempdir.into_path();
+  Arc::new(CollabKV::open(path).unwrap())
+}

+ 0 - 0
frontend/rust-lib/flowy-document2/tests/document_test.rs


+ 1 - 0
frontend/rust-lib/flowy-document2/tests/main.rs

@@ -0,0 +1 @@
+mod document;

+ 0 - 1
frontend/rust-lib/flowy-folder2/src/manager.rs

@@ -132,7 +132,6 @@ impl Folder2Manager {
   /// Called when the current user logout
   /// Called when the current user logout
   ///
   ///
   pub async fn clear(&self, _user_id: i64) {
   pub async fn clear(&self, _user_id: i64) {
-    todo!()
   }
   }
 
 
   pub async fn create_workspace(&self, params: CreateWorkspaceParams) -> FlowyResult<Workspace> {
   pub async fn create_workspace(&self, params: CreateWorkspaceParams) -> FlowyResult<Workspace> {