Browse Source

feat: support updating the view name and icon through document (#3099)

* feat: support updating the view name and icon through document

* fix: store expand pages

* fix: refactor text link

* fix: update cargo.toml

* fix: update test

* fix: update event map

* fix: move deal with icon codes to a single file

* fix: delete useless code from flutter

* fix: document banner

* fix: build error

* fix: update rust library

---------

Co-authored-by: Lucas.Xu <[email protected]>
Kilu.He 1 year ago
parent
commit
16a01e11ed
91 changed files with 1295 additions and 1297 deletions
  1. 1 14
      frontend/appflowy_flutter/lib/plugins/database_view/application/row/row_banner_bloc.dart
  2. 1 1
      frontend/appflowy_flutter/lib/workspace/application/view/view_ext.dart
  3. 0 10
      frontend/appflowy_flutter/lib/workspace/application/view/view_service.dart
  4. 1 1
      frontend/appflowy_flutter/lib/workspace/presentation/home/menu/view/view_item.dart
  5. 10 10
      frontend/appflowy_tauri/src-tauri/Cargo.lock
  6. 14 14
      frontend/appflowy_tauri/src-tauri/Cargo.toml
  7. 13 23
      frontend/appflowy_tauri/src/appflowy_app/AppMain.hooks.ts
  8. 82 36
      frontend/appflowy_tauri/src/appflowy_app/components/_shared/EmojiPicker/EmojiPicker.hooks.ts
  9. 18 3
      frontend/appflowy_tauri/src/appflowy_app/components/_shared/EmojiPicker/EmojiPickerCategories.tsx
  10. 18 10
      frontend/appflowy_tauri/src/appflowy_app/components/_shared/EmojiPicker/EmojiPickerHeader.tsx
  11. 10 15
      frontend/appflowy_tauri/src/appflowy_app/components/_shared/EmojiPicker/index.tsx
  12. 4 3
      frontend/appflowy_tauri/src/appflowy_app/components/auth/GetStarted/GetStarted.tsx
  13. 1 1
      frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSideToolbar/BlockSideToolbar.hooks.tsx
  14. 72 0
      frontend/appflowy_tauri/src/appflowy_app/components/document/DocumentBanner/DocumentBanner.hooks.ts
  15. 5 2
      frontend/appflowy_tauri/src/appflowy_app/components/document/DocumentBanner/DocumentIcon.tsx
  16. 10 5
      frontend/appflowy_tauri/src/appflowy_app/components/document/DocumentBanner/TitleButtonGroup.tsx
  17. 0 0
      frontend/appflowy_tauri/src/appflowy_app/components/document/DocumentBanner/cover/ChangeColors.tsx
  18. 5 6
      frontend/appflowy_tauri/src/appflowy_app/components/document/DocumentBanner/cover/ChangeCoverButton.tsx
  19. 11 11
      frontend/appflowy_tauri/src/appflowy_app/components/document/DocumentBanner/cover/ChangeCoverPopover.tsx
  20. 2 2
      frontend/appflowy_tauri/src/appflowy_app/components/document/DocumentBanner/cover/ChangeImages.tsx
  21. 10 6
      frontend/appflowy_tauri/src/appflowy_app/components/document/DocumentBanner/cover/DocumentCover.tsx
  22. 0 0
      frontend/appflowy_tauri/src/appflowy_app/components/document/DocumentBanner/cover/GalleryItem.tsx
  23. 1 1
      frontend/appflowy_tauri/src/appflowy_app/components/document/DocumentBanner/cover/GalleryList.tsx
  24. 0 0
      frontend/appflowy_tauri/src/appflowy_app/components/document/DocumentBanner/cover/config.ts
  25. 30 0
      frontend/appflowy_tauri/src/appflowy_app/components/document/DocumentBanner/index.tsx
  26. 13 32
      frontend/appflowy_tauri/src/appflowy_app/components/document/DocumentTitle/DocumentTitle.hooks.ts
  27. 0 44
      frontend/appflowy_tauri/src/appflowy_app/components/document/DocumentTitle/DocumentTopPanel.tsx
  28. 3 11
      frontend/appflowy_tauri/src/appflowy_app/components/document/DocumentTitle/index.tsx
  29. 0 3
      frontend/appflowy_tauri/src/appflowy_app/components/document/Overlay/index.tsx
  30. 2 2
      frontend/appflowy_tauri/src/appflowy_app/components/document/QuoteBlock/index.tsx
  31. 2 11
      frontend/appflowy_tauri/src/appflowy_app/components/document/TextActionMenu/menu/FormatButton.tsx
  32. 7 7
      frontend/appflowy_tauri/src/appflowy_app/components/document/VirtualizedList/index.tsx
  33. 1 2
      frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/InlineBlock/InlineContainer.tsx
  34. 67 0
      frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/InlineBlock/LinkInline.tsx
  35. 10 16
      frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SlateEditor/TextLeaf.tsx
  36. 1 6
      frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SlateEditor/useEditor.ts
  37. 0 13
      frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SubscribeLinkPopover.hooks.ts
  38. 0 11
      frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SubscribeSelection.hooks.ts
  39. 125 0
      frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/TemporaryInput/LinkEditContent.tsx
  40. 0 1
      frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/TemporaryInput/TemporaryEquation.tsx
  41. 20 0
      frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/TemporaryInput/TemporaryLink.tsx
  42. 13 1
      frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/TemporaryInput/TemporaryPopover.tsx
  43. 14 3
      frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/TemporaryInput/index.tsx
  44. 0 39
      frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/TextLink/EditLink.tsx
  45. 0 96
      frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/TextLink/EditLinkToolbar.tsx
  46. 0 135
      frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/TextLink/LinkEditPopover.tsx
  47. 0 16
      frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/TextLink/LinkHighLight.tsx
  48. 0 27
      frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/TextLink/TextLink.hooks.ts
  49. 0 84
      frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/TextLink/index.tsx
  50. 29 28
      frontend/appflowy_tauri/src/appflowy_app/components/layout/Breadcrumb/Breadcrumb.hooks.ts
  51. 21 38
      frontend/appflowy_tauri/src/appflowy_app/components/layout/NestedPage/NestedPage.hooks.ts
  52. 8 2
      frontend/appflowy_tauri/src/appflowy_app/components/layout/NestedPage/NestedPageTitle.tsx
  53. 6 1
      frontend/appflowy_tauri/src/appflowy_app/components/layout/NestedPage/RenameDialog.tsx
  54. 6 2
      frontend/appflowy_tauri/src/appflowy_app/components/layout/NestedPage/index.tsx
  55. 1 0
      frontend/appflowy_tauri/src/appflowy_app/components/layout/UserSetting/AppearanceSetting.tsx
  56. 1 1
      frontend/appflowy_tauri/src/appflowy_app/components/layout/WorkspaceManager/NewPageButton.tsx
  57. 1 15
      frontend/appflowy_tauri/src/appflowy_app/components/layout/WorkspaceManager/Workspace.hooks.ts
  58. 10 19
      frontend/appflowy_tauri/src/appflowy_app/interfaces/document.ts
  59. 18 3
      frontend/appflowy_tauri/src/appflowy_app/stores/effects/user/user_setting_controller.ts
  60. 17 8
      frontend/appflowy_tauri/src/appflowy_app/stores/effects/workspace/page/page_bd_svc.ts
  61. 19 26
      frontend/appflowy_tauri/src/appflowy_app/stores/effects/workspace/page/page_controller.ts
  62. 6 29
      frontend/appflowy_tauri/src/appflowy_app/stores/effects/workspace/workspace_controller.ts
  63. 10 10
      frontend/appflowy_tauri/src/appflowy_app/stores/effects/workspace/workspace_observer.ts
  64. 18 2
      frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/update.ts
  65. 0 1
      frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/index.ts
  66. 89 23
      frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/keydown.ts
  67. 0 103
      frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/link.ts
  68. 34 4
      frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/temporary.ts
  69. 18 63
      frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/slice.ts
  70. 34 0
      frontend/appflowy_tauri/src/appflowy_app/stores/reducers/pages/async_actions.ts
  71. 34 8
      frontend/appflowy_tauri/src/appflowy_app/stores/reducers/pages/slice.ts
  72. 2 2
      frontend/appflowy_tauri/src/appflowy_app/utils/document/emoji.ts
  73. 0 3
      frontend/appflowy_tauri/src/appflowy_app/views/DocumentPage.hooks.ts
  74. 4 0
      frontend/appflowy_tauri/src/styles/mui.css
  75. 5 1
      frontend/resources/translations/en.json
  76. 56 56
      frontend/rust-lib/Cargo.lock
  77. 13 13
      frontend/rust-lib/Cargo.toml
  78. 85 0
      frontend/rust-lib/flowy-folder2/src/entities/icon.rs
  79. 2 0
      frontend/rust-lib/flowy-folder2/src/entities/mod.rs
  80. 0 4
      frontend/rust-lib/flowy-folder2/src/entities/parser/view/view_name.rs
  81. 9 27
      frontend/rust-lib/flowy-folder2/src/entities/view.rs
  82. 12 1
      frontend/rust-lib/flowy-folder2/src/event_handler.rs
  83. 4 0
      frontend/rust-lib/flowy-folder2/src/event_map.rs
  84. 45 23
      frontend/rust-lib/flowy-folder2/src/manager.rs
  85. 5 9
      frontend/rust-lib/flowy-folder2/src/view_operation.rs
  86. 32 5
      frontend/rust-lib/flowy-folder2/tests/workspace/folder_test.rs
  87. 19 0
      frontend/rust-lib/flowy-folder2/tests/workspace/script.rs
  88. 11 1
      frontend/rust-lib/flowy-test/src/lib.rs
  89. 9 26
      frontend/rust-lib/flowy-test/tests/folder/local_test/test.rs
  90. 4 5
      frontend/rust-lib/flowy-user/src/migrations/migration.rs
  91. 1 1
      frontend/rust-lib/flowy-user/src/services/user_session.rs

+ 1 - 14
frontend/appflowy_flutter/lib/plugins/database_view/application/row/row_banner_bloc.dart

@@ -1,7 +1,6 @@
 import 'package:appflowy/plugins/database_view/application/field/field_listener.dart';
 import 'package:appflowy/plugins/database_view/application/field/field_service.dart';
 import 'package:appflowy/plugins/database_view/application/row/row_service.dart';
-import 'package:appflowy/workspace/application/view/prelude.dart';
 import 'package:appflowy_backend/log.dart';
 import 'package:appflowy_backend/protobuf/flowy-database2/field_entities.pb.dart';
 import 'package:appflowy_backend/protobuf/flowy-database2/row_entities.pb.dart';
@@ -115,18 +114,6 @@ class RowBannerBloc extends Bloc<RowBannerEvent, RowBannerState> {
         (err) => Log.error(err),
       );
     });
-
-    // Set the icon and cover of the view
-    ViewBackendService.updateView(
-      viewId: viewId,
-      iconURL: iconURL,
-      coverURL: coverURL,
-    ).then((result) {
-      result.fold(
-        (l) => null,
-        (err) => Log.error(err),
-      );
-    });
   }
 }
 
@@ -136,7 +123,7 @@ class RowBannerEvent with _$RowBannerEvent {
   const factory RowBannerEvent.didReceiveRowMeta(RowMetaPB rowMeta) =
       _DidReceiveRowMeta;
   const factory RowBannerEvent.didReceiveFieldUpdate(FieldPB field) =
-      _DidReceiveFieldUdate;
+      _DidReceiveFieldUpdate;
   const factory RowBannerEvent.setIcon(String iconURL) = _SetIcon;
   const factory RowBannerEvent.setCover(String coverURL) = _SetCover;
 }

+ 1 - 1
frontend/appflowy_flutter/lib/workspace/application/view/view_ext.dart

@@ -44,7 +44,7 @@ extension ViewExtension on ViewPB {
     return widget;
   }
 
-  Widget icon() {
+  Widget defaultIcon() {
     final iconName = switch (layout) {
       ViewLayoutPB.Board => 'editor/board',
       ViewLayoutPB.Calendar => 'editor/calendar',

+ 0 - 10
frontend/appflowy_flutter/lib/workspace/application/view/view_service.dart

@@ -138,8 +138,6 @@ class ViewBackendService {
   static Future<Either<ViewPB, FlowyError>> updateView({
     required String viewId,
     String? name,
-    String? iconURL,
-    String? coverURL,
     bool? isFavorite,
   }) {
     final payload = UpdateViewPayloadPB.create()..viewId = viewId;
@@ -148,14 +146,6 @@ class ViewBackendService {
       payload.name = name;
     }
 
-    if (iconURL != null) {
-      payload.iconUrl = iconURL;
-    }
-
-    if (coverURL != null) {
-      payload.coverUrl = coverURL;
-    }
-
     if (isFavorite != null) {
       payload.isFavorite = isFavorite;
     }

+ 1 - 1
frontend/appflowy_flutter/lib/workspace/presentation/home/menu/view/view_item.dart

@@ -229,7 +229,7 @@ class _SingleInnerViewItemState extends State<SingleInnerViewItem> {
       // icon
       SizedBox.square(
         dimension: 16,
-        child: widget.view.icon(),
+        child: widget.view.defaultIcon(),
       ),
       const HSpace(5),
       // title

+ 10 - 10
frontend/appflowy_tauri/src-tauri/Cargo.lock

@@ -105,7 +105,7 @@ checksum = "9c7d0618f0e0b7e8ff11427422b64564d5fb0be1940354bfe2e0529b18a9d9b8"
 [[package]]
 name = "appflowy-integrate"
 version = "0.1.0"
-source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=e9a50f#e9a50f780fa97b1e3fce6ffe7720e9585d674974"
+source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=aac4e56#aac4e56bdb6a61598697283582d369a7af85d9c8"
 dependencies = [
  "anyhow",
  "collab",
@@ -1021,7 +1021,7 @@ dependencies = [
 [[package]]
 name = "collab"
 version = "0.1.0"
-source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=e9a50f#e9a50f780fa97b1e3fce6ffe7720e9585d674974"
+source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=aac4e56#aac4e56bdb6a61598697283582d369a7af85d9c8"
 dependencies = [
  "anyhow",
  "bytes",
@@ -1039,7 +1039,7 @@ dependencies = [
 [[package]]
 name = "collab-client-ws"
 version = "0.1.0"
-source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=e9a50f#e9a50f780fa97b1e3fce6ffe7720e9585d674974"
+source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=aac4e56#aac4e56bdb6a61598697283582d369a7af85d9c8"
 dependencies = [
  "bytes",
  "collab-sync",
@@ -1057,7 +1057,7 @@ dependencies = [
 [[package]]
 name = "collab-database"
 version = "0.1.0"
-source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=e9a50f#e9a50f780fa97b1e3fce6ffe7720e9585d674974"
+source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=aac4e56#aac4e56bdb6a61598697283582d369a7af85d9c8"
 dependencies = [
  "anyhow",
  "async-trait",
@@ -1084,7 +1084,7 @@ dependencies = [
 [[package]]
 name = "collab-derive"
 version = "0.1.0"
-source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=e9a50f#e9a50f780fa97b1e3fce6ffe7720e9585d674974"
+source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=aac4e56#aac4e56bdb6a61598697283582d369a7af85d9c8"
 dependencies = [
  "proc-macro2",
  "quote",
@@ -1096,7 +1096,7 @@ dependencies = [
 [[package]]
 name = "collab-document"
 version = "0.1.0"
-source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=e9a50f#e9a50f780fa97b1e3fce6ffe7720e9585d674974"
+source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=aac4e56#aac4e56bdb6a61598697283582d369a7af85d9c8"
 dependencies = [
  "anyhow",
  "collab",
@@ -1115,7 +1115,7 @@ dependencies = [
 [[package]]
 name = "collab-folder"
 version = "0.1.0"
-source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=e9a50f#e9a50f780fa97b1e3fce6ffe7720e9585d674974"
+source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=aac4e56#aac4e56bdb6a61598697283582d369a7af85d9c8"
 dependencies = [
  "anyhow",
  "chrono",
@@ -1135,7 +1135,7 @@ dependencies = [
 [[package]]
 name = "collab-persistence"
 version = "0.1.0"
-source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=e9a50f#e9a50f780fa97b1e3fce6ffe7720e9585d674974"
+source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=aac4e56#aac4e56bdb6a61598697283582d369a7af85d9c8"
 dependencies = [
  "bincode",
  "chrono",
@@ -1155,7 +1155,7 @@ dependencies = [
 [[package]]
 name = "collab-plugins"
 version = "0.1.0"
-source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=e9a50f#e9a50f780fa97b1e3fce6ffe7720e9585d674974"
+source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=aac4e56#aac4e56bdb6a61598697283582d369a7af85d9c8"
 dependencies = [
  "anyhow",
  "async-trait",
@@ -1185,7 +1185,7 @@ dependencies = [
 [[package]]
 name = "collab-sync"
 version = "0.1.0"
-source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=e9a50f#e9a50f780fa97b1e3fce6ffe7720e9585d674974"
+source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=aac4e56#aac4e56bdb6a61598697283582d369a7af85d9c8"
 dependencies = [
  "bytes",
  "collab",

+ 14 - 14
frontend/appflowy_tauri/src-tauri/Cargo.toml

@@ -34,20 +34,20 @@ default = ["custom-protocol"]
 custom-protocol = ["tauri/custom-protocol"]
 
 [patch.crates-io]
-collab = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "e9a50f" }
-collab-folder = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "e9a50f" }
-collab-persistence = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "e9a50f" }
-collab-document = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "e9a50f" }
-collab-database = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "e9a50f" }
-appflowy-integrate = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "e9a50f" }
-collab-plugins = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "e9a50f" }
-
-#collab = { path = "../../AppFlowy-Collab/collab" }
-#collab-folder = { path = "../../AppFlowy-Collab/collab-folder" }
-#collab-document = { path = "../../AppFlowy-Collab/collab-document" }
-#collab-database = { path = "../../AppFlowy-Collab/collab-database" }
-#appflowy-integrate = { path = "../../AppFlowy-Collab/appflowy-integrate" }
-#collab-plugins = { path = "../../AppFlowy-Collab/collab-plugins" }
+collab = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "aac4e56" }
+collab-folder = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "aac4e56" }
+collab-persistence = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "aac4e56" }
+collab-document = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "aac4e56" }
+collab-database = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "aac4e56" }
+appflowy-integrate = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "aac4e56" }
+collab-plugins = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "aac4e56" }
+
+#collab = { path = "../../../../AppFlowy-Collab/collab" }
+#collab-folder = { path = "../../../../AppFlowy-Collab/collab-folder" }
+#collab-document = { path = "../../../../AppFlowy-Collab/collab-document" }
+#collab-database = { path = "../../../../AppFlowy-Collab/collab-database" }
+#appflowy-integrate = { path = "../../../../AppFlowy-Collab/appflowy-integrate" }
+#collab-plugins = { path = "../../../../AppFlowy-Collab/collab-plugins" }
 
 
 

+ 13 - 23
frontend/appflowy_tauri/src/appflowy_app/AppMain.hooks.ts

@@ -1,8 +1,8 @@
 import { useAppDispatch, useAppSelector } from '$app/stores/store';
-import { useEffect, useMemo } from 'react';
+import { useCallback, useEffect, useMemo } from 'react';
 import { UserSettingController } from '$app/stores/effects/user/user_setting_controller';
 import { currentUserActions } from '$app_reducers/current-user/slice';
-import { Theme as ThemeType, Theme, ThemeMode } from '$app/interfaces';
+import { Theme as ThemeType, ThemeMode } from '$app/interfaces';
 import { createTheme } from '@mui/material/styles';
 import { getDesignTokens } from '$app/utils/mui';
 import { useTranslation } from 'react-i18next';
@@ -18,28 +18,18 @@ export function useUserSetting() {
     return controller;
   }, [currentUser?.id]);
 
+  const loadUserSetting = useCallback(async () => {
+    if (!userSettingController) return;
+    const settings = await userSettingController.getAppearanceSetting();
+
+    if (!settings) return;
+    dispatch(currentUserActions.setUserSetting(settings));
+    await i18n.changeLanguage(settings.language);
+  }, [dispatch, i18n, userSettingController]);
+
   useEffect(() => {
-    userSettingController?.getAppearanceSetting().then((res) => {
-      if (!res) return;
-      const locale = res.locale;
-      let language = 'en';
-
-      if (locale.language_code && locale.country_code) {
-        language = `${locale.language_code}-${locale.country_code}`;
-      } else if (locale.language_code) {
-        language = locale.language_code;
-      }
-
-      dispatch(
-        currentUserActions.setUserSetting({
-          themeMode: res.theme_mode,
-          theme: res.theme as Theme,
-          language: language,
-        })
-      );
-      i18n.changeLanguage(language);
-    });
-  }, [i18n, dispatch, userSettingController]);
+    void loadUserSetting();
+  }, [loadUserSetting]);
 
   const { themeMode = ThemeMode.Light, theme: themeType = ThemeType.Default } = useAppSelector((state) => {
     return state.currentUser.userSetting || {};

+ 82 - 36
frontend/appflowy_tauri/src/appflowy_app/components/_shared/EmojiPicker/EmojiPicker.hooks.ts

@@ -1,10 +1,18 @@
-import { useCallback, useEffect, useRef, useState } from 'react';
+import React, { useCallback, useEffect, useRef, useState } from 'react';
 import emojiData, { EmojiMartData } from '@emoji-mart/data';
+import { init, FrequentlyUsed, getEmojiDataFromNative, Store } from 'emoji-mart';
+
 import { PopoverProps } from '@mui/material/Popover';
 import { PopoverOrigin } from '@mui/material/Popover/Popover';
 import { useVirtualizer } from '@tanstack/react-virtual';
 import { chunkArray } from '$app/utils/tool';
 
+export const EMOJI_SIZE = 32;
+
+export const PER_ROW_EMOJI_COUNT = 13;
+
+export const MAX_FREQUENTLY_ROW_COUNT = 2;
+
 export interface EmojiCategory {
   id: string;
   emojis: Emoji[];
@@ -15,48 +23,87 @@ interface Emoji {
   name: string;
   native: string;
 }
-export function useLoadEmojiData({ skin }: { skin: number }) {
+
+export function useLoadEmojiData({ onEmojiSelect }: { onEmojiSelect: (emoji: string) => void }) {
   const [searchValue, setSearchValue] = useState('');
   const [emojiCategories, setEmojiCategories] = useState<EmojiCategory[]>([]);
+  const [skin, setSkin] = useState<number>(() => {
+    return Number(Store.get('skin')) || 0;
+  });
+
+  const onSkinChange = useCallback((val: number) => {
+    setSkin(val);
+    Store.set('skin', String(val));
+  }, []);
+
+  const loadEmojiData = useCallback(
+    async (searchVal?: string) => {
+      const { emojis, categories } = emojiData as EmojiMartData;
+
+      const filteredCategories = categories
+        .map((category) => {
+          const { id, emojis: categoryEmojis } = category;
+
+          return {
+            id,
+            emojis: categoryEmojis
+              .filter((emojiId) => {
+                const emoji = emojis[emojiId];
+
+                if (!searchVal) return true;
+                return filterSearchValue(emoji, searchVal);
+              })
+              .map((emojiId) => {
+                const emoji = emojis[emojiId];
+                const { name, skins } = emoji;
+
+                return {
+                  id: emojiId,
+                  name,
+                  native: skins[skin] ? skins[skin].native : skins[0].native,
+                };
+              }),
+          };
+        })
+        .filter((category) => category.emojis.length > 0);
+
+      setEmojiCategories(filteredCategories);
+    },
+    [skin]
+  );
+
+  useEffect(() => {
+    void (async () => {
+      await init({ data: emojiData, maxFrequentRows: MAX_FREQUENTLY_ROW_COUNT, perLine: PER_ROW_EMOJI_COUNT });
+      await loadEmojiData();
+    })();
+  }, [loadEmojiData]);
 
   useEffect(() => {
-    const { emojis, categories } = emojiData as EmojiMartData;
-
-    const emojiCategories = categories
-      .map((category) => {
-        const { id, emojis: categoryEmojis } = category;
-
-        return {
-          id,
-          emojis: categoryEmojis
-            .filter((emojiId) => {
-              const emoji = emojis[emojiId];
-
-              if (!searchValue) return true;
-              return filterSearchValue(emoji, searchValue);
-            })
-            .map((emojiId) => {
-              const emoji = emojis[emojiId];
-              const { id, name, skins } = emoji;
-
-              return {
-                id,
-                name,
-                native: skins[skin] ? skins[skin].native : skins[0].native,
-              };
-            }),
-        };
-      })
-      .filter((category) => category.emojis.length > 0);
-
-    setEmojiCategories(emojiCategories);
-  }, [skin, searchValue]);
+    void loadEmojiData(searchValue);
+  }, [loadEmojiData, searchValue]);
+
+  const onSelect = useCallback(
+    async (native: string) => {
+      onEmojiSelect(native);
+      if (!native) {
+        return;
+      }
+
+      const data = await getEmojiDataFromNative(native);
+
+      FrequentlyUsed.add(data);
+    },
+    [onEmojiSelect]
+  );
 
   return {
     emojiCategories,
-    skin,
     setSearchValue,
     searchValue,
+    onSelect,
+    onSkinChange,
+    skin,
   };
 }
 
@@ -124,9 +171,8 @@ export function useVirtualizedCategories({ count }: { count: number }) {
     count,
     getScrollElement: () => ref.current,
     estimateSize: () => {
-      return 60;
+      return EMOJI_SIZE;
     },
-    overscan: 3,
   });
 
   return { virtualize, ref };

+ 18 - 3
frontend/appflowy_tauri/src/appflowy_app/components/_shared/EmojiPicker/EmojiPickerCategories.tsx

@@ -1,7 +1,9 @@
 import React, { useCallback, useMemo } from 'react';
 import {
+  EMOJI_SIZE,
   EmojiCategory,
   getRowsWithCategories,
+  PER_ROW_EMOJI_COUNT,
   useVirtualizedCategories,
 } from '$app/components/_shared/EmojiPicker/EmojiPicker.hooks';
 import { useTranslation } from 'react-i18next';
@@ -16,7 +18,7 @@ function EmojiPickerCategories({
 }) {
   const { t } = useTranslation();
   const rows = useMemo(() => {
-    return getRowsWithCategories(emojiCategories, 13);
+    return getRowsWithCategories(emojiCategories, PER_ROW_EMOJI_COUNT);
   }, [emojiCategories]);
 
   const { ref, virtualize } = useVirtualizedCategories({
@@ -27,6 +29,7 @@ function EmojiPickerCategories({
   const getCategoryName = useCallback(
     (id: string) => {
       const i18nName: Record<string, string> = {
+        frequent: t('emoji.categories.frequentlyUsed'),
         people: t('emoji.categories.people'),
         nature: t('emoji.categories.nature'),
         foods: t('emoji.categories.food'),
@@ -43,7 +46,12 @@ function EmojiPickerCategories({
   );
 
   return (
-    <div ref={ref} className={'mt-2 w-[416px] flex-1 items-center justify-center overflow-y-auto overflow-x-hidden'}>
+    <div
+      ref={ref}
+      className={`mt-2 w-[${
+        EMOJI_SIZE * PER_ROW_EMOJI_COUNT
+      }px] flex-1 items-center justify-center overflow-y-auto overflow-x-hidden`}
+    >
       <div
         style={{
           height: virtualize.getTotalSize(),
@@ -72,7 +80,14 @@ function EmojiPickerCategories({
                   <div className={'flex'}>
                     {item.emojis?.map((emoji) => {
                       return (
-                        <div key={emoji.id} className={'flex h-[32px] w-[32px] items-center justify-center'}>
+                        <div
+                          key={emoji.id}
+                          style={{
+                            width: EMOJI_SIZE,
+                            height: EMOJI_SIZE,
+                          }}
+                          className={`flex items-center justify-center`}
+                        >
                           <IconButton
                             size={'small'}
                             onClick={() => {

+ 18 - 10
frontend/appflowy_tauri/src/appflowy_app/components/_shared/EmojiPicker/EmojiPickerHeader.tsx

@@ -1,6 +1,6 @@
 import React from 'react';
 import { Box, IconButton } from '@mui/material';
-import { DeleteOutlineRounded, SearchOutlined } from '@mui/icons-material';
+import { Circle, DeleteOutlineRounded, SearchOutlined } from '@mui/icons-material';
 import TextField from '@mui/material/TextField';
 import Tooltip from '@mui/material/Tooltip';
 import { randomEmoji } from '$app/utils/document/emoji';
@@ -12,26 +12,26 @@ import { useTranslation } from 'react-i18next';
 const skinTones = [
   {
     value: 0,
-    label: '✋',
+    color: '#ffc93a',
   },
   {
-    label: '✋🏻',
+    color: '#ffdab7',
     value: 1,
   },
   {
-    label: '✋🏼',
+    color: '#e7b98f',
     value: 2,
   },
   {
-    label: '✋🏽',
+    color: '#c88c61',
     value: 3,
   },
   {
-    label: '✋🏾',
+    color: '#a46134',
     value: 4,
   },
   {
-    label: '✋🏿',
+    color: '#5d4437',
     value: 5,
   },
 ];
@@ -78,7 +78,11 @@ function EmojiPickerHeader({ onEmojiSelect, onSkinSelect, searchValue, onSearchC
         <Tooltip title={t('emoji.selectSkinTone')}>
           <div className={'random-emoji-btn mr-2 rounded border border-line-divider'}>
             <IconButton size={'small'} className={'h-[25px] w-[25px]'} onClick={onOpen}>
-              {skinTones[skin].label}
+              <Circle
+                style={{
+                  fill: skinTones[skin].color,
+                }}
+              />
             </IconButton>
           </div>
         </Tooltip>
@@ -100,7 +104,7 @@ function EmojiPickerHeader({ onEmojiSelect, onSkinSelect, searchValue, onSearchC
             <div className={'mx-0.5'} key={skinTone.value}>
               <IconButton
                 style={{
-                  backgroundColor: skinTone.value === skin ? 'var(--fill-list-hover)' : 'transparent',
+                  backgroundColor: skinTone.value === skin ? 'var(--fill-list-hover)' : undefined,
                 }}
                 size={'small'}
                 onClick={() => {
@@ -108,7 +112,11 @@ function EmojiPickerHeader({ onEmojiSelect, onSkinSelect, searchValue, onSearchC
                   popoverProps.onClose?.();
                 }}
               >
-                {skinTone.label}
+                <Circle
+                  style={{
+                    fill: skinTone.color,
+                  }}
+                />
               </IconButton>
             </div>
           ))}

+ 10 - 15
frontend/appflowy_tauri/src/appflowy_app/components/_shared/EmojiPicker/index.tsx

@@ -1,33 +1,28 @@
-import React, { useState } from 'react';
+import React from 'react';
 
-import { useLoadEmojiData } from '$app/components/_shared/EmojiPicker/EmojiPicker.hooks';
-
-import EmojiPickerHeader from '$app/components/_shared/EmojiPicker/EmojiPickerHeader';
-import EmojiPickerCategories from '$app/components/_shared/EmojiPicker/EmojiPickerCategories';
+import { useLoadEmojiData } from './EmojiPicker.hooks';
+import EmojiPickerHeader from './EmojiPickerHeader';
+import EmojiPickerCategories from './EmojiPickerCategories';
 
 interface Props {
   onEmojiSelect: (emoji: string) => void;
 }
 
-function EmojiPickerComponent({ onEmojiSelect }: Props) {
-  const [skin, setSkin] = useState(0);
-
-  const { emojiCategories, setSearchValue, searchValue } = useLoadEmojiData({
-    skin,
-  });
+function EmojiPicker(props: Props) {
+  const { skin, onSkinChange, emojiCategories, setSearchValue, searchValue, onSelect } = useLoadEmojiData(props);
 
   return (
     <div className={'emoji-picker flex h-[360px] max-h-[70vh] flex-col p-4 pt-2'}>
       <EmojiPickerHeader
-        onEmojiSelect={onEmojiSelect}
+        onEmojiSelect={onSelect}
         skin={skin}
-        onSkinSelect={setSkin}
+        onSkinSelect={onSkinChange}
         searchValue={searchValue}
         onSearchChange={setSearchValue}
       />
-      <EmojiPickerCategories onEmojiSelect={onEmojiSelect} emojiCategories={emojiCategories} />
+      <EmojiPickerCategories onEmojiSelect={onSelect} emojiCategories={emojiCategories} />
     </div>
   );
 }
 
-export default EmojiPickerComponent;
+export default EmojiPicker;

+ 4 - 3
frontend/appflowy_tauri/src/appflowy_app/components/auth/GetStarted/GetStarted.tsx

@@ -1,7 +1,8 @@
 import { t } from 'i18next';
 import { AppflowyLogo } from '../../_shared/svg/AppflowyLogo';
-import { Button } from '../../_shared/Button';
+
 import { useLogin } from '../Login/Login.hooks';
+import Button from '@mui/material/Button';
 
 export const GetStarted = () => {
   const { onAutoSignInClick } = useLogin();
@@ -20,8 +21,8 @@ export const GetStarted = () => {
             </span>
           </div>
 
-          <div id='Get-Started' className='flex w-full max-w-[340px] flex-col gap-6 ' aria-label='Get-Started'>
-            <Button size={'primary'} onClick={() => onAutoSignInClick()}>
+          <div id='Get-Started' className='flex w-full max-w-[340px] flex-col ' aria-label='Get-Started'>
+            <Button size={'large'} variant={'contained'} onClick={() => onAutoSignInClick()}>
               {t('signUp.getStartedText')}
             </Button>
           </div>

+ 1 - 1
frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSideToolbar/BlockSideToolbar.hooks.tsx

@@ -10,7 +10,7 @@ import { get } from '$app/utils/tool';
 
 const headingBlockTopOffset: Record<number, string> = {
   1: '0.4rem',
-  2: '0.2rem',
+  2: '0.35rem',
   3: '0.15rem',
 };
 

+ 72 - 0
frontend/appflowy_tauri/src/appflowy_app/components/document/DocumentBanner/DocumentBanner.hooks.ts

@@ -0,0 +1,72 @@
+import { useAppDispatch, useAppSelector } from '$app/stores/store';
+import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks';
+import { updatePageIcon } from '$app_reducers/pages/async_actions';
+import { useCallback, useMemo } from 'react';
+import { ViewIconTypePB } from '@/services/backend';
+import { CoverType } from '$app/interfaces/document';
+import { updateNodeDataThunk } from '$app_reducers/document/async-actions';
+import { useSubscribeNode } from '$app/components/document/_shared/SubscribeNode.hooks';
+export const heightCls = {
+  cover: 'h-[220px]',
+  icon: 'h-[80px]',
+  coverAndIcon: 'h-[250px]',
+  none: 'h-0',
+};
+
+export function useDocumentBanner(id: string) {
+  const dispatch = useAppDispatch();
+  const { docId, controller } = useSubscribeDocument();
+  const icon = useAppSelector((state) => state.pages.pageMap[docId]?.icon);
+  const { node } = useSubscribeNode(id);
+  const { cover, coverType } = node.data;
+
+  const onUpdateIcon = useCallback(
+    (icon: string) => {
+      dispatch(
+        updatePageIcon({
+          id: docId,
+          icon: icon
+            ? {
+                ty: ViewIconTypePB.Emoji,
+                value: icon,
+              }
+            : undefined,
+        })
+      );
+    },
+    [dispatch, docId]
+  );
+
+  const onUpdateCover = useCallback(
+    (coverType: CoverType | null, cover: string | null) => {
+      dispatch(
+        updateNodeDataThunk({
+          id,
+          data: {
+            coverType: coverType || '',
+            cover: cover || '',
+          },
+          controller,
+        })
+      );
+    },
+    [controller, dispatch, id]
+  );
+
+  const className = useMemo(() => {
+    if (cover && icon) return heightCls.coverAndIcon;
+    if (cover) return heightCls.cover;
+    if (icon) return heightCls.icon;
+    return heightCls.none;
+  }, [cover, icon]);
+
+  return {
+    onUpdateCover,
+    onUpdateIcon,
+    className,
+    icon,
+    cover,
+    coverType,
+    node,
+  };
+}

+ 5 - 2
frontend/appflowy_tauri/src/appflowy_app/components/document/DocumentTitle/DocumentIcon.tsx → frontend/appflowy_tauri/src/appflowy_app/components/document/DocumentBanner/DocumentIcon.tsx

@@ -1,13 +1,14 @@
 import React, { useCallback, useState } from 'react';
 import Popover from '@mui/material/Popover';
 import EmojiPicker from '$app/components/_shared/EmojiPicker';
+import { PageIcon } from '$app_reducers/pages/slice';
 
 function DocumentIcon({
   icon,
   className,
   onUpdateIcon,
 }: {
-  icon?: string;
+  icon?: PageIcon;
   className?: string;
   onUpdateIcon: (icon: string) => void;
 }) {
@@ -41,13 +42,15 @@ function DocumentIcon({
     <>
       <div className={`absolute bottom-0 left-0 pt-[20px] ${className}`}>
         <div onClick={onOpen} className={'h-full w-full cursor-pointer rounded text-6xl hover:text-7xl'}>
-          {icon}
+          {icon.value}
         </div>
       </div>
       <Popover
         open={open}
         anchorReference='anchorPosition'
         anchorPosition={anchorPosition}
+        disableAutoFocus
+        disableRestoreFocus
         onClose={() => setAnchorPosition(undefined)}
       >
         <EmojiPicker onEmojiSelect={onEmojiSelect} />

+ 10 - 5
frontend/appflowy_tauri/src/appflowy_app/components/document/DocumentTitle/TitleButtonGroup.tsx → frontend/appflowy_tauri/src/appflowy_app/components/document/DocumentBanner/TitleButtonGroup.tsx

@@ -2,18 +2,23 @@ import React, { useCallback } from 'react';
 import Button from '@mui/material/Button';
 import { useTranslation } from 'react-i18next';
 import { EmojiEmotionsOutlined, ImageOutlined } from '@mui/icons-material';
-import { BlockType, NestedBlock } from '$app/interfaces/document';
-import { randomColor } from '$app/components/document/DocumentTitle/cover/config';
+import { BlockType, CoverType, NestedBlock } from '$app/interfaces/document';
+import { randomColor } from '$app/components/document/DocumentBanner/cover/config';
 import { randomEmoji } from '$app/utils/document/emoji';
+import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks';
+import { useAppSelector } from '$app/stores/store';
 
 interface Props {
   node: NestedBlock<BlockType.PageBlock>;
-  onUpdateCover: (coverType: 'image' | 'color', cover: string) => void;
+  onUpdateCover: (coverType: CoverType, cover: string) => void;
   onUpdateIcon: (icon: string) => void;
 }
 function TitleButtonGroup({ onUpdateIcon, onUpdateCover, node }: Props) {
   const { t } = useTranslation();
-  const showAddIcon = !node.data.icon;
+  const { docId } = useSubscribeDocument();
+  const icon = useAppSelector((state) => state.pages.pageMap[docId]?.icon);
+
+  const showAddIcon = !icon;
   const showAddCover = !node.data.cover;
 
   const onAddIcon = useCallback(() => {
@@ -25,7 +30,7 @@ function TitleButtonGroup({ onUpdateIcon, onUpdateCover, node }: Props) {
   const onAddCover = useCallback(() => {
     const color = randomColor();
 
-    onUpdateCover('color', color);
+    onUpdateCover(CoverType.Color, color);
   }, [onUpdateCover]);
 
   return (

+ 0 - 0
frontend/appflowy_tauri/src/appflowy_app/components/document/DocumentTitle/cover/ChangeColors.tsx → frontend/appflowy_tauri/src/appflowy_app/components/document/DocumentBanner/cover/ChangeColors.tsx


+ 5 - 6
frontend/appflowy_tauri/src/appflowy_app/components/document/DocumentTitle/cover/ChangeCoverButton.tsx → frontend/appflowy_tauri/src/appflowy_app/components/document/DocumentBanner/cover/ChangeCoverButton.tsx

@@ -1,9 +1,8 @@
 import React, { useCallback, useState } from 'react';
 import { DeleteOutlineRounded } from '@mui/icons-material';
 import { useTranslation } from 'react-i18next';
-import { ButtonGroup } from '@mui/material';
-import Button from '@mui/material/Button';
-import ChangeCoverPopover from '$app/components/document/DocumentTitle/cover/ChangeCoverPopover';
+import ChangeCoverPopover from '$app/components/document/DocumentBanner/cover/ChangeCoverPopover';
+import { CoverType } from '$app/interfaces/document';
 
 function ChangeCoverButton({
   visible,
@@ -13,8 +12,8 @@ function ChangeCoverButton({
 }: {
   visible: boolean;
   cover: string;
-  coverType: 'image' | 'color';
-  onUpdateCover: (coverType: 'image' | 'color' | '', cover: string) => void;
+  coverType: CoverType;
+  onUpdateCover: (coverType: CoverType | null, cover: string | null) => void;
 }) {
   const { t } = useTranslation();
   const [anchorPosition, setAnchorPosition] = useState<undefined | { top: number; left: number }>(undefined);
@@ -32,7 +31,7 @@ function ChangeCoverButton({
   }, []);
 
   const onDeleteCover = useCallback(() => {
-    onUpdateCover('', '');
+    onUpdateCover(null, null);
   }, [onUpdateCover]);
 
   return (

+ 11 - 11
frontend/appflowy_tauri/src/appflowy_app/components/document/DocumentTitle/cover/ChangeCoverPopover.tsx → frontend/appflowy_tauri/src/appflowy_app/components/document/DocumentBanner/cover/ChangeCoverPopover.tsx

@@ -1,30 +1,30 @@
-import React, { useCallback, useEffect, useRef, useState } from 'react';
-import Popover, { PopoverActions } from '@mui/material/Popover';
-import ChangeColors from '$app/components/document/DocumentTitle/cover/ChangeColors';
-import ChangeImages from '$app/components/document/DocumentTitle/cover/ChangeImages';
-import { useAppDispatch } from '$app/stores/store';
-import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks';
+import React, { useRef } from 'react';
+import Popover from '@mui/material/Popover';
+import ChangeColors from '$app/components/document/DocumentBanner/cover/ChangeColors';
+import ChangeImages from '$app/components/document/DocumentBanner/cover/ChangeImages';
+import { CoverType } from '$app/interfaces/document';
 
 function ChangeCoverPopover({
   open,
   anchorPosition,
   onClose,
-  coverType,
   cover,
   onUpdateCover,
 }: {
   open: boolean;
   anchorPosition?: { top: number; left: number };
   onClose: () => void;
-  coverType: 'image' | 'color';
+  coverType: CoverType;
   cover: string;
-  onUpdateCover: (coverType: 'image' | 'color', cover: string) => void;
+  onUpdateCover: (coverType: CoverType, cover: string) => void;
 }) {
   const ref = useRef<HTMLDivElement>(null);
 
   return (
     <Popover
       open={open}
+      disableAutoFocus
+      disableRestoreFocus
       anchorReference={'anchorPosition'}
       anchorPosition={anchorPosition}
       onClose={onClose}
@@ -50,11 +50,11 @@ function ChangeCoverPopover({
       >
         <ChangeColors
           onChange={(color) => {
-            onUpdateCover('color', color);
+            onUpdateCover(CoverType.Color, color);
           }}
           cover={cover}
         />
-        <ChangeImages cover={cover} onChange={(url) => onUpdateCover('image', url)} />
+        <ChangeImages cover={cover} onChange={(url) => onUpdateCover(CoverType.Image, url)} />
       </div>
     </Popover>
   );

+ 2 - 2
frontend/appflowy_tauri/src/appflowy_app/components/document/DocumentTitle/cover/ChangeImages.tsx → frontend/appflowy_tauri/src/appflowy_app/components/document/DocumentBanner/cover/ChangeImages.tsx

@@ -1,11 +1,11 @@
 import React, { useCallback, useEffect, useState } from 'react';
 import { useTranslation } from 'react-i18next';
 
-import GalleryList from '$app/components/document/DocumentTitle/cover/GalleryList';
+import GalleryList from '$app/components/document/DocumentBanner/cover/GalleryList';
 import Button from '@mui/material/Button';
 import { readCoverImageUrls, readImage, writeCoverImageUrls } from '$app/utils/document/image';
 import { Log } from '$app/utils/log';
-import { Image } from '$app/components/document/DocumentTitle/cover/GalleryItem';
+import { Image } from '$app/components/document/DocumentBanner/cover/GalleryItem';
 
 function ChangeImages({ cover, onChange }: { onChange: (url: string) => void; cover: string }) {
   const { t } = useTranslation();

+ 10 - 6
frontend/appflowy_tauri/src/appflowy_app/components/document/DocumentTitle/cover/DocumentCover.tsx → frontend/appflowy_tauri/src/appflowy_app/components/document/DocumentBanner/cover/DocumentCover.tsx

@@ -1,6 +1,7 @@
 import React, { useCallback, useEffect, useState } from 'react';
-import ChangeCoverButton from '$app/components/document/DocumentTitle/cover/ChangeCoverButton';
+import ChangeCoverButton from '$app/components/document/DocumentBanner/cover/ChangeCoverButton';
 import { readImage } from '$app/utils/document/image';
+import { CoverType } from '$app/interfaces/document';
 
 function DocumentCover({
   cover,
@@ -9,9 +10,9 @@ function DocumentCover({
   onUpdateCover,
 }: {
   cover?: string;
-  coverType?: 'image' | 'color';
+  coverType?: CoverType;
   className?: string;
-  onUpdateCover: (coverType: 'image' | 'color' | '', cover: string) => void;
+  onUpdateCover: (coverType: CoverType | null, cover: string | null) => void;
 }) {
   const [hover, setHover] = useState(false);
   const [leftOffset, setLeftOffset] = useState(0);
@@ -55,7 +56,7 @@ function DocumentCover({
   }, [handleWidthChange]);
 
   useEffect(() => {
-    if (coverType === 'image' && cover) {
+    if (coverType === CoverType.Image && cover) {
       void (async () => {
         const src = await readImage(cover);
 
@@ -75,8 +76,11 @@ function DocumentCover({
       }}
       className={`absolute top-0 w-full overflow-hidden ${className}`}
     >
-      {coverType === 'image' && <img src={coverSrc} className={'h-full w-full object-cover'} />}
-      {coverType === 'color' && <div className={'h-full w-full'} style={{ backgroundColor: cover }} />}
+      {coverType === CoverType.Image ? (
+        <img src={coverSrc} className={'h-full w-full object-cover'} />
+      ) : (
+        <div className={'h-full w-full'} style={{ backgroundColor: cover }} />
+      )}
       <ChangeCoverButton onUpdateCover={onUpdateCover} visible={hover} cover={cover} coverType={coverType} />
     </div>
   );

+ 0 - 0
frontend/appflowy_tauri/src/appflowy_app/components/document/DocumentTitle/cover/GalleryItem.tsx → frontend/appflowy_tauri/src/appflowy_app/components/document/DocumentBanner/cover/GalleryItem.tsx


+ 1 - 1
frontend/appflowy_tauri/src/appflowy_app/components/document/DocumentTitle/cover/GalleryList.tsx → frontend/appflowy_tauri/src/appflowy_app/components/document/DocumentBanner/cover/GalleryList.tsx

@@ -7,7 +7,7 @@ import { useTranslation } from 'react-i18next';
 import Dialog from '@mui/material/Dialog';
 import DialogTitle from '@mui/material/DialogTitle';
 import ImageEdit from '$app/components/document/_shared/UploadImage/ImageEdit';
-import GalleryItem, { Image } from '$app/components/document/DocumentTitle/cover/GalleryItem';
+import GalleryItem, { Image } from '$app/components/document/DocumentBanner/cover/GalleryItem';
 
 interface Props {
   onSelected: (image: Image) => void;

+ 0 - 0
frontend/appflowy_tauri/src/appflowy_app/components/document/DocumentTitle/cover/config.ts → frontend/appflowy_tauri/src/appflowy_app/components/document/DocumentBanner/cover/config.ts


+ 30 - 0
frontend/appflowy_tauri/src/appflowy_app/components/document/DocumentBanner/index.tsx

@@ -0,0 +1,30 @@
+import { heightCls, useDocumentBanner } from './DocumentBanner.hooks';
+import TitleButtonGroup from './TitleButtonGroup';
+import DocumentCover from './cover/DocumentCover';
+import DocumentIcon from './DocumentIcon';
+
+function DocumentBanner({ id, hover }: { id: string; hover: boolean }) {
+  const { onUpdateCover, node, onUpdateIcon, icon, cover, className, coverType } = useDocumentBanner(id);
+  return (
+    <>
+      <div
+        style={{
+          display: icon || cover ? 'block' : 'none',
+        }}
+        className={`relative ${className}`}
+      >
+        <DocumentCover onUpdateCover={onUpdateCover} className={heightCls.cover} cover={cover} coverType={coverType} />
+        <DocumentIcon onUpdateIcon={onUpdateIcon} className={heightCls.icon} icon={icon} />
+      </div>
+      <div
+        style={{
+          opacity: hover ? 1 : 0,
+        }}
+      >
+        <TitleButtonGroup node={node} onUpdateCover={onUpdateCover} onUpdateIcon={onUpdateIcon} />
+      </div>
+    </>
+  );
+}
+
+export default DocumentBanner;

+ 13 - 32
frontend/appflowy_tauri/src/appflowy_app/components/document/DocumentTitle/DocumentTitle.hooks.ts

@@ -1,47 +1,28 @@
 import { useSubscribeNode } from '../_shared/SubscribeNode.hooks';
-import { useCallback } from 'react';
-import { updateNodeDataThunk } from '$app_reducers/document/async-actions';
+import { useEffect } from 'react';
 import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks';
-import { useAppDispatch } from '$app/stores/store';
+import { useAppDispatch, useAppSelector } from '$app/stores/store';
+import { documentActions } from '$app_reducers/document/slice';
 
 export function useDocumentTitle(id: string) {
   const { node } = useSubscribeNode(id);
-  const { controller } = useSubscribeDocument();
   const dispatch = useAppDispatch();
-  const onUpdateIcon = useCallback(
-    (icon: string) => {
-      dispatch(
-        updateNodeDataThunk({
-          id,
-          data: {
-            icon,
-          },
-          controller,
-        })
-      );
-    },
-    [controller, dispatch, id]
-  );
+  const { docId } = useSubscribeDocument();
+  const page = useAppSelector((state) => state.pages.pageMap[docId]);
 
-  const onUpdateCover = useCallback(
-    (coverType: 'image' | 'color' | '', cover: string) => {
+  useEffect(() => {
+    if (page) {
       dispatch(
-        updateNodeDataThunk({
-          id,
-          data: {
-            cover,
-            coverType,
-          },
-          controller,
+        documentActions.updateRootNodeDelta({
+          docId,
+          delta: [{ insert: page.name }],
+          rootId: id,
         })
       );
-    },
-    [controller, dispatch, id]
-  );
+    }
+  }, [dispatch, docId, id, page]);
 
   return {
     node,
-    onUpdateCover,
-    onUpdateIcon,
   };
 }

+ 0 - 44
frontend/appflowy_tauri/src/appflowy_app/components/document/DocumentTitle/DocumentTopPanel.tsx

@@ -1,44 +0,0 @@
-import React, { useEffect, useMemo } from 'react';
-import { BlockType, NestedBlock } from '$app/interfaces/document';
-import DocumentCover from '$app/components/document/DocumentTitle/cover/DocumentCover';
-import DocumentIcon from '$app/components/document/DocumentTitle/DocumentIcon';
-
-const heightCls = {
-  cover: 'h-[220px]',
-  icon: 'h-[80px]',
-  coverAndIcon: 'h-[250px]',
-  none: 'h-0',
-};
-
-function DocumentTopPanel({
-  node,
-  onUpdateCover,
-  onUpdateIcon,
-}: {
-  node: NestedBlock<BlockType.PageBlock>;
-  onUpdateCover: (coverType: 'image' | 'color' | '', cover: string) => void;
-  onUpdateIcon: (icon: string) => void;
-}) {
-  const { cover, coverType, icon } = node.data;
-
-  const className = useMemo(() => {
-    if (cover && icon) return heightCls.coverAndIcon;
-    if (cover) return heightCls.cover;
-    if (icon) return heightCls.icon;
-    return heightCls.none;
-  }, [cover, icon]);
-
-  return (
-    <div
-      style={{
-        display: icon || cover ? 'block' : 'none',
-      }}
-      className={`relative ${className}`}
-    >
-      <DocumentCover onUpdateCover={onUpdateCover} className={heightCls.cover} cover={cover} coverType={coverType} />
-      <DocumentIcon onUpdateIcon={onUpdateIcon} className={heightCls.icon} icon={icon} />
-    </div>
-  );
-}
-
-export default DocumentTopPanel;

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

@@ -2,11 +2,10 @@ import React, { useState } from 'react';
 import { useDocumentTitle } from './DocumentTitle.hooks';
 import TextBlock from '../TextBlock';
 import { useTranslation } from 'react-i18next';
-import TitleButtonGroup from './TitleButtonGroup';
-import DocumentTopPanel from './DocumentTopPanel';
+import DocumentBanner from '$app/components/document/DocumentBanner';
 
 export default function DocumentTitle({ id }: { id: string }) {
-  const { node, onUpdateCover, onUpdateIcon } = useDocumentTitle(id);
+  const { node } = useDocumentTitle(id);
   const { t } = useTranslation();
   const [hover, setHover] = useState(false);
 
@@ -14,14 +13,7 @@ export default function DocumentTitle({ id }: { id: string }) {
 
   return (
     <div className={'flex flex-col'} onMouseEnter={() => setHover(true)} onMouseLeave={() => setHover(false)}>
-      <DocumentTopPanel onUpdateCover={onUpdateCover} onUpdateIcon={onUpdateIcon} node={node} />
-      <div
-        style={{
-          opacity: hover ? 1 : 0,
-        }}
-      >
-        <TitleButtonGroup node={node} onUpdateCover={onUpdateCover} onUpdateIcon={onUpdateIcon} />
-      </div>
+      <DocumentBanner id={node.id} hover={hover} />
       <div data-block-id={node.id} className='doc-title relative text-4xl font-bold'>
         <TextBlock placeholder={t('document.title.placeholder')} childIds={[]} node={node} />
       </div>

+ 0 - 3
frontend/appflowy_tauri/src/appflowy_app/components/document/Overlay/index.tsx

@@ -1,11 +1,9 @@
 import React from 'react';
-import BlockSideToolbar from '../BlockSideToolbar';
 import BlockSelection from '../BlockSelection';
 import TextActionMenu from '$app/components/document/TextActionMenu';
 import BlockSlash from '$app/components/document/BlockSlash';
 import { useCopy } from '$app/components/document/_shared/CopyPasteHooks/useCopy';
 import { usePaste } from '$app/components/document/_shared/CopyPasteHooks/usePaste';
-import LinkEditPopover from '$app/components/document/_shared/TextLink/LinkEditPopover';
 import { useUndoRedo } from '$app/components/document/_shared/UndoHooks/useUndoRedo';
 import TemporaryPopover from '$app/components/document/_shared/TemporaryInput/TemporaryPopover';
 
@@ -18,7 +16,6 @@ export default function Overlay({ container }: { container: HTMLDivElement }) {
       <TextActionMenu container={container} />
       <BlockSelection container={container} />
       <BlockSlash container={container} />
-      <LinkEditPopover />
       <TemporaryPopover />
     </>
   );

+ 2 - 2
frontend/appflowy_tauri/src/appflowy_app/components/document/QuoteBlock/index.tsx

@@ -10,8 +10,8 @@ export default function QuoteBlock({
   childIds?: string[];
 }) {
   return (
-    <div className={'py-[2px]'}>
-      <div className={'border-l-4 border-solid border-fill-default px-3 '}>
+    <div className={'py-[2px] pl-0.5'}>
+      <div className={'border-l-4 border-solid border-fill-default pl-3'}>
         <TextBlock node={node} />
         <NodeChildren childIds={childIds} />
       </div>

+ 2 - 11
frontend/appflowy_tauri/src/appflowy_app/components/document/TextActionMenu/menu/FormatButton.tsx

@@ -4,7 +4,6 @@ import ToolbarTooltip from '$app/components/document/_shared/ToolbarTooltip';
 import { getFormatActiveThunk, toggleFormatThunk } from '$app_reducers/document/async-actions/format';
 import { useAppDispatch, useAppSelector } from '$app/stores/store';
 import { useSubscribeNode } from '$app/components/document/_shared/SubscribeNode.hooks';
-import { newLinkThunk } from '$app_reducers/document/async-actions/link';
 import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks';
 import { RANGE_NAME } from '$app/constants/document/name';
 import { createTemporary } from '$app_reducers/document/async-actions/temporary';
@@ -57,14 +56,6 @@ const FormatButton = ({ format, icon }: { format: TextAction; icon: string }) =>
     [controller, dispatch, isActive]
   );
 
-  const addLink = useCallback(() => {
-    dispatch(
-      newLinkThunk({
-        docId,
-      })
-    );
-  }, [dispatch, docId]);
-
   const addTemporaryInput = useCallback(
     (type: TemporaryType) => {
       dispatch(createTemporary({ type, docId }));
@@ -103,12 +94,12 @@ const FormatButton = ({ format, icon }: { format: TextAction; icon: string }) =>
         case TextAction.Code:
           return toggleFormat(format);
         case TextAction.Link:
-          return addLink();
+          return addTemporaryInput(TemporaryType.Link);
         case TextAction.Equation:
           return addTemporaryInput(TemporaryType.Equation);
       }
     },
-    [addLink, addTemporaryInput, toggleFormat]
+    [addTemporaryInput, toggleFormat]
   );
 
   const formatIcon = useMemo(() => {

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

@@ -1,4 +1,3 @@
-import React from 'react';
 import { useVirtualizedList } from './VirtualizedList.hooks';
 import DocumentTitle from '../DocumentTitle';
 import Overlay from '../Overlay';
@@ -15,9 +14,8 @@ export default function VirtualizedList({
   node: Node;
   renderNode: (nodeId: string) => JSX.Element;
 }) {
-  const { virtualize, parentRef } = useVirtualizedList(childIds.length);
+  const { virtualize, parentRef } = useVirtualizedList(childIds.length + 1);
   const virtualItems = virtualize.getVirtualItems();
-
   const { docId } = useSubscribeDocument();
 
   return (
@@ -46,12 +44,14 @@ export default function VirtualizedList({
               }}
             >
               {virtualItems.map((virtualRow) => {
-                const id = childIds[virtualRow.index];
+                const isDocumentTitle = virtualRow.index === 0;
+                const id = isDocumentTitle ? node.id : childIds[virtualRow.index - 1];
 
                 return (
-                  <div className={'pt-[0.5px]'} key={id} data-index={virtualRow.index} ref={virtualize.measureElement}>
-                    {virtualRow.index === 0 ? <DocumentTitle id={node.id} /> : null}
-                    {renderNode(id)}
+                  <div className={isDocumentTitle ? '' : 'pt-[0.5px]'} key={id} data-index={virtualRow.index} ref={virtualize.measureElement}>
+                    {
+                      isDocumentTitle ? <DocumentTitle id={node.id} /> : renderNode(id)
+                    }
                   </div>
                 );
               })}

+ 1 - 2
frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/InlineBlock/InlineContainer.tsx

@@ -22,7 +22,6 @@ function InlineContainer({
 }: {
   getSelection: (node: Element) => RangeStaticNoId | null;
   children: React.ReactNode;
-  formula: string;
   selectedText: string;
   isLast: boolean;
   isFirst: boolean;
@@ -52,7 +51,7 @@ function InlineContainer({
             selection,
             selectedText,
             type: temporaryType,
-            data: temporaryData as { latex: string },
+            data: temporaryData
           },
         })
       );

+ 67 - 0
frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/InlineBlock/LinkInline.tsx

@@ -0,0 +1,67 @@
+import React, { useCallback, useContext, useRef } from 'react';
+import { RangeStaticNoId, TemporaryType } from '$app/interfaces/document';
+import { NodeIdContext } from '$app/components/document/_shared/SubscribeNode.hooks';
+import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks';
+import { useAppDispatch } from '$app/stores/store';
+import { createTemporary } from '$app_reducers/document/async-actions/temporary';
+
+function LinkInline({
+  children,
+  getSelection,
+  selectedText,
+  temporaryType,
+  data,
+}: {
+  getSelection: (node: Element) => RangeStaticNoId | null;
+  children: React.ReactNode;
+  selectedText: string;
+  temporaryType: TemporaryType;
+  data: {
+    href?: string;
+  };
+}) {
+  const id = useContext(NodeIdContext);
+  const { docId } = useSubscribeDocument();
+  const ref = useRef<HTMLAnchorElement>(null);
+  const dispatch = useAppDispatch();
+
+  const onClick = useCallback(
+    (e: React.MouseEvent) => {
+      if (!ref.current) return;
+      const selection = getSelection(ref.current);
+
+      if (!selection) return;
+      const rect = ref.current?.getBoundingClientRect();
+      if (!rect) return;
+      e.stopPropagation();
+      e.preventDefault();
+
+      dispatch(
+        createTemporary({
+          docId,
+          state: {
+            id,
+            selection,
+            selectedText,
+            type: temporaryType,
+            data: {
+              href: data.href,
+              text: selectedText,
+            },
+          },
+        })
+      );
+    },
+    [data, dispatch, docId, getSelection, id, selectedText, temporaryType]
+  );
+
+  return (
+    <>
+      <span onClick={onClick} ref={ref} className='cursor-pointer text-text-link-default'>
+        <span className={' border-b-[1px] border-b-text-link-default'}>{children}</span>
+      </span>
+    </>
+  );
+}
+
+export default LinkInline;

+ 10 - 16
frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SlateEditor/TextLeaf.tsx

@@ -1,12 +1,11 @@
 import { ReactEditor, RenderLeafProps } from 'slate-react';
 import { BaseText } from 'slate';
 import { useCallback, useRef } from 'react';
-import TextLink from '../TextLink';
 import { converToIndexLength } from '$app/utils/document/slate_editor';
-import LinkHighLight from '$app/components/document/_shared/TextLink/LinkHighLight';
 import TemporaryInput from '$app/components/document/_shared/TemporaryInput';
 import InlineContainer from '$app/components/document/_shared/InlineBlock/InlineContainer';
 import { TemporaryType } from '$app/interfaces/document';
+import LinkInline from '$app/components/document/_shared/InlineBlock/LinkInline';
 
 interface Attributes {
   bold?: boolean;
@@ -17,8 +16,6 @@ interface Attributes {
   selection_high_lighted?: boolean;
   href?: string;
   prism_token?: string;
-  link_selection_lighted?: boolean;
-  link_placeholder?: string;
   temporary?: boolean;
   formula?: string;
   font_color?: string;
@@ -69,9 +66,16 @@ const TextLeaf = (props: TextLeafProps) => {
 
   if (leaf.href) {
     newChildren = (
-      <TextLink getSelection={getSelection} title={leaf.text} href={leaf.href}>
+      <LinkInline
+        temporaryType={TemporaryType.Link}
+        getSelection={getSelection}
+        selectedText={leaf.text}
+        data={{
+          href: leaf.href,
+        }}
+      >
         {newChildren}
-      </TextLink>
+      </LinkInline>
     );
   }
 
@@ -85,7 +89,6 @@ const TextLeaf = (props: TextLeafProps) => {
         isLast={isLast}
         isFirst={text === parent.children[0]}
         getSelection={getSelection}
-        formula={leaf.formula}
         data={data}
         temporaryType={temporaryType}
         selectedText={leaf.text}
@@ -100,21 +103,12 @@ const TextLeaf = (props: TextLeafProps) => {
     leaf.prism_token && leaf.prism_token,
     leaf.strikethrough && 'line-through',
     leaf.selection_high_lighted && 'bg-content-blue-100',
-    leaf.link_selection_lighted && 'text-text-link-selector bg-content-blue-100',
     leaf.code && !leaf.temporary && 'inline-code',
     leaf.bold && 'font-bold',
     leaf.italic && 'italic',
     leaf.underline && 'underline',
   ].filter(Boolean);
 
-  if (leaf.link_placeholder && leaf.text) {
-    newChildren = (
-      <LinkHighLight leaf={leaf} title={leaf.link_placeholder}>
-        {newChildren}
-      </LinkHighLight>
-    );
-  }
-
   if (leaf.temporary) {
     newChildren = (
       <TemporaryInput getSelection={getSelection} leaf={leaf}>

+ 1 - 6
frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SlateEditor/useEditor.ts

@@ -25,7 +25,6 @@ export function useEditor({
   decorateSelection,
   onKeyDown,
   isCodeBlock,
-  linkDecorateSelection,
   temporarySelection,
 }: EditorProps) {
   const { editor } = useSlateYjs({ delta });
@@ -97,10 +96,6 @@ export function useEditor({
         getDecorateRange(path, decorateSelection, {
           selection_high_lighted: true,
         }),
-        getDecorateRange(path, linkDecorateSelection?.selection, {
-          link_selection_lighted: true,
-          link_placeholder: linkDecorateSelection?.placeholder,
-        }),
         getDecorateRange(path, temporarySelection, {
           temporary: true,
         }),
@@ -108,7 +103,7 @@ export function useEditor({
 
       return ranges;
     },
-    [temporarySelection, decorateSelection, linkDecorateSelection, getDecorateRange]
+    [temporarySelection, decorateSelection, getDecorateRange]
   );
 
   const onKeyDownRewrite = useCallback(

+ 0 - 13
frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SubscribeLinkPopover.hooks.ts

@@ -1,13 +0,0 @@
-import { useAppSelector } from '$app/stores/store';
-import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks';
-import { TEXT_LINK_NAME } from '$app/constants/document/name';
-
-export function useSubscribeLinkPopover() {
-  const { docId } = useSubscribeDocument();
-
-  const linkPopover = useAppSelector((state) => {
-    return state[TEXT_LINK_NAME][docId];
-  });
-
-  return linkPopover;
-}

+ 0 - 11
frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SubscribeSelection.hooks.ts

@@ -18,19 +18,8 @@ export function useSubscribeDecorate(id: string) {
     return temporary.selection;
   });
 
-  const linkDecorateSelection = useAppSelector((state) => {
-    const linkPopoverState = state[TEXT_LINK_NAME][docId];
-
-    if (!linkPopoverState?.open || linkPopoverState?.id !== id) return;
-    return {
-      selection: linkPopoverState.selection,
-      placeholder: linkPopoverState.title,
-    };
-  });
-
   return {
     decorateSelection,
-    linkDecorateSelection,
     temporarySelection,
   };
 }

+ 125 - 0
frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/TemporaryInput/LinkEditContent.tsx

@@ -0,0 +1,125 @@
+import React, { useCallback, useMemo, useRef } from 'react';
+import TextField from '@mui/material/TextField';
+import { IconButton } from '@mui/material';
+import { LinkOff, OpenInNew } from '@mui/icons-material';
+import { useTranslation } from 'react-i18next';
+import Button from '@mui/material/Button';
+import Tooltip from '@mui/material/Tooltip';
+import CopyIcon from '@mui/icons-material/CopyAll';
+import { copyText } from '$app/utils/document/copy_paste';
+import { open } from '@tauri-apps/api/shell';
+
+function LinkEditContent({
+  value,
+  onChange,
+  onConfirm,
+}: {
+  value: {
+    href?: string;
+    text?: string;
+  };
+  onChange: (val: { href: string; text: string }) => void;
+  onConfirm: () => void;
+}) {
+  const valueRef = useRef<{
+    href?: string;
+    text?: string;
+  }>(value);
+  const { t } = useTranslation();
+  const onKeyDown = useCallback(
+    (e: React.KeyboardEvent) => {
+      if (e.key === 'Enter' && !e.shiftKey) {
+        e.preventDefault();
+        onConfirm();
+      }
+    },
+    [onConfirm]
+  );
+
+  const operations = useMemo(
+    () => [
+      {
+        icon: <OpenInNew />,
+        tooltip: t('document.inlineLink.openInNewTab'),
+        onClick: () => {
+          void open(valueRef.current.href || '');
+        },
+      },
+      {
+        icon: <CopyIcon />,
+        tooltip: t('document.inlineLink.copyLink'),
+        onClick: () => {
+          void copyText(valueRef.current.href || '');
+        },
+      },
+      {
+        icon: <LinkOff />,
+        tooltip: t('document.inlineLink.removeLink'),
+        onClick: () => {
+          onChange({
+            href: '',
+            text: valueRef.current.text || '',
+          });
+          onConfirm();
+        },
+      },
+    ],
+    [onChange, t, onConfirm]
+  );
+
+  return (
+    <div className={'flex w-[420px] flex-col items-end p-4'}>
+      <div className={'flex w-full items-center justify-end'}>
+        {operations.map((operation, index) => (
+          <Tooltip placement={'top'} key={index} title={operation.tooltip}>
+            <div className={'ml-2 cursor-pointer rounded border border-line-divider'}>
+              <IconButton onClick={operation.onClick}>{operation.icon}</IconButton>
+            </div>
+          </Tooltip>
+        ))}
+      </div>
+      <div className={'flex h-[150px] w-full flex-col justify-between'}>
+        <TextField
+          autoFocus
+          placeholder={t('document.inlineLink.url.placeholder')}
+          label={t('document.inlineLink.url.label')}
+          onKeyDown={onKeyDown}
+          variant='standard'
+          value={value.href}
+          onChange={(e) => {
+            const newVal = e.target.value;
+
+            if (newVal === value.href) return;
+            onChange({
+              text: value.text || '',
+              href: newVal,
+            });
+          }}
+        />
+        <TextField
+          placeholder={t('document.inlineLink.title.placeholder')}
+          label={t('document.inlineLink.title.label')}
+          onKeyDown={onKeyDown}
+          variant='standard'
+          value={value.text}
+          onChange={(e) => {
+            const newVal = e.target.value;
+
+            if (newVal === value.text) return;
+            onChange({
+              text: newVal,
+              href: value.href || '',
+            });
+          }}
+        />
+        <div className={'flex w-full items-center justify-end'}>
+          <Button onClick={onConfirm} color='primary'>
+            {t('button.save')}
+          </Button>
+        </div>
+      </div>
+    </div>
+  );
+}
+
+export default LinkEditContent;

+ 0 - 1
frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/TemporaryInput/TemporaryEquation.tsx

@@ -1,4 +1,3 @@
-import React, { useRef } from 'react';
 import { Functions } from '@mui/icons-material';
 import KatexMath from '$app/components/document/_shared/KatexMath';
 

+ 20 - 0
frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/TemporaryInput/TemporaryLink.tsx

@@ -0,0 +1,20 @@
+import React from 'react';
+import { AddLinkOutlined } from '@mui/icons-material';
+import { useTranslation } from 'react-i18next';
+
+function TemporaryLink({ href = '', text = '' }: { href?: string; text?: string }) {
+  const { t } = useTranslation();
+  return (
+    <span className={'bg-content-blue-100'} contentEditable={false}>
+      {text ? (
+        <span className={'text-text-link-default underline'}>{text}</span>
+      ) : (
+        <span className={'text-text-caption'}>
+          <AddLinkOutlined /> {t('document.inlineLink.title.label')}
+        </span>
+      )}
+    </span>
+  );
+}
+
+export default TemporaryLink;

+ 13 - 1
frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/TemporaryInput/TemporaryPopover.tsx

@@ -8,6 +8,7 @@ import { formatTemporary } from '$app_reducers/document/async-actions/temporary'
 import { useAppDispatch } from '$app/stores/store';
 import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks';
 import { useSubscribeTemporary } from '$app/components/document/_shared/SubscribeTemporary.hooks';
+import LinkEditContent from '$app/components/document/_shared/TemporaryInput/LinkEditContent';
 
 const AFTER_RENDER_DELAY = 100;
 
@@ -97,7 +98,7 @@ function TemporaryPopover() {
       case TemporaryType.Equation:
         return (
           <EquationEditContent
-            value={data.latex}
+            value={data.latex || ''}
             onChange={(latex: string) =>
               onChangeData({
                 latex,
@@ -106,6 +107,17 @@ function TemporaryPopover() {
             onConfirm={onConfirm}
           />
         );
+      case TemporaryType.Link:
+        return (
+          <LinkEditContent
+            value={{
+              href: data.href || '',
+              text: data.text || '',
+            }}
+            onChange={(val: { href: string; text: string }) => onChangeData(val)}
+            onConfirm={onConfirm}
+          />
+        );
     }
   }, [onChangeData, onConfirm, temporaryState]);
 

+ 14 - 3
frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/TemporaryInput/index.tsx

@@ -1,4 +1,4 @@
-import React, { useCallback, useEffect, useMemo, useRef } from 'react';
+import React, { useCallback, useEffect, useRef, useState } from 'react';
 import { RangeStaticNoId, TemporaryType } from '$app/interfaces/document';
 import TemporaryEquation from '$app/components/document/_shared/TemporaryInput/TemporaryEquation';
 import { useSubscribeTemporary } from '$app/components/document/_shared/SubscribeTemporary.hooks';
@@ -6,6 +6,7 @@ import { PopoverPosition } from '@mui/material';
 import { useAppDispatch } from '$app/stores/store';
 import { temporaryActions } from '$app_reducers/document/temporary_slice';
 import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks';
+import TemporaryLink from '$app/components/document/_shared/TemporaryInput/TemporaryLink';
 
 function TemporaryInput({
   leaf,
@@ -21,7 +22,9 @@ function TemporaryInput({
   const dispatch = useAppDispatch();
   const ref = useRef<HTMLSpanElement>(null);
   const { docId } = useSubscribeDocument();
-  const match = useMemo(() => {
+  const [match, setMatch] = useState(false);
+
+  const getMatch = useCallback(() => {
     if (!ref.current) return false;
     if (!leaf.text) return false;
     if (!temporaryState) return false;
@@ -29,6 +32,7 @@ function TemporaryInput({
     const selection = getSelection(ref.current);
 
     if (!selection) return false;
+
     return leaf.text === selectedText || selection.index <= temporaryState.selection.index;
   }, [leaf.text, temporaryState, getSelection]);
 
@@ -38,7 +42,9 @@ function TemporaryInput({
 
     switch (type) {
       case TemporaryType.Equation:
-        return <TemporaryEquation latex={data.latex} />;
+        return <TemporaryEquation latex={data.latex || ''} />;
+      case TemporaryType.Link:
+        return <TemporaryLink {...data} />;
       default:
         return null;
     }
@@ -69,6 +75,11 @@ function TemporaryInput({
     });
   }, [dispatch, docId, id, match, setAnchorPosition]);
 
+  useEffect(() => {
+    const match = getMatch();
+    setMatch(match);
+  }, [getMatch]);
+
   return (
     <span ref={ref}>
       {match ? renderPlaceholder() : null}

+ 0 - 39
frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/TextLink/EditLink.tsx

@@ -1,39 +0,0 @@
-import React, { useEffect, useState } from 'react';
-import TextField from '@mui/material/TextField';
-
-function EditLink({
-  autoFocus,
-  text,
-  value,
-  onChange,
-}: {
-  autoFocus?: boolean;
-  text: string;
-  value: string;
-  onChange?: (newValue: string) => void;
-}) {
-  const [val, setVal] = useState(value);
-
-  useEffect(() => {
-    onChange?.(val);
-  }, [val, onChange]);
-
-  return (
-    <div className={'mb-2 w-[100%] text-sm'}>
-      <TextField
-        className={'w-[100%]'}
-        label={text}
-        autoFocus={autoFocus}
-        variant='standard'
-        onChange={(e) => {
-          const newValue = e.target.value;
-
-          setVal(newValue);
-        }}
-        value={val}
-      />
-    </div>
-  );
-}
-
-export default EditLink;

+ 0 - 96
frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/TextLink/EditLinkToolbar.tsx

@@ -1,96 +0,0 @@
-import React, { useEffect, useRef } from 'react';
-import BlockPortal from '$app/components/document/BlockPortal';
-import { getNode } from '$app/utils/document/node';
-import LanguageIcon from '@mui/icons-material/Language';
-import CopyIcon from '@mui/icons-material/CopyAll';
-import { copyText } from '$app/utils/document/copy_paste';
-import { useMessage } from '$app/components/document/_shared/Message';
-import { useTranslation } from 'react-i18next';
-
-const iconSize = {
-  width: '1rem',
-  height: '1rem',
-};
-
-function EditLinkToolbar({
-  blockId,
-  linkElement,
-  onMouseEnter,
-  onMouseLeave,
-  href,
-  editing,
-  onEdit,
-}: {
-  blockId: string;
-  linkElement: HTMLAnchorElement;
-  href: string;
-  onMouseEnter: () => void;
-  onMouseLeave: () => void;
-  editing: boolean;
-  onEdit: () => void;
-}) {
-  const { t } = useTranslation();
-  const { show, contentHolder } = useMessage();
-  const ref = useRef<HTMLDivElement>(null);
-
-  useEffect(() => {
-    const toolbarDom = ref.current;
-
-    if (!toolbarDom) return;
-
-    const linkRect = linkElement.getBoundingClientRect();
-    const node = getNode(blockId);
-
-    if (!node) return;
-    const nodeRect = node.getBoundingClientRect();
-    const top = linkRect.top - nodeRect.top + linkRect.height + 4;
-    const left = linkRect.left - nodeRect.left;
-
-    toolbarDom.style.top = `${top}px`;
-    toolbarDom.style.left = `${left}px`;
-    toolbarDom.style.opacity = '1';
-  });
-  return (
-    <>
-      {editing && (
-        <BlockPortal blockId={blockId}>
-          <div
-            ref={ref}
-            onMouseEnter={onMouseEnter}
-            onMouseLeave={onMouseLeave}
-            style={{
-              opacity: 0,
-            }}
-            className='absolute z-10 inline-flex h-[32px] min-w-[200px] max-w-[400px] items-stretch overflow-hidden rounded-[8px] bg-bg-body leading-tight text-text-title shadow-md transition-opacity duration-100'
-          >
-            <div className={'flex w-[100%] items-center justify-between px-2 text-[75%]'}>
-              <div className={'mr-2'}>
-                <LanguageIcon sx={iconSize} />
-              </div>
-              <div className={'mr-2 flex-1 overflow-hidden text-ellipsis whitespace-nowrap'}>{href}</div>
-              <div
-                onClick={async () => {
-                  try {
-                    await copyText(href);
-                    show({ message: t('message.copy.success'), duration: 6000 });
-                  } catch {
-                    show({ message: t('message.copy.fail'), duration: 6000 });
-                  }
-                }}
-                className={'mr-2 cursor-pointer'}
-              >
-                <CopyIcon sx={iconSize} />
-              </div>
-              <div onClick={onEdit} className={'cursor-pointer'}>
-                {t('button.edit')}
-              </div>
-            </div>
-          </div>
-        </BlockPortal>
-      )}
-      {contentHolder}
-    </>
-  );
-}
-
-export default EditLinkToolbar;

+ 0 - 135
frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/TextLink/LinkEditPopover.tsx

@@ -1,135 +0,0 @@
-import React, { useCallback } from 'react';
-import Popover from '@mui/material/Popover';
-import { DeleteOutline, Done } from '@mui/icons-material';
-import EditLink from '$app/components/document/_shared/TextLink/EditLink';
-import { useAppDispatch } from '$app/stores/store';
-import { linkPopoverActions, rangeActions } from '$app_reducers/document/slice';
-import { formatLinkThunk } from '$app_reducers/document/async-actions/link';
-import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks';
-import { useSubscribeLinkPopover } from '$app/components/document/_shared/SubscribeLinkPopover.hooks';
-import Button from '@mui/material/Button';
-import { useTranslation } from 'react-i18next';
-
-function LinkEditPopover() {
-  const dispatch = useAppDispatch();
-  const { docId, controller } = useSubscribeDocument();
-  const { t } = useTranslation();
-  const popoverState = useSubscribeLinkPopover();
-  const { anchorPosition, id, selection, title = '', href = '', open = false } = popoverState;
-
-  const onClose = useCallback(() => {
-    dispatch(linkPopoverActions.closeLinkPopover(docId));
-  }, [dispatch, docId]);
-
-  const onExited = useCallback(() => {
-    if (!id || !selection) return;
-    const newSelection = {
-      index: selection.index,
-      length: title.length,
-    };
-
-    dispatch(
-      rangeActions.setRange({
-        docId,
-        id,
-        rangeStatic: newSelection,
-      })
-    );
-    dispatch(
-      rangeActions.setCaret({
-        docId,
-        caret: {
-          id,
-          ...newSelection,
-        },
-      })
-    );
-  }, [docId, id, selection, title, dispatch]);
-
-  const onChange = useCallback(
-    (newVal: { href?: string; title: string }) => {
-      if (!id) return;
-      if (newVal.title === title && newVal.href === href) return;
-
-      dispatch(
-        linkPopoverActions.updateLinkPopover({
-          docId,
-          linkState: {
-            id,
-            href: newVal.href,
-            title: newVal.title,
-          },
-        })
-      );
-    },
-    [docId, dispatch, href, id, title]
-  );
-
-  const onDone = useCallback(async () => {
-    if (!controller) return;
-    await dispatch(
-      formatLinkThunk({
-        controller,
-      })
-    );
-    onClose();
-  }, [controller, dispatch, onClose]);
-
-  return (
-    <Popover
-      onMouseDown={(e) => e.stopPropagation()}
-      open={open}
-      disableAutoFocus={true}
-      anchorReference='anchorPosition'
-      anchorPosition={anchorPosition}
-      TransitionProps={{
-        onExited,
-      }}
-      onClose={onClose}
-      anchorOrigin={{
-        vertical: 'bottom',
-        horizontal: 'center',
-      }}
-      transformOrigin={{
-        vertical: 'top',
-        horizontal: 'center',
-      }}
-      PaperProps={{
-        sx: {
-          width: 500,
-        },
-      }}
-    >
-      <div className='flex flex-col p-3'>
-        <EditLink
-          text={t('document.inlineLink.url.label')}
-          value={href}
-          onChange={(link) => {
-            onChange({
-              href: link,
-              title,
-            });
-          }}
-        />
-        <EditLink
-          text={t('document.inlineLink.title.label')}
-          value={title}
-          onChange={(text) =>
-            onChange({
-              href,
-              title: text,
-            })
-          }
-        />
-        <div className={'flex items-center justify-end'}>
-          <Button onClick={onDone}>
-            <Done />
-            {t('button.done')}
-          </Button>
-        </div>
-      </div>
-    </Popover>
-  );
-}
-
-export default LinkEditPopover;

+ 0 - 16
frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/TextLink/LinkHighLight.tsx

@@ -1,16 +0,0 @@
-import React from 'react';
-import { isOverlappingPrefix } from '$app/utils/document/temporary';
-
-function LinkHighLight({ children, leaf, title }: { leaf: { text: string }; title: string; children: React.ReactNode }) {
-  return (
-    <>
-      {leaf.text === title || isOverlappingPrefix(leaf.text, title) ? (
-        <span contentEditable={false}>{title}</span>
-      ) : null}
-
-      <span className={'absolute opacity-0'}>{children}</span>
-    </>
-  );
-}
-
-export default LinkHighLight;

+ 0 - 27
frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/TextLink/TextLink.hooks.ts

@@ -1,27 +0,0 @@
-import { useCallback, useMemo, useRef, useState } from 'react';
-import { debounce } from '$app/utils/tool';
-
-export function useTextLink(id: string) {
-  const [editing, setEditing] = useState(false);
-  const ref = useRef<HTMLAnchorElement | null>(null);
-
-  const show = useMemo(() => debounce(() => setEditing(true), 500), []);
-  const hide = useMemo(() => debounce(() => setEditing(false), 500), []);
-
-  const onMouseEnter = useCallback(() => {
-    hide.cancel();
-    show();
-  }, [hide, show]);
-
-  const onMouseLeave = useCallback(() => {
-    show.cancel();
-    hide();
-  }, [hide, show]);
-
-  return {
-    editing,
-    onMouseEnter,
-    onMouseLeave,
-    ref,
-  };
-}

+ 0 - 84
frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/TextLink/index.tsx

@@ -1,84 +0,0 @@
-import React, { useCallback, useContext } from 'react';
-import { NodeIdContext } from '$app/components/document/_shared/SubscribeNode.hooks';
-import { useTextLink } from '$app/components/document/_shared/TextLink/TextLink.hooks';
-import EditLinkToolbar from '$app/components/document/_shared/TextLink/EditLinkToolbar';
-import { useAppDispatch } from '$app/stores/store';
-import { linkPopoverActions } from '$app_reducers/document/slice';
-import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks';
-
-function TextLink({
-  getSelection,
-  title,
-  href,
-  children,
-}: {
-  getSelection: (node: Element) => {
-    index: number;
-    length: number;
-  } | null;
-  children: React.ReactNode;
-  href: string;
-  title: string;
-}) {
-  const blockId = useContext(NodeIdContext);
-  const { editing, ref, onMouseEnter, onMouseLeave } = useTextLink(blockId);
-  const dispatch = useAppDispatch();
-  const { docId } = useSubscribeDocument();
-
-  const onEdit = useCallback(() => {
-    if (!ref.current) return;
-    const selection = getSelection(ref.current);
-
-    if (!selection) return;
-    const rect = ref.current?.getBoundingClientRect();
-
-    if (!rect) return;
-    dispatch(
-      linkPopoverActions.setLinkPopover({
-        docId,
-        linkState: {
-          anchorPosition: {
-            top: rect.top + rect.height,
-            left: rect.left + rect.width / 2,
-          },
-          id: blockId,
-          selection,
-          title,
-          href,
-          open: true,
-        },
-      })
-    );
-  }, [blockId, dispatch, docId, getSelection, href, ref, title]);
-
-  if (!blockId) return null;
-
-  return (
-    <>
-      <a
-        onMouseLeave={onMouseLeave}
-        onMouseEnter={onMouseEnter}
-        ref={ref}
-        href={href}
-        target='_blank'
-        rel='noopener noreferrer'
-        className='cursor-pointer text-text-link-default'
-      >
-        <span className={' border-b-[1px] border-b-text-link-default '}>{children}</span>
-      </a>
-      {ref.current && (
-        <EditLinkToolbar
-          editing={editing}
-          href={href}
-          onMouseLeave={onMouseLeave}
-          onMouseEnter={onMouseEnter}
-          linkElement={ref.current}
-          blockId={blockId}
-          onEdit={onEdit}
-        />
-      )}
-    </>
-  );
-}
-
-export default TextLink;

+ 29 - 28
frontend/appflowy_tauri/src/appflowy_app/components/layout/Breadcrumb/Breadcrumb.hooks.ts

@@ -1,18 +1,17 @@
-import { useAppDispatch } from '$app/stores/store';
+import { useAppSelector } from "$app/stores/store";
 import { useCallback, useEffect, useMemo, useState } from 'react';
-import { PageController } from '$app/stores/effects/workspace/page/page_controller';
 import { useParams, useLocation } from 'react-router-dom';
-import { Page, pagesActions } from '$app_reducers/pages/slice';
-import { Log } from '$app/utils/log';
+import { Page } from '$app_reducers/pages/slice';
 import { useTranslation } from 'react-i18next';
+import { PageController } from "$app/stores/effects/workspace/page/page_controller";
 
 export function useLoadExpandedPages() {
-  const dispatch = useAppDispatch();
   const { t } = useTranslation();
   const params = useParams();
   const location = useLocation();
   const isTrash = useMemo(() => location.pathname.includes('trash'), [location.pathname]);
   const currentPageId = params.id;
+  const pageMap = useAppSelector((state) => state.pages.pageMap);
   const [pagePath, setPagePath] = useState<
     (
       | Page
@@ -22,37 +21,39 @@ export function useLoadExpandedPages() {
     )[]
   >([]);
 
-  const loadPage = useCallback(
-    async (id: string) => {
-      if (!id) return;
-      const controller = new PageController(id);
-
-      try {
-        const page = await controller.getPage();
-        const childPages = await controller.getChildPages();
-
-        dispatch(pagesActions.addChildPages({ id, childPages }));
-        dispatch(pagesActions.expandPage(id));
+  const loadPagePath = useCallback(
+    async (pageId: string) => {
+      let page = pageMap[pageId];
+      const controller = new PageController(pageId);
+      if (!page) {
+        try {
+          page = await controller.getPage();
+        } catch (e) {
+          // do nothing
+        }
 
-        setPagePath((prev) => [page, ...prev]);
-        await loadPage(page.parentId);
-      } catch (e) {
-        Log.info(`${id} is workspace`);
+        if (!page) {
+          return;
+        }
       }
-    },
-    [dispatch]
-  );
+      setPagePath(prev => {
+        return [
+          page,
+          ...prev
+        ]
+      });
+      await loadPagePath(page.parentId);
+
+    }, [pageMap]);
 
   useEffect(() => {
     setPagePath([]);
     if (!currentPageId) {
       return;
     }
-
-    void (async () => {
-      await loadPage(currentPageId);
-    })();
-  }, [currentPageId, dispatch, loadPage]);
+    loadPagePath(currentPageId);
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, [currentPageId]);
 
   useEffect(() => {
     if (isTrash) {

+ 21 - 38
frontend/appflowy_tauri/src/appflowy_app/components/layout/NestedPage/NestedPage.hooks.ts

@@ -5,12 +5,11 @@ import { useAppDispatch, useAppSelector } from '$app/stores/store';
 import { ViewLayoutPB } from '@/services/backend';
 import { useNavigate, useParams } from 'react-router-dom';
 import { pageTypeMap } from '$app/constants';
-import { useTranslation } from 'react-i18next';
+import { updatePageName } from '$app_reducers/pages/async_actions';
 
 export function useLoadChildPages(pageId: string) {
   const dispatch = useAppDispatch();
   const childPages = useAppSelector((state) => state.pages.relationMap[pageId]);
-
   const collapsed = useAppSelector((state) => !state.pages.expandedIdMap[pageId]);
   const toggleCollapsed = useCallback(() => {
     if (collapsed) {
@@ -24,31 +23,21 @@ export function useLoadChildPages(pageId: string) {
     return new PageController(pageId);
   }, [pageId]);
 
-  const onChildPagesChanged = useCallback(
-    (childPages: Page[]) => {
+  const onPageChanged = useCallback(
+    (page: Page, children: Page[]) => {
+      dispatch(pagesActions.onPageChanged(page));
       dispatch(
         pagesActions.addChildPages({
-          id: pageId,
-          childPages,
+          id: page.id,
+          childPages: children,
         })
       );
     },
-    [dispatch, pageId]
-  );
-
-  const onPageChanged = useCallback(
-    (page: Page) => {
-      dispatch(pagesActions.onPageChanged(page));
-    },
     [dispatch]
   );
 
-  const onPageCollapsed = useCallback(async () => {
-    dispatch(pagesActions.removeChildPages(pageId));
-    await controller.unsubscribe();
-  }, [dispatch, pageId, controller]);
 
-  const onPageExpanded = useCallback(async () => {
+  const loadPageChildren = useCallback(async (pageId: string) => {
     const childPages = await controller.getChildPages();
 
     dispatch(
@@ -57,25 +46,22 @@ export function useLoadChildPages(pageId: string) {
         childPages,
       })
     );
-    await controller.subscribe({
-      onChildPagesChanged,
-      onPageChanged,
-    });
-  }, [controller, dispatch, onChildPagesChanged, onPageChanged, pageId]);
+
+  }, [controller, dispatch]);
+
 
   useEffect(() => {
-    if (collapsed) {
-      onPageCollapsed();
-    } else {
-      onPageExpanded();
-    }
-  }, [collapsed, onPageCollapsed, onPageExpanded]);
+    void loadPageChildren(pageId);
+  }, [loadPageChildren, pageId]);
 
   useEffect(() => {
+    controller.subscribe({
+      onPageChanged,
+    });
     return () => {
       controller.dispose();
     };
-  }, [controller]);
+  }, [controller, onPageChanged]);
 
   return {
     toggleCollapsed,
@@ -86,7 +72,6 @@ export function useLoadChildPages(pageId: string) {
 
 export function usePageActions(pageId: string) {
   const page = useAppSelector((state) => state.pages.pageMap[pageId]);
-  const { t } = useTranslation();
   const dispatch = useAppDispatch();
   const navigate = useNavigate();
   const controller = useMemo(() => {
@@ -103,7 +88,7 @@ export function usePageActions(pageId: string) {
     async (layout: ViewLayoutPB) => {
       const newViewId = await controller.createPage({
         layout,
-        name: t('document.title.placeholder'),
+        name: ""
       });
 
       dispatch(pagesActions.expandPage(pageId));
@@ -111,7 +96,7 @@ export function usePageActions(pageId: string) {
 
       navigate(`/page/${pageType}/${newViewId}`);
     },
-    [t, controller, dispatch, navigate, pageId]
+    [controller, dispatch, navigate, pageId]
   );
 
   const onDeletePage = useCallback(async () => {
@@ -124,12 +109,9 @@ export function usePageActions(pageId: string) {
 
   const onRenamePage = useCallback(
     async (name: string) => {
-      await controller.updatePage({
-        id: pageId,
-        name,
-      });
+      await dispatch(updatePageName({ id: pageId, name }));
     },
-    [controller, pageId]
+    [dispatch, pageId]
   );
 
   useEffect(() => {
@@ -152,3 +134,4 @@ export function useSelectedPage(pageId: string) {
 
   return id === pageId;
 }
+

+ 8 - 2
frontend/appflowy_tauri/src/appflowy_app/components/layout/NestedPage/NestedPageTitle.tsx

@@ -6,6 +6,7 @@ import AddButton from './AddButton';
 import MoreButton from './MoreButton';
 import { ViewLayoutPB } from '@/services/backend';
 import { useSelectedPage } from '$app/components/layout/NestedPage/NestedPage.hooks';
+import { useTranslation } from 'react-i18next';
 
 function NestedPageTitle({
   pageId,
@@ -26,6 +27,7 @@ function NestedPageTitle({
   onDuplicate: () => Promise<void>;
   onRename: (newName: string) => Promise<void>;
 }) {
+  const { t } = useTranslation();
   const page = useAppSelector((state) => {
     return state.pages.pageMap[pageId];
   });
@@ -47,7 +49,7 @@ function NestedPageTitle({
               toggleCollapsed();
             }}
             style={{
-              transform: collapsed ? 'rotate(0deg)' : 'rotate(-90deg)',
+              transform: collapsed ? 'rotate(0deg)' : 'rotate(90deg)',
             }}
             className={'flex h-[100%] w-8 items-center justify-center p-2'}
           >
@@ -55,7 +57,11 @@ function NestedPageTitle({
               <ArrowRightSvg />
             </div>
           </button>
-          <div className={'flex-1 overflow-hidden text-ellipsis whitespace-nowrap'}>{page.name}</div>
+          {page.icon ? <div className={'mr-1 h-5 w-5'}>{page.icon.value}</div> : null}
+
+          <div className={'flex-1 overflow-hidden text-ellipsis whitespace-nowrap'}>
+            {page.name || t('menuAppHeader.defaultNewPageName')}
+          </div>
         </div>
         <div onClick={(e) => e.stopPropagation()} className={'min:w-14 flex items-center justify-end'}>
           <AddButton isVisible={isHovering} onAddPage={onAddPage} />

+ 6 - 1
frontend/appflowy_tauri/src/appflowy_app/components/layout/NestedPage/RenameDialog.tsx

@@ -1,4 +1,4 @@
-import React, { useState } from 'react';
+import React, { useEffect, useState } from 'react';
 import DialogTitle from '@mui/material/DialogTitle';
 import DialogContent from '@mui/material/DialogContent';
 import Dialog from '@mui/material/Dialog';
@@ -21,6 +21,10 @@ function RenameDialog({
   const [value, setValue] = useState(defaultValue);
   const [error, setError] = useState(false);
 
+  useEffect(() => {
+    setValue(defaultValue);
+    setError(false);
+  }, [defaultValue]);
   return (
     <Dialog keepMounted={false} onMouseDown={(e) => e.stopPropagation()} open={open} onClose={onClose}>
       <DialogTitle>{t('menuAppHeader.renameDialog')}</DialogTitle>
@@ -37,6 +41,7 @@ function RenameDialog({
           variant='standard'
         />
       </DialogContent>
+
       <DialogActions>
         <Button onClick={onClose}>{t('button.Cancel')}</Button>
         <Button

+ 6 - 2
frontend/appflowy_tauri/src/appflowy_app/components/layout/NestedPage/index.tsx

@@ -1,4 +1,4 @@
-import React, { useEffect } from 'react';
+import React, { useMemo } from 'react';
 import Collapse from '@mui/material/Collapse';
 import { TransitionGroup } from 'react-transition-group';
 import NestedPageTitle from '$app/components/layout/NestedPage/NestedPageTitle';
@@ -10,6 +10,10 @@ function NestedPage({ pageId }: { pageId: string }) {
   const { toggleCollapsed, collapsed, childPages } = useLoadChildPages(pageId);
   const { onAddPage, onPageClick, onDeletePage, onDuplicatePage, onRenamePage } = usePageActions(pageId);
 
+  const children = useMemo(() => {
+    return collapsed ? [] : childPages;
+  }, [collapsed, childPages]);
+
   return (
     <BlockDraggable id={pageId} type={BlockDraggableType.PAGE} data-page-id={pageId}>
       <NestedPageTitle
@@ -27,7 +31,7 @@ function NestedPage({ pageId }: { pageId: string }) {
 
       <div className={'pl-4 pt-[2px]'}>
         <TransitionGroup>
-          {childPages?.map((pageId) => (
+          {children?.map((pageId) => (
             <Collapse key={pageId}>
               <NestedPage key={pageId} pageId={pageId} />
             </Collapse>

+ 1 - 0
frontend/appflowy_tauri/src/appflowy_app/components/layout/UserSetting/AppearanceSetting.tsx

@@ -16,6 +16,7 @@ function AppearanceSetting({
   const { t } = useTranslation();
 
   useEffect(() => {
+
     const html = document.documentElement;
 
     html?.setAttribute('data-dark-mode', String(themeMode === ThemeMode.Dark));

+ 1 - 1
frontend/appflowy_tauri/src/appflowy_app/components/layout/WorkspaceManager/NewPageButton.tsx

@@ -21,7 +21,7 @@ function NewPageButton({ workspaceId }: { workspaceId: string }) {
       <button
         onClick={async () => {
           const { id } = await controller.createView({
-            name: t('document.title.placeholder'),
+            name: "",
             layout: ViewLayoutPB.Document,
             parent_view_id: workspaceId,
           });

+ 1 - 15
frontend/appflowy_tauri/src/appflowy_app/components/layout/WorkspaceManager/Workspace.hooks.ts

@@ -63,17 +63,6 @@ export function useLoadWorkspace(workspace: WorkspaceItem) {
     return new WorkspaceController(id);
   }, [id]);
 
-  const onWorkspaceChanged = useCallback(
-    (data: WorkspaceItem) => {
-      dispatch(workspaceActions.onWorkspaceChanged(data));
-    },
-    [dispatch]
-  );
-
-  const onWorkspaceDeleted = useCallback(() => {
-    dispatch(workspaceActions.onWorkspaceDeleted(id));
-  }, [dispatch, id]);
-
   const openWorkspace = useCallback(async () => {
     await controller.open();
   }, [controller]);
@@ -96,7 +85,6 @@ export function useLoadWorkspace(workspace: WorkspaceItem) {
 
   const initializeWorkspace = useCallback(async () => {
     const childPages = await controller.getChildPages();
-
     dispatch(
       pagesActions.addChildPages({
         id,
@@ -107,11 +95,9 @@ export function useLoadWorkspace(workspace: WorkspaceItem) {
 
   const subscribeToWorkspace = useCallback(async () => {
     await controller.subscribe({
-      onWorkspaceChanged,
-      onWorkspaceDeleted,
       onChildPagesChanged,
     });
-  }, [controller, onChildPagesChanged, onWorkspaceChanged, onWorkspaceDeleted]);
+  }, [controller, onChildPagesChanged]);
 
   useEffect(() => {
     void (async () => {

+ 10 - 19
frontend/appflowy_tauri/src/appflowy_app/interfaces/document.ts

@@ -82,10 +82,13 @@ export interface ImageBlockData {
   align: Align;
 }
 
+export enum CoverType {
+  Image = 'image',
+  Color = 'color',
+}
 export interface PageBlockData extends TextBlockData {
   cover?: string;
-  icon?: string;
-  coverType?: 'image' | 'color';
+  coverType?: CoverType;
 }
 
 export type BlockData<Type> = Type extends BlockType.HeadingBlock
@@ -303,10 +306,6 @@ export interface EditorProps {
   value?: Delta;
   selection?: RangeStaticNoId;
   decorateSelection?: RangeStaticNoId;
-  linkDecorateSelection?: {
-    selection?: RangeStaticNoId;
-    placeholder?: string;
-  };
   temporarySelection?: RangeStaticNoId;
   onSelectionChange?: (range: RangeStaticNoId | null, oldRange: RangeStaticNoId | null, source?: Sources) => void;
   onChange?: (delta: Delta, oldDelta: Delta, source?: Sources) => void;
@@ -319,15 +318,6 @@ export interface BlockCopyData {
   html: string;
 }
 
-export interface LinkPopoverState {
-  anchorPosition?: { top: number; left: number };
-  id?: string;
-  selection?: RangeStaticNoId;
-  open?: boolean;
-  href?: string;
-  title?: string;
-}
-
 export interface TemporaryState {
   id: string;
   type: TemporaryType;
@@ -339,10 +329,11 @@ export interface TemporaryState {
 
 export enum TemporaryType {
   Equation = 'equation',
+  Link = 'link',
 }
 
-export type TemporaryData = InlineEquationData;
-
-export interface InlineEquationData {
-  latex: string;
+export interface TemporaryData {
+  latex?: string;
+  href?: string;
+  text?: string;
 }

+ 18 - 3
frontend/appflowy_tauri/src/appflowy_app/stores/effects/user/user_setting_controller.ts

@@ -1,5 +1,6 @@
 import { UserBackendService } from '$app/stores/effects/user/user_bd_svc';
-import { AppearanceSettingsPB, ThemeModePB } from '@/services/backend';
+import { AppearanceSettingsPB } from '@/services/backend';
+import { Theme, ThemeMode, UserSetting } from '$app/interfaces';
 
 export class UserSettingController {
   private readonly backendService: UserBackendService;
@@ -17,11 +18,25 @@ export class UserSettingController {
     return {};
   };
 
-  getAppearanceSetting = async (): Promise<AppearanceSettingsPB | undefined> => {
+  getAppearanceSetting = async (): Promise<Partial<UserSetting> | undefined> => {
     const appearanceSetting = await this.backendService.getAppearanceSettings();
 
     if (appearanceSetting.ok) {
-      return appearanceSetting.val;
+      const res = appearanceSetting.val;
+      const { locale, theme = Theme.Default, theme_mode = ThemeMode.Light } = res;
+      let language = 'en';
+
+      if (locale.language_code && locale.country_code) {
+        language = `${locale.language_code}-${locale.country_code}`;
+      } else if (locale.language_code) {
+        language = locale.language_code;
+      }
+
+      return {
+        themeMode: theme_mode,
+        theme: theme as Theme,
+        language: language,
+      };
     }
 
     return;

+ 17 - 8
frontend/appflowy_tauri/src/appflowy_app/stores/effects/workspace/page/page_bd_svc.ts

@@ -14,8 +14,11 @@ import {
   ImportPB,
   MoveNestedViewPayloadPB,
   FolderEventMoveNestedView,
+  ViewIconPB,
+  UpdateViewIconPayloadPB,
+  FolderEventUpdateViewIcon,
 } from '@/services/backend/events/flowy-folder2';
-import { Page } from '$app_reducers/pages/slice';
+import { Page, PageIcon } from '$app_reducers/pages/slice';
 
 export class PageBackendService {
   constructor() {
@@ -54,15 +57,21 @@ export class PageBackendService {
       payload.name = page.name;
     }
 
-    if (page.cover !== undefined) {
-      payload.cover_url = page.cover;
-    }
+    return FolderEventUpdateView(payload);
+  };
 
-    if (page.icon !== undefined) {
-      payload.icon_url = page.icon;
-    }
+  updatePageIcon = async (viewId: string, icon?: PageIcon) => {
+    const payload = new UpdateViewIconPayloadPB({
+      view_id: viewId,
+      icon: icon
+        ? new ViewIconPB({
+            ty: icon.ty,
+            value: icon.value,
+          })
+        : undefined,
+    });
 
-    return FolderEventUpdateView(payload);
+    return FolderEventUpdateViewIcon(payload);
   };
 
   deletePage = async (viewId: string) => {

+ 19 - 26
frontend/appflowy_tauri/src/appflowy_app/stores/effects/workspace/page/page_controller.ts

@@ -1,14 +1,12 @@
-import { ViewLayoutPB } from '@/services/backend';
+import { ViewLayoutPB, ViewPB } from '@/services/backend';
 import { PageBackendService } from '$app/stores/effects/workspace/page/page_bd_svc';
 import { WorkspaceObserver } from '$app/stores/effects/workspace/workspace_observer';
-import { Page, parserViewPBToPage } from '$app_reducers/pages/slice';
-import { AsyncQueue } from '$app/utils/async_queue';
+import { Page, PageIcon, parserViewPBToPage } from '$app_reducers/pages/slice';
 
 export class PageController {
   private readonly backendService: PageBackendService = new PageBackendService();
 
   private readonly observer: WorkspaceObserver = new WorkspaceObserver();
-  private onChangeQueue?: AsyncQueue;
   constructor(private readonly id: string) {
     //
   }
@@ -72,22 +70,15 @@ export class PageController {
     return this.getPage(parentPageId);
   };
 
-  subscribe = async (callbacks: {
-    onChildPagesChanged?: (childPages: Page[]) => void;
-    onPageChanged?: (page: Page) => void;
-  }) => {
-    const onChanged = async () => {
-      const page = await this.getPage();
-      const childPages = await this.getChildPages();
-
-      callbacks.onPageChanged?.(page);
-      callbacks.onChildPagesChanged?.(childPages);
+  subscribe = async (callbacks: { onPageChanged?: (page: Page, children: Page[]) => void }) => {
+    const didUpdateView = (payload: Uint8Array) => {
+      const res = ViewPB.deserializeBinary(payload);
+      const page = parserViewPBToPage(ViewPB.deserializeBinary(payload));
+      const childPages = res.child_views.map(parserViewPBToPage);
+      callbacks.onPageChanged?.(page, childPages);
     };
-
-    this.onChangeQueue = new AsyncQueue(onChanged);
     await this.observer.subscribeView(this.id, {
-      didUpdateChildViews: this.didUpdateChildPages,
-      didUpdateView: this.didUpdateView,
+      didUpdateView,
     });
   };
 
@@ -105,6 +96,16 @@ export class PageController {
     return Promise.reject(result.err);
   };
 
+  updatePageIcon = async (icon?: PageIcon) => {
+    const result = await this.backendService.updatePageIcon(this.id, icon);
+
+    if (result.ok) {
+      return result.val;
+    }
+
+    return Promise.reject(result.err);
+  };
+
   deletePage = async () => {
     const result = await this.backendService.deletePage(this.id);
 
@@ -125,12 +126,4 @@ export class PageController {
 
     return Promise.reject(result.err);
   };
-
-  private didUpdateChildPages = (payload: Uint8Array) => {
-    this.onChangeQueue?.enqueue(Math.random());
-  };
-
-  private didUpdateView = (payload: Uint8Array) => {
-    this.onChangeQueue?.enqueue(Math.random());
-  };
 }

+ 6 - 29
frontend/appflowy_tauri/src/appflowy_app/stores/effects/workspace/workspace_controller.ts

@@ -1,18 +1,13 @@
 import { WorkspaceBackendService } from '$app/stores/effects/workspace/workspace_bd_svc';
 import { WorkspaceObserver } from '$app/stores/effects/workspace/workspace_observer';
-import { CreateViewPayloadPB } from '@/services/backend';
-import { WorkspaceItem } from '$app_reducers/workspace/slice';
+import { CreateViewPayloadPB, RepeatedViewPB } from "@/services/backend";
 import { PageBackendService } from '$app/stores/effects/workspace/page/page_bd_svc';
 import { Page, parserViewPBToPage } from '$app_reducers/pages/slice';
-import { AsyncQueue } from '$app/utils/async_queue';
 
 export class WorkspaceController {
   private readonly observer: WorkspaceObserver = new WorkspaceObserver();
   private readonly pageBackendService: PageBackendService;
   private readonly backendService: WorkspaceBackendService;
-  private onWorkspaceChanged?: (data: WorkspaceItem) => void;
-  private onWorkspaceDeleted?: () => void;
-  private onChangeQueue?: AsyncQueue;
   constructor(private readonly workspaceId: string) {
     this.pageBackendService = new PageBackendService();
     this.backendService = new WorkspaceBackendService();
@@ -43,23 +38,15 @@ export class WorkspaceController {
   };
 
   subscribe = async (callbacks: {
-    onWorkspaceChanged?: (data: WorkspaceItem) => void;
-    onWorkspaceDeleted?: () => void;
     onChildPagesChanged?: (childPages: Page[]) => void;
   }) => {
-    this.onWorkspaceChanged = callbacks.onWorkspaceChanged;
-    this.onWorkspaceDeleted = callbacks.onWorkspaceDeleted;
-    const onChildPagesChanged = async () => {
-      const childPages = await this.getChildPages();
 
-      callbacks.onChildPagesChanged?.(childPages);
-    };
-
-    this.onChangeQueue = new AsyncQueue(onChildPagesChanged);
+    const didUpdateWorkspace = (payload: Uint8Array) => {
+      const res = RepeatedViewPB.deserializeBinary(payload).items;
+      callbacks.onChildPagesChanged?.(res.map(parserViewPBToPage));
+    }
     await this.observer.subscribeWorkspace(this.workspaceId, {
-      didUpdateWorkspace: this.didUpdateWorkspace,
-      didDeleteWorkspace: this.didDeleteWorkspace,
-      didUpdateChildViews: this.didUpdateChildPages,
+      didUpdateWorkspace
     });
   };
 
@@ -85,15 +72,5 @@ export class WorkspaceController {
     return [];
   };
 
-  private didUpdateWorkspace = (payload: Uint8Array) => {
-    // this.onWorkspaceChanged?.(payload.toObject());
-  };
-
-  private didDeleteWorkspace = (payload: Uint8Array) => {
-    this.onWorkspaceDeleted?.();
-  };
 
-  private didUpdateChildPages = (payload: Uint8Array) => {
-    this.onChangeQueue?.enqueue(Math.random());
-  };
 }

+ 10 - 10
frontend/appflowy_tauri/src/appflowy_app/stores/effects/workspace/workspace_observer.ts

@@ -26,22 +26,22 @@ export class WorkspaceObserver {
   subscribeWorkspace = async (
     workspaceId: string,
     callbacks: {
-      didUpdateChildViews: (payload: Uint8Array) => void;
-      didUpdateWorkspace: (payload: Uint8Array) => void;
-      didDeleteWorkspace: (payload: Uint8Array) => void;
+      didUpdateChildViews?: (payload: Uint8Array) => void;
+      didUpdateWorkspace?: (payload: Uint8Array) => void;
+      didDeleteWorkspace?: (payload: Uint8Array) => void;
     }
   ) => {
     this.listener = new WorkspaceNotificationObserver({
       id: workspaceId,
       parserHandler: (notification, result) => {
         switch (notification) {
-          case FolderNotification.DidUpdateWorkspace:
+          case FolderNotification.DidUpdateWorkspaceViews:
             if (!result.ok) break;
-            callbacks.didUpdateWorkspace(result.val);
+            callbacks.didUpdateWorkspace?.(result.val);
             break;
           case FolderNotification.DidUpdateChildViews:
             if (!result.ok) break;
-            callbacks.didUpdateChildViews(result.val);
+            callbacks.didUpdateChildViews?.(result.val);
             break;
           // case FolderNotification.DidDeleteWorkspace:
           //   if (!result.ok) break;
@@ -58,8 +58,8 @@ export class WorkspaceObserver {
   subscribeView = async (
     viewId: string,
     callbacks: {
-      didUpdateChildViews: (payload: Uint8Array) => void;
-      didUpdateView: (payload: Uint8Array) => void;
+      didUpdateChildViews?: (payload: Uint8Array) => void;
+      didUpdateView?: (payload: Uint8Array) => void;
     }
   ) => {
     this.listener = new WorkspaceNotificationObserver({
@@ -68,11 +68,11 @@ export class WorkspaceObserver {
         switch (notification) {
           case FolderNotification.DidUpdateChildViews:
             if (!result.ok) break;
-            callbacks.didUpdateChildViews(result.val);
+            callbacks.didUpdateChildViews?.(result.val);
             break;
           case FolderNotification.DidUpdateView:
             if (!result.ok) break;
-            callbacks.didUpdateView(result.val);
+            callbacks.didUpdateView?.(result.val);
             break;
           default:
             break;

+ 18 - 2
frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/update.ts

@@ -4,17 +4,33 @@ import { createAsyncThunk } from '@reduxjs/toolkit';
 import Delta, { Op } from 'quill-delta';
 import { RootState } from '$app/stores/store';
 import { DOCUMENT_NAME } from '$app/constants/document/name';
+import { updatePageName } from '$app_reducers/pages/async_actions';
+import { getDeltaText } from '$app/utils/document/delta';
 
 export const updateNodeDeltaThunk = createAsyncThunk(
   'document/updateNodeDelta',
   async (payload: { id: string; delta: Op[]; controller: DocumentController }, thunkAPI) => {
     const { id, delta, controller } = payload;
-    const { getState } = thunkAPI;
+    const { getState, dispatch } = thunkAPI;
     const state = getState() as RootState;
     const docId = controller.documentId;
     const docState = state[DOCUMENT_NAME][docId];
     const node = docState.nodes[id];
-    const diffDelta = new Delta(delta).diff(new Delta(node.data.delta || []));
+    const oldDelta = new Delta(node.data.delta);
+    const newDelta = new Delta(delta);
+
+    // If the node is the root node, update the page name
+    if (!node.parent) {
+      await dispatch(
+        updatePageName({
+          id: docId,
+          name: getDeltaText(newDelta),
+        })
+      );
+      return;
+    }
+
+    const diffDelta = newDelta.diff(oldDelta);
 
     if (diffDelta.ops.length === 0) return;
 

+ 0 - 1
frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/index.ts

@@ -2,4 +2,3 @@ export * from './blocks';
 export * from './turn_to';
 export * from './keydown';
 export * from './range';
-export * from './link';

+ 89 - 23
frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/keydown.ts

@@ -1,7 +1,7 @@
-import { createAsyncThunk } from '@reduxjs/toolkit';
-import { DocumentController } from '$app/stores/effects/document/document_controller';
-import { BlockType, RangeStatic, SplitRelationship } from '$app/interfaces/document';
-import { turnToTextBlockThunk } from '$app_reducers/document/async-actions/turn_to';
+import { createAsyncThunk } from "@reduxjs/toolkit";
+import { DocumentController } from "$app/stores/effects/document/document_controller";
+import { BlockType, RangeStatic, SplitRelationship } from "$app/interfaces/document";
+import { turnToTextBlockThunk } from "$app_reducers/document/async-actions/turn_to";
 import {
   findNextHasDeltaNode,
   findPrevHasDeltaNode,
@@ -9,23 +9,27 @@ import {
   getLeftCaretByRange,
   getRightCaretByRange,
   transformToNextLineCaret,
-  transformToPrevLineCaret,
-} from '$app/utils/document/action';
-import Delta from 'quill-delta';
-import { indentNodeThunk, mergeDeltaThunk, outdentNodeThunk } from '$app_reducers/document/async-actions/blocks';
-import { rangeActions } from '$app_reducers/document/slice';
-import { RootState } from '$app/stores/store';
-import { blockConfig } from '$app/constants/document/config';
-import { Keyboard } from '$app/constants/document/keyboard';
-import { DOCUMENT_NAME, RANGE_NAME } from '$app/constants/document/name';
-import { getPreviousWordIndex } from '$app/utils/document/delta';
+  transformToPrevLineCaret
+} from "$app/utils/document/action";
+import Delta from "quill-delta";
+import { indentNodeThunk, mergeDeltaThunk, outdentNodeThunk } from "$app_reducers/document/async-actions/blocks";
+import { rangeActions } from "$app_reducers/document/slice";
+import { RootState } from "$app/stores/store";
+import { blockConfig } from "$app/constants/document/config";
+import { Keyboard } from "$app/constants/document/keyboard";
+import { DOCUMENT_NAME, RANGE_NAME } from "$app/constants/document/name";
+import { getDeltaText, getPreviousWordIndex } from "$app/utils/document/delta";
+import { updatePageName } from "$app_reducers/pages/async_actions";
+import { newBlock } from "$app/utils/document/block";
+
 
 /**
- * Delete a block by backspace or delete key
- * 1. If the block is not a text block, turn it to a text block
- * 2. If the block is a text block
- *   2.1 If the block has next node or is top level, merge it to the previous line
- *   2.2 If the block has no next node and is not top level, outdent it
+ - Deletes a block using the backspace or delete key.
+ - If the block is not a text block, it is converted into a text block.
+ - If the block is a text block:
+ - - If the block is the first line, it is merged into the document title, and a new line is inserted.
+ - - If the block is not the first line and it has a next sibling, it is merged into the previous line (including the previous sibling and its parent).
+ - - If the block has no next sibling and is not a top-level block, it is outdented (moved to a higher level in the hierarchy).
  */
 export const backspaceDeleteActionForBlockThunk = createAsyncThunk(
   'document/backspaceDeleteActionForBlock',
@@ -49,11 +53,43 @@ export const backspaceDeleteActionForBlockThunk = createAsyncThunk(
     }
 
     const isTopLevel = parent.type === BlockType.PageBlock;
+    const isFirstLine = isTopLevel && index === 0;
 
+    if (isTopLevel && isFirstLine) {
+      // merge to document title and insert a new line
+      const parentDelta = new Delta(parent.data.delta);
+      const caretIndex = parentDelta.length();
+      const caret = {
+        id: parent.id,
+        index: caretIndex,
+        length: 0,
+      };
+      const titleDelta = parentDelta.concat(new Delta(node.data.delta));
+      await dispatch(updatePageName({ id: docId, name: getDeltaText(titleDelta) }));
+      const actions = [
+        controller.getDeleteAction(node),
+      ]
+
+      if (!nextNodeId) {
+        // insert a new line
+        const block = newBlock<any>(BlockType.TextBlock, parent.id, {
+          delta: [{ insert: "" }]
+        });
+        actions.push(controller.getInsertAction(block, null));
+      }
+      await controller.applyActions(actions);
+      dispatch(rangeActions.initialState(docId));
+      dispatch(
+        rangeActions.setCaret({
+          docId,
+          caret,
+        })
+      );
+      return;
+    }
     if (isTopLevel || nextNodeId) {
       // merge to previous line
       const prevLine = findPrevHasDeltaNode(state, id);
-
       if (!prevLine) return;
       const caretIndex = new Delta(prevLine.data.delta).length();
       const caret = {
@@ -104,19 +140,49 @@ export const enterActionForBlockThunk = createAsyncThunk(
     if (!node || !caret || caret.id !== id) return;
     const delta = new Delta(node.data.delta);
 
+    const nodeDelta = delta.slice(0, caret.index);
+
+    const insertNodeDelta = new Delta(node.data.delta).slice(caret.index + caret.length);
+
+    const isDocumentTitle = !node.parent;
+    // update page title and insert a new line
+    if (isDocumentTitle) {
+      // update page title
+      await dispatch(updatePageName({
+        id: docId,
+        name: getDeltaText(nodeDelta),
+      }));
+      // insert a new line
+      const block = newBlock<any>(BlockType.TextBlock, node.id, {
+        delta: insertNodeDelta.ops,
+      });
+      const insertNodeAction = controller.getInsertAction(block, null);
+      await controller.applyActions([insertNodeAction]);
+      dispatch(rangeActions.initialState(docId));
+      dispatch(
+        rangeActions.setCaret({
+          docId,
+          caret: {
+            id: block.id,
+            index: 0,
+            length: 0,
+          },
+        })
+      );
+      return;
+    }
+
     if (delta.length() === 0 && node.type !== BlockType.TextBlock) {
       // If the node is not a text block, turn it to a text block
       await dispatch(turnToTextBlockThunk({ id, controller }));
       return;
     }
 
-    const nodeDelta = delta.slice(0, caret.index);
-
-    const insertNodeDelta = new Delta(node.data.delta).slice(caret.index + caret.length);
 
     const insertNodeAction = getInsertEnterNodeAction(node, insertNodeDelta, controller);
 
     if (!insertNodeAction) return;
+
     const updateNode = {
       ...node,
       data: {

+ 0 - 103
frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/link.ts

@@ -1,103 +0,0 @@
-import { createAsyncThunk } from '@reduxjs/toolkit';
-import { DocumentController } from '$app/stores/effects/document/document_controller';
-import Delta from 'quill-delta';
-import { linkPopoverActions, rangeActions } from '$app_reducers/document/slice';
-import { RootState } from '$app/stores/store';
-import { DOCUMENT_NAME, RANGE_NAME, TEXT_LINK_NAME } from '$app/constants/document/name';
-
-export const formatLinkThunk = createAsyncThunk<
-  boolean,
-  {
-    controller: DocumentController;
-  }
->('document/formatLink', async (payload, thunkAPI) => {
-  const { controller } = payload;
-  const { getState } = thunkAPI;
-  const docId = controller.documentId;
-  const state = getState() as RootState;
-  const documentState = state[DOCUMENT_NAME][docId];
-  const linkPopover = state[TEXT_LINK_NAME][docId];
-
-  if (!linkPopover) return false;
-  const { selection, id, href, title = '' } = linkPopover;
-
-  if (!selection || !id) return false;
-  const node = documentState.nodes[id];
-  const nodeDelta = new Delta(node.data?.delta);
-  const index = selection.index || 0;
-  const length = selection.length || 0;
-  const regex = new RegExp(/^(https?:\/\/)?([\da-z.-]+)\.([a-z.]{2,6})([/\w .-]*)*\/?$/);
-
-  if (href && !regex.test(href)) {
-    return false;
-  }
-
-  const diffDelta = new Delta().retain(index).delete(length).insert(title, {
-    href,
-  });
-
-  const newDelta = nodeDelta.compose(diffDelta);
-
-  const updateAction = controller.getUpdateAction({
-    ...node,
-    data: {
-      ...node.data,
-      delta: newDelta.ops,
-    },
-  });
-
-  await controller.applyActions([updateAction]);
-  return true;
-});
-
-export const newLinkThunk = createAsyncThunk<
-  void,
-  {
-    docId: string;
-  }
->('document/newLink', async ({ docId }, thunkAPI) => {
-  const { getState, dispatch } = thunkAPI;
-  const state = getState() as RootState;
-  const documentState = state[DOCUMENT_NAME][docId];
-  const documentRange = state[RANGE_NAME][docId];
-
-  const { caret } = documentRange;
-
-  if (!caret) return;
-  const { index, length, id } = caret;
-
-  const block = documentState.nodes[id];
-  const delta = new Delta(block.data.delta).slice(index, index + length);
-  const op = delta.ops.find((op) => op.attributes?.href);
-  const href = op?.attributes?.href as string;
-
-  const domSelection = window.getSelection();
-
-  if (!domSelection) return;
-  const domRange = domSelection.rangeCount > 0 ? domSelection.getRangeAt(0) : null;
-
-  if (!domRange) return;
-  const title = domSelection.toString();
-  const { top, left, height, width } = domRange.getBoundingClientRect();
-
-  dispatch(rangeActions.initialState(docId));
-  dispatch(
-    linkPopoverActions.setLinkPopover({
-      docId,
-      linkState: {
-        anchorPosition: {
-          top: top + height,
-          left: left + width / 2,
-        },
-        id,
-        selection: {
-          index,
-          length,
-        },
-        title,
-        href,
-        open: true,
-      },
-    })
-  );
-});

+ 34 - 4
frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/temporary.ts

@@ -33,14 +33,13 @@ export const createTemporary = createAsyncThunk(
       const rangeDelta = getDeltaByRange(nodeDelta, selection);
       const text = getDeltaText(rangeDelta);
 
+      const data = newDataWithTemporaryType(type, text);
       temporaryState = {
         id,
         selection,
         selectedText: text,
         type,
-        data: {
-          latex: text,
-        },
+        data,
       };
     }
 
@@ -51,6 +50,22 @@ export const createTemporary = createAsyncThunk(
   }
 );
 
+function newDataWithTemporaryType(type: TemporaryType, text: string) {
+  switch (type) {
+    case TemporaryType.Equation:
+      return {
+        latex: text,
+      };
+    case TemporaryType.Link:
+      return {
+        href: '',
+        text: text,
+      };
+    default:
+      return {};
+  }
+}
+
 export const formatTemporary = createAsyncThunk(
   'document/temporary/format',
   async (payload: { controller: DocumentController }, thunkAPI) => {
@@ -69,7 +84,7 @@ export const formatTemporary = createAsyncThunk(
     const nodeDelta = new Delta(node.data?.delta);
     const { index, length } = selection;
     const diffDelta: Delta = new Delta();
-    let newSelection;
+    let newSelection = selection;
 
     switch (type) {
       case TemporaryType.Equation: {
@@ -91,6 +106,21 @@ export const formatTemporary = createAsyncThunk(
 
         break;
       }
+      case TemporaryType.Link: {
+        if (!data.text) return;
+        if (!data.href) {
+          diffDelta.retain(index).delete(length).insert(data.text);
+        } else {
+          diffDelta.retain(index).delete(length).insert(data.text, {
+            href: data.href,
+          });
+        }
+        newSelection = {
+          index: selection.index,
+          length: data.text.length,
+        };
+        break;
+      }
 
       default:
         break;

+ 18 - 63
frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/slice.ts

@@ -5,21 +5,15 @@ import {
   SlashCommandState,
   RangeState,
   RangeStatic,
-  LinkPopoverState,
   SlashCommandOption,
 } from '@/appflowy_app/interfaces/document';
 import { BlockEventPayloadPB } from '@/services/backend';
 import { createSlice, PayloadAction } from '@reduxjs/toolkit';
 import { parseValue, matchChange } from '$app/utils/document/subscribe';
 import { temporarySlice } from '$app_reducers/document/temporary_slice';
-import {
-  DOCUMENT_NAME,
-  RANGE_NAME,
-  RECT_RANGE_NAME,
-  SLASH_COMMAND_NAME,
-  TEXT_LINK_NAME,
-} from '$app/constants/document/name';
+import { DOCUMENT_NAME, RANGE_NAME, RECT_RANGE_NAME, SLASH_COMMAND_NAME } from '$app/constants/document/name';
 import { blockEditSlice } from '$app_reducers/document/block_edit_slice';
+import { Op } from 'quill-delta';
 
 const initialState: Record<string, DocumentState> = {};
 
@@ -29,8 +23,6 @@ const rangeInitialState: Record<string, RangeState> = {};
 
 const slashCommandInitialState: Record<string, SlashCommandState> = {};
 
-const linkPopoverState: Record<string, LinkPopoverState> = {};
-
 export const documentSlice = createSlice({
   name: DOCUMENT_NAME,
   initialState: initialState,
@@ -68,6 +60,22 @@ export const documentSlice = createSlice({
         children,
       };
     },
+
+    updateRootNodeDelta: (
+      state,
+      action: PayloadAction<{
+        docId: string;
+        rootId: string;
+        delta: Op[];
+      }>
+    ) => {
+      const { docId, delta, rootId } = action.payload;
+      const documentState = state[docId];
+      if (!documentState) return;
+      const rootNode = documentState.nodes[rootId];
+      if (!rootNode) return;
+      rootNode.data.delta = delta;
+    },
     /**
      This function listens for changes in the data layer triggered by the data API,
      and updates the UI state accordingly.
@@ -371,63 +379,11 @@ export const slashCommandSlice = createSlice({
   },
 });
 
-export const linkPopoverSlice = createSlice({
-  name: TEXT_LINK_NAME,
-  initialState: linkPopoverState,
-  reducers: {
-    initialState: (state, action: PayloadAction<string>) => {
-      const docId = action.payload;
-
-      state[docId] = {
-        open: false,
-      };
-    },
-    clear: (state, action: PayloadAction<string>) => {
-      const docId = action.payload;
-
-      delete state[docId];
-    },
-    setLinkPopover: (
-      state,
-      action: PayloadAction<{
-        docId: string;
-        linkState: LinkPopoverState;
-      }>
-    ) => {
-      const { docId, linkState } = action.payload;
-
-      state[docId] = linkState;
-    },
-    updateLinkPopover: (
-      state,
-      action: PayloadAction<{
-        docId: string;
-        linkState: LinkPopoverState;
-      }>
-    ) => {
-      const { docId, linkState } = action.payload;
-      const { id } = linkState;
-
-      if (!state[docId].open || state[docId].id !== id) return;
-      state[docId] = {
-        ...state[docId],
-        ...linkState,
-      };
-    },
-    closeLinkPopover: (state, action: PayloadAction<string>) => {
-      const docId = action.payload;
-
-      state[docId].open = false;
-    },
-  },
-});
-
 export const documentReducers = {
   [documentSlice.name]: documentSlice.reducer,
   [rectSelectionSlice.name]: rectSelectionSlice.reducer,
   [rangeSlice.name]: rangeSlice.reducer,
   [slashCommandSlice.name]: slashCommandSlice.reducer,
-  [linkPopoverSlice.name]: linkPopoverSlice.reducer,
   [temporarySlice.name]: temporarySlice.reducer,
   [blockEditSlice.name]: blockEditSlice.reducer,
 };
@@ -436,4 +392,3 @@ export const documentActions = documentSlice.actions;
 export const rectSelectionActions = rectSelectionSlice.actions;
 export const rangeActions = rangeSlice.actions;
 export const slashCommandActions = slashCommandSlice.actions;
-export const linkPopoverActions = linkPopoverSlice.actions;

+ 34 - 0
frontend/appflowy_tauri/src/appflowy_app/stores/reducers/pages/async_actions.ts

@@ -2,6 +2,7 @@ import { createAsyncThunk } from '@reduxjs/toolkit';
 import { RootState } from '$app/stores/store';
 import { DragInsertType } from '$app_reducers/block-draggable/slice';
 import { PageController } from '$app/stores/effects/workspace/page/page_controller';
+import { PageIcon } from '$app_reducers/pages/slice';
 
 export const movePageThunk = createAsyncThunk(
   'pages/movePage',
@@ -56,3 +57,36 @@ export const movePageThunk = createAsyncThunk(
     await controller.movePage({ parentId, prevId });
   }
 );
+
+export const updatePageName = createAsyncThunk(
+  'pages/updateName',
+  async (
+    payload: {
+      id: string;
+      name: string;
+    },
+    thunkAPI
+  ) => {
+    const controller = new PageController(payload.id);
+
+    await controller.updatePage({
+      id: payload.id,
+      name: payload.name,
+    });
+  }
+);
+
+export const updatePageIcon = createAsyncThunk(
+  'pages/updateIcon',
+  async (
+    payload: {
+      id: string;
+      icon?: PageIcon;
+    },
+    thunkAPI
+  ) => {
+    const controller = new PageController(payload.id);
+
+    await controller.updatePageIcon(payload.icon);
+  }
+);

+ 34 - 8
frontend/appflowy_tauri/src/appflowy_app/stores/reducers/pages/slice.ts

@@ -1,4 +1,4 @@
-import { ViewLayoutPB, ViewPB } from '@/services/backend';
+import { ViewIconTypePB, ViewLayoutPB, ViewPB } from '@/services/backend';
 import { createSlice, PayloadAction } from '@reduxjs/toolkit';
 
 export interface Page {
@@ -6,18 +6,28 @@ export interface Page {
   parentId: string;
   name: string;
   layout: ViewLayoutPB;
-  icon?: string;
-  cover?: string;
+  icon?: PageIcon;
 }
 
-export function parserViewPBToPage(view: ViewPB) {
+export interface PageIcon {
+  ty: ViewIconTypePB;
+  value: string;
+}
+
+export function parserViewPBToPage(view: ViewPB): Page {
+  const icon = view.icon;
+
   return {
     id: view.id,
     name: view.name,
     parentId: view.parent_view_id,
     layout: view.layout,
-    cover: view.cover_url,
-    icon: view.icon_url,
+    icon: icon
+      ? {
+          ty: icon.ty,
+          value: icon.value,
+        }
+      : undefined,
   };
 }
 
@@ -30,7 +40,10 @@ export interface PageState {
 export const initialState: PageState = {
   pageMap: {},
   relationMap: {},
-  expandedIdMap: {},
+  expandedIdMap: getExpandedPageIds().reduce((acc, id) => {
+    acc[id] = true;
+    return acc;
+  }, {} as Record<string, boolean>),
 };
 
 export const pagesSlice = createSlice({
@@ -75,16 +88,29 @@ export const pagesSlice = createSlice({
 
     expandPage(state, action: PayloadAction<string>) {
       const id = action.payload;
-
       state.expandedIdMap[id] = true;
+      const ids = Object.keys(state.expandedIdMap).filter(id => state.expandedIdMap[id]);
+      storeExpandedPageIds(ids);
     },
 
     collapsePage(state, action: PayloadAction<string>) {
       const id = action.payload;
 
       state.expandedIdMap[id] = false;
+      const ids = Object.keys(state.expandedIdMap).filter(id => state.expandedIdMap[id]);
+      storeExpandedPageIds(ids);
     },
   },
 });
 
 export const pagesActions = pagesSlice.actions;
+
+function storeExpandedPageIds(expandedPageIds: string[]) {
+  localStorage.setItem('expandedPageIds', JSON.stringify(expandedPageIds));
+}
+
+function getExpandedPageIds(): string[] {
+  const expandedPageIds = localStorage.getItem('expandedPageIds');
+
+  return expandedPageIds ? JSON.parse(expandedPageIds) : [];
+}

+ 2 - 2
frontend/appflowy_tauri/src/appflowy_app/utils/document/emoji.ts

@@ -1,9 +1,9 @@
 import emojiData, { EmojiMartData } from '@emoji-mart/data';
 
-export const randomEmoji = () => {
+export const randomEmoji = (skin = 0) => {
   const emojis = (emojiData as EmojiMartData).emojis;
   const keys = Object.keys(emojis);
   const randomKey = keys[Math.floor(Math.random() * keys.length)];
 
-  return emojis[randomKey].skins[0].native;
+  return emojis[randomKey].skins[skin].native;
 };

+ 0 - 3
frontend/appflowy_tauri/src/appflowy_app/views/DocumentPage.hooks.ts

@@ -6,7 +6,6 @@ import { useAppDispatch } from '../stores/store';
 import { Log } from '../utils/log';
 import {
   documentActions,
-  linkPopoverActions,
   rangeActions,
   rectSelectionActions,
   slashCommandActions,
@@ -34,7 +33,6 @@ export const useDocument = () => {
       dispatch(rangeActions.initialState(docId));
       dispatch(rectSelectionActions.initialState(docId));
       dispatch(slashCommandActions.initialState(docId));
-      dispatch(linkPopoverActions.initialState(docId));
     },
     [dispatch]
   );
@@ -46,7 +44,6 @@ export const useDocument = () => {
       dispatch(rangeActions.clear(docId));
       dispatch(rectSelectionActions.clear(docId));
       dispatch(slashCommandActions.clear(docId));
-      dispatch(linkPopoverActions.clear(docId));
     },
     [dispatch]
   );

+ 4 - 0
frontend/appflowy_tauri/src/styles/mui.css

@@ -15,6 +15,10 @@
     background-color: var(--fill-list-active);
 }
 
+.MuiList-root .Mui-focusVisible.MuiMenuItem-root:not(:hover, .Mui-selected) {
+    background-color: unset;
+}
+
 .MuiPaper-root.MuiMenu-paper.MuiPopover-paper {
     background-image: none;
 }

+ 5 - 1
frontend/resources/translations/en.json

@@ -563,6 +563,9 @@
     },
     "inlineLink": {
       "placeholder": "Paste or type a link",
+      "openInNewTab": "Open in new tab",
+      "copyLink": "Copy link",
+      "removeLink": "Remove link",
       "url": {
         "label": "Link URL",
         "placeholder": "Enter link URL"
@@ -651,7 +654,8 @@
       "objects": "Objects",
       "symbols": "Symbols",
       "flags": "Flags",
-      "nature": "Nature"
+      "nature": "Nature",
+      "frequentlyUsed": "Frequently Used"
     }
   }
 }

+ 56 - 56
frontend/rust-lib/Cargo.lock

@@ -96,7 +96,7 @@ checksum = "9c7d0618f0e0b7e8ff11427422b64564d5fb0be1940354bfe2e0529b18a9d9b8"
 [[package]]
 name = "appflowy-integrate"
 version = "0.1.0"
-source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=e9a50f#e9a50f780fa97b1e3fce6ffe7720e9585d674974"
+source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=aac4e56#aac4e56bdb6a61598697283582d369a7af85d9c8"
 dependencies = [
  "anyhow",
  "collab",
@@ -189,9 +189,9 @@ checksum = "d468802bab17cbc0cc575e9b053f41e72aa36bfa6b7f55e3529ffa43161b97fa"
 
 [[package]]
 name = "aws-config"
-version = "0.55.2"
+version = "0.55.3"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "fc00553f5f3c06ffd4510a9d576f92143618706c45ea6ff81e84ad9be9588abd"
+checksum = "bcdcf0d683fe9c23d32cf5b53c9918ea0a500375a9fb20109802552658e576c9"
 dependencies = [
  "aws-credential-types",
  "aws-http",
@@ -219,9 +219,9 @@ dependencies = [
 
 [[package]]
 name = "aws-credential-types"
-version = "0.55.2"
+version = "0.55.3"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "4cb57ac6088805821f78d282c0ba8aec809f11cbee10dda19a97b03ab040ccc2"
+checksum = "1fcdb2f7acbc076ff5ad05e7864bdb191ca70a6fd07668dc3a1a8bcd051de5ae"
 dependencies = [
  "aws-smithy-async",
  "aws-smithy-types",
@@ -233,9 +233,9 @@ dependencies = [
 
 [[package]]
 name = "aws-endpoint"
-version = "0.55.2"
+version = "0.55.3"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9c5f6f84a4f46f95a9bb71d9300b73cd67eb868bc43ae84f66ad34752299f4ac"
+checksum = "8cce1c41a6cfaa726adee9ebb9a56fcd2bbfd8be49fd8a04c5e20fd968330b04"
 dependencies = [
  "aws-smithy-http",
  "aws-smithy-types",
@@ -247,9 +247,9 @@ dependencies = [
 
 [[package]]
 name = "aws-http"
-version = "0.55.2"
+version = "0.55.3"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a754683c322f7dc5167484266489fdebdcd04d26e53c162cad1f3f949f2c5671"
+checksum = "aadbc44e7a8f3e71c8b374e03ecd972869eb91dd2bc89ed018954a52ba84bc44"
 dependencies = [
  "aws-credential-types",
  "aws-smithy-http",
@@ -292,9 +292,9 @@ dependencies = [
 
 [[package]]
 name = "aws-sdk-sso"
-version = "0.27.0"
+version = "0.28.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "babfd626348836a31785775e3c08a4c345a5ab4c6e06dfd9167f2bee0e6295d6"
+checksum = "c8b812340d86d4a766b2ca73f740dfd47a97c2dff0c06c8517a16d88241957e4"
 dependencies = [
  "aws-credential-types",
  "aws-endpoint",
@@ -317,9 +317,9 @@ dependencies = [
 
 [[package]]
 name = "aws-sdk-sts"
-version = "0.27.0"
+version = "0.28.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "2d0fbe3c2c342bc8dfea4bb43937405a8ec06f99140a0dcb9c7b59e54dfa93a1"
+checksum = "265fac131fbfc188e5c3d96652ea90ecc676a934e3174eaaee523c6cec040b3b"
 dependencies = [
  "aws-credential-types",
  "aws-endpoint",
@@ -343,9 +343,9 @@ dependencies = [
 
 [[package]]
 name = "aws-sig-auth"
-version = "0.55.2"
+version = "0.55.3"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "84dc92a63ede3c2cbe43529cb87ffa58763520c96c6a46ca1ced80417afba845"
+checksum = "3b94acb10af0c879ecd5c7bdf51cda6679a0a4f4643ce630905a77673bfa3c61"
 dependencies = [
  "aws-credential-types",
  "aws-sigv4",
@@ -357,9 +357,9 @@ dependencies = [
 
 [[package]]
 name = "aws-sigv4"
-version = "0.55.2"
+version = "0.55.3"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "392fefab9d6fcbd76d518eb3b1c040b84728ab50f58df0c3c53ada4bea9d327e"
+checksum = "9d2ce6f507be68e968a33485ced670111d1cbad161ddbbab1e313c03d37d8f4c"
 dependencies = [
  "aws-smithy-http",
  "form_urlencoded",
@@ -376,9 +376,9 @@ dependencies = [
 
 [[package]]
 name = "aws-smithy-async"
-version = "0.55.2"
+version = "0.55.3"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ae23b9fe7a07d0919000116c4c5c0578303fbce6fc8d32efca1f7759d4c20faf"
+checksum = "13bda3996044c202d75b91afeb11a9afae9db9a721c6a7a427410018e286b880"
 dependencies = [
  "futures-util",
  "pin-project-lite",
@@ -388,9 +388,9 @@ dependencies = [
 
 [[package]]
 name = "aws-smithy-client"
-version = "0.55.2"
+version = "0.55.3"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5230d25d244a51339273b8870f0f77874cd4449fb4f8f629b21188ae10cfc0ba"
+checksum = "0a86aa6e21e86c4252ad6a0e3e74da9617295d8d6e374d552be7d3059c41cedd"
 dependencies = [
  "aws-smithy-async",
  "aws-smithy-http",
@@ -412,9 +412,9 @@ dependencies = [
 
 [[package]]
 name = "aws-smithy-http"
-version = "0.55.2"
+version = "0.55.3"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b60e2133beb9fe6ffe0b70deca57aaeff0a35ad24a9c6fab2fd3b4f45b99fdb5"
+checksum = "2b3b693869133551f135e1f2c77cb0b8277d9e3e17feaf2213f735857c4f0d28"
 dependencies = [
  "aws-smithy-types",
  "bytes",
@@ -434,9 +434,9 @@ dependencies = [
 
 [[package]]
 name = "aws-smithy-http-tower"
-version = "0.55.2"
+version = "0.55.3"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3a4d94f556c86a0dd916a5d7c39747157ea8cb909ca469703e20fee33e448b67"
+checksum = "3ae4f6c5798a247fac98a867698197d9ac22643596dc3777f0c76b91917616b9"
 dependencies = [
  "aws-smithy-http",
  "aws-smithy-types",
@@ -450,18 +450,18 @@ dependencies = [
 
 [[package]]
 name = "aws-smithy-json"
-version = "0.55.2"
+version = "0.55.3"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5ce3d6e6ebb00b2cce379f079ad5ec508f9bcc3a9510d9b9c1840ed1d6f8af39"
+checksum = "23f9f42fbfa96d095194a632fbac19f60077748eba536eb0b9fecc28659807f8"
 dependencies = [
  "aws-smithy-types",
 ]
 
 [[package]]
 name = "aws-smithy-query"
-version = "0.55.2"
+version = "0.55.3"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d58edfca32ef9bfbc1ca394599e17ea329cb52d6a07359827be74235b64b3298"
+checksum = "98819eb0b04020a1c791903533b638534ae6c12e2aceda3e6e6fba015608d51d"
 dependencies = [
  "aws-smithy-types",
  "urlencoding",
@@ -469,9 +469,9 @@ dependencies = [
 
 [[package]]
 name = "aws-smithy-types"
-version = "0.55.2"
+version = "0.55.3"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "58db46fc1f4f26be01ebdb821751b4e2482cd43aa2b64a0348fb89762defaffa"
+checksum = "16a3d0bf4f324f4ef9793b86a1701d9700fbcdbd12a846da45eed104c634c6e8"
 dependencies = [
  "base64-simd",
  "itoa",
@@ -482,18 +482,18 @@ dependencies = [
 
 [[package]]
 name = "aws-smithy-xml"
-version = "0.55.2"
+version = "0.55.3"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "fb557fe4995bd9ec87fb244bbb254666a971dc902a783e9da8b7711610e9664c"
+checksum = "b1b9d12875731bd07e767be7baad95700c3137b56730ec9ddeedb52a5e5ca63b"
 dependencies = [
  "xmlparser",
 ]
 
 [[package]]
 name = "aws-types"
-version = "0.55.2"
+version = "0.55.3"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "de0869598bfe46ec44ffe17e063ed33336e59df90356ca8ff0e8da6f7c1d994b"
+checksum = "6dd209616cc8d7bfb82f87811a5c655dc97537f592689b18743bddf5dc5c4829"
 dependencies = [
  "aws-credential-types",
  "aws-smithy-async",
@@ -925,7 +925,7 @@ dependencies = [
 [[package]]
 name = "collab"
 version = "0.1.0"
-source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=e9a50f#e9a50f780fa97b1e3fce6ffe7720e9585d674974"
+source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=aac4e56#aac4e56bdb6a61598697283582d369a7af85d9c8"
 dependencies = [
  "anyhow",
  "bytes",
@@ -943,7 +943,7 @@ dependencies = [
 [[package]]
 name = "collab-client-ws"
 version = "0.1.0"
-source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=e9a50f#e9a50f780fa97b1e3fce6ffe7720e9585d674974"
+source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=aac4e56#aac4e56bdb6a61598697283582d369a7af85d9c8"
 dependencies = [
  "bytes",
  "collab-sync",
@@ -961,7 +961,7 @@ dependencies = [
 [[package]]
 name = "collab-database"
 version = "0.1.0"
-source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=e9a50f#e9a50f780fa97b1e3fce6ffe7720e9585d674974"
+source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=aac4e56#aac4e56bdb6a61598697283582d369a7af85d9c8"
 dependencies = [
  "anyhow",
  "async-trait",
@@ -988,7 +988,7 @@ dependencies = [
 [[package]]
 name = "collab-derive"
 version = "0.1.0"
-source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=e9a50f#e9a50f780fa97b1e3fce6ffe7720e9585d674974"
+source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=aac4e56#aac4e56bdb6a61598697283582d369a7af85d9c8"
 dependencies = [
  "proc-macro2",
  "quote",
@@ -1000,7 +1000,7 @@ dependencies = [
 [[package]]
 name = "collab-document"
 version = "0.1.0"
-source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=e9a50f#e9a50f780fa97b1e3fce6ffe7720e9585d674974"
+source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=aac4e56#aac4e56bdb6a61598697283582d369a7af85d9c8"
 dependencies = [
  "anyhow",
  "collab",
@@ -1019,7 +1019,7 @@ dependencies = [
 [[package]]
 name = "collab-folder"
 version = "0.1.0"
-source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=e9a50f#e9a50f780fa97b1e3fce6ffe7720e9585d674974"
+source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=aac4e56#aac4e56bdb6a61598697283582d369a7af85d9c8"
 dependencies = [
  "anyhow",
  "chrono",
@@ -1039,7 +1039,7 @@ dependencies = [
 [[package]]
 name = "collab-persistence"
 version = "0.1.0"
-source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=e9a50f#e9a50f780fa97b1e3fce6ffe7720e9585d674974"
+source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=aac4e56#aac4e56bdb6a61598697283582d369a7af85d9c8"
 dependencies = [
  "bincode",
  "chrono",
@@ -1059,7 +1059,7 @@ dependencies = [
 [[package]]
 name = "collab-plugins"
 version = "0.1.0"
-source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=e9a50f#e9a50f780fa97b1e3fce6ffe7720e9585d674974"
+source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=aac4e56#aac4e56bdb6a61598697283582d369a7af85d9c8"
 dependencies = [
  "anyhow",
  "async-trait",
@@ -1089,7 +1089,7 @@ dependencies = [
 [[package]]
 name = "collab-sync"
 version = "0.1.0"
-source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=e9a50f#e9a50f780fa97b1e3fce6ffe7720e9585d674974"
+source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=aac4e56#aac4e56bdb6a61598697283582d369a7af85d9c8"
 dependencies = [
  "bytes",
  "collab",
@@ -3450,9 +3450,9 @@ dependencies = [
 
 [[package]]
 name = "postgrest"
-version = "1.5.0"
+version = "1.6.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e66400cb23a379592bc8c8bdc9adda652eef4a969b74ab78454a8e8c11330c2b"
+checksum = "5a966c650b47a064e7082170b4be74fca08c088d893244fc4b70123e3c1f3ee7"
 dependencies = [
  "reqwest",
 ]
@@ -4108,9 +4108,9 @@ dependencies = [
 
 [[package]]
 name = "rustls-native-certs"
-version = "0.6.2"
+version = "0.6.3"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0167bac7a9f490495f3c33013e7722b53cb087ecbe082fb0c6387c96f634ea50"
+checksum = "a9aace74cb666635c918e9c12bc0d348266037aa8eb599b5cba565709a8dff00"
 dependencies = [
  "openssl-probe",
  "rustls-pemfile",
@@ -4229,15 +4229,15 @@ dependencies = [
 
 [[package]]
 name = "semver"
-version = "1.0.17"
+version = "1.0.18"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "bebd363326d05ec3e2f532ab7660680f3b02130d780c299bca73469d521bc0ed"
+checksum = "b0293b4b29daaf487284529cc2f5675b8e57c61f70167ba415a463651fd6a918"
 
 [[package]]
 name = "serde"
-version = "1.0.178"
+version = "1.0.175"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "60363bdd39a7be0266a520dab25fdc9241d2f987b08a01e01f0ec6d06a981348"
+checksum = "5d25439cd7397d044e2748a6fe2432b5e85db703d6d097bd014b3c0ad1ebff0b"
 dependencies = [
  "serde_derive",
 ]
@@ -4255,9 +4255,9 @@ dependencies = [
 
 [[package]]
 name = "serde_derive"
-version = "1.0.178"
+version = "1.0.175"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f28482318d6641454cb273da158647922d1be6b5a2fcc6165cd89ebdd7ed576b"
+checksum = "b23f7ade6f110613c0d63858ddb8b94c1041f550eab58a16b371bdf2c9c80ab4"
 dependencies = [
  "proc-macro2",
  "quote",
@@ -5163,9 +5163,9 @@ dependencies = [
 
 [[package]]
 name = "urlencoding"
-version = "2.1.2"
+version = "2.1.3"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e8db7427f936968176eaa7cdf81b7f98b980b18495ec28f1b5791ac3bfe3eea9"
+checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da"
 
 [[package]]
 name = "utf-8"

+ 13 - 13
frontend/rust-lib/Cargo.toml

@@ -38,17 +38,17 @@ opt-level = 3
 incremental = false
 
 [patch.crates-io]
-collab = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "e9a50f" }
-collab-folder = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "e9a50f" }
-collab-document = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "e9a50f" }
-collab-database = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "e9a50f" }
-appflowy-integrate = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "e9a50f" }
-collab-plugins = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "e9a50f" }
-
-#collab = { path = "../AppFlowy-Collab/collab" }
-#collab-folder = { path = "../AppFlowy-Collab/collab-folder" }
-#collab-database= { path = "../AppFlowy-Collab/collab-database" }
-#collab-document = { path = "../AppFlowy-Collab/collab-document" }
-#collab-plugins = { path = "../AppFlowy-Collab/collab-plugins" }
-#appflowy-integrate = { path = "../AppFlowy-Collab/appflowy-integrate" }
+collab = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "aac4e56" }
+collab-folder = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "aac4e56" }
+collab-document = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "aac4e56" }
+collab-database = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "aac4e56" }
+appflowy-integrate = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "aac4e56" }
+collab-plugins = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "aac4e56" }
+#
+#collab = { path = "../../../AppFlowy-Collab/collab" }
+#collab-folder = { path = "../../../AppFlowy-Collab/collab-folder" }
+#collab-database= { path = "../../../AppFlowy-Collab/collab-database" }
+#collab-document = { path = "../../../AppFlowy-Collab/collab-document" }
+#collab-plugins = { path = "../../../AppFlowy-Collab/collab-plugins" }
+#appflowy-integrate = { path = "../../../AppFlowy-Collab/appflowy-integrate" }
 

+ 85 - 0
frontend/rust-lib/flowy-folder2/src/entities/icon.rs

@@ -0,0 +1,85 @@
+use crate::entities::parser::view::ViewIdentify;
+use collab_folder::core::{IconType, ViewIcon};
+use flowy_derive::{ProtoBuf, ProtoBuf_Enum};
+use flowy_error::ErrorCode;
+
+#[derive(ProtoBuf_Enum, Clone, Debug, PartialEq, Eq, Default)]
+pub enum ViewIconTypePB {
+  #[default]
+  Emoji = 0,
+  Url = 1,
+  Icon = 2,
+}
+
+impl std::convert::From<ViewIconTypePB> for IconType {
+  fn from(rev: ViewIconTypePB) -> Self {
+    match rev {
+      ViewIconTypePB::Emoji => IconType::Emoji,
+      ViewIconTypePB::Url => IconType::Url,
+      ViewIconTypePB::Icon => IconType::Icon,
+    }
+  }
+}
+
+impl Into<ViewIconTypePB> for IconType {
+  fn into(self) -> ViewIconTypePB {
+    match self {
+      IconType::Emoji => ViewIconTypePB::Emoji,
+      IconType::Url => ViewIconTypePB::Url,
+      IconType::Icon => ViewIconTypePB::Icon,
+    }
+  }
+}
+
+#[derive(Default, ProtoBuf, Debug, Clone, PartialEq, Eq)]
+pub struct ViewIconPB {
+  #[pb(index = 1)]
+  pub ty: ViewIconTypePB,
+  #[pb(index = 2)]
+  pub value: String,
+}
+
+impl std::convert::From<ViewIconPB> for ViewIcon {
+  fn from(rev: ViewIconPB) -> Self {
+    ViewIcon {
+      ty: rev.ty.into(),
+      value: rev.value,
+    }
+  }
+}
+
+impl Into<ViewIconPB> for ViewIcon {
+  fn into(self) -> ViewIconPB {
+    ViewIconPB {
+      ty: self.ty.into(),
+      value: self.value,
+    }
+  }
+}
+
+#[derive(Default, ProtoBuf)]
+pub struct UpdateViewIconPayloadPB {
+  #[pb(index = 1)]
+  pub view_id: String,
+
+  #[pb(index = 2, one_of)]
+  pub icon: Option<ViewIconPB>,
+}
+
+#[derive(Clone, Debug)]
+pub struct UpdateViewIconParams {
+  pub view_id: String,
+  pub icon: Option<ViewIcon>,
+}
+
+impl TryInto<UpdateViewIconParams> for UpdateViewIconPayloadPB {
+  type Error = ErrorCode;
+
+  fn try_into(self) -> Result<UpdateViewIconParams, Self::Error> {
+    let view_id = ViewIdentify::parse(self.view_id)?.0;
+
+    let icon = self.icon.map(|icon| icon.into());
+
+    Ok(UpdateViewIconParams { view_id, icon })
+  }
+}

+ 2 - 0
frontend/rust-lib/flowy-folder2/src/entities/mod.rs

@@ -1,9 +1,11 @@
+pub mod icon;
 mod import;
 mod parser;
 pub mod trash;
 pub mod view;
 pub mod workspace;
 
+pub use icon::*;
 pub use import::*;
 pub use trash::*;
 pub use view::*;

+ 0 - 4
frontend/rust-lib/flowy-folder2/src/entities/parser/view/view_name.rs

@@ -6,10 +6,6 @@ pub struct ViewName(pub String);
 
 impl ViewName {
   pub fn parse(s: String) -> Result<ViewName, ErrorCode> {
-    if s.trim().is_empty() {
-      return Err(ErrorCode::ViewNameInvalid);
-    }
-
     if s.graphemes(true).count() > 256 {
       return Err(ErrorCode::ViewNameTooLong);
     }

+ 9 - 27
frontend/rust-lib/flowy-folder2/src/entities/view.rs

@@ -5,6 +5,7 @@ use std::sync::Arc;
 
 use collab_folder::core::{View, ViewLayout};
 
+use crate::entities::icon::ViewIconPB;
 use flowy_derive::{ProtoBuf, ProtoBuf_Enum};
 use flowy_error::ErrorCode;
 
@@ -50,16 +51,11 @@ pub struct ViewPB {
   #[pb(index = 6)]
   pub layout: ViewLayoutPB,
 
-  /// The icon url of the view.
-  /// It can be used to save the emoji icon of the view.
+  /// The icon of the view.
   #[pb(index = 7, one_of)]
-  pub icon_url: Option<String>,
+  pub icon: Option<ViewIconPB>,
 
-  /// The cover url of the view.
-  #[pb(index = 8, one_of)]
-  pub cover_url: Option<String>,
-
-  #[pb(index = 9)]
+  #[pb(index = 8)]
   pub is_favorite: bool,
 }
 
@@ -71,9 +67,8 @@ pub fn view_pb_without_child_views(view: Arc<View>) -> ViewPB {
     create_time: view.created_at,
     child_views: Default::default(),
     layout: view.layout.clone().into(),
-    icon_url: view.icon_url.clone(),
-    cover_url: view.cover_url.clone(),
-    is_favorite: view.is_favorite.clone(),
+    icon: view.icon.clone().map(|icon| icon.into()),
+    is_favorite: view.is_favorite,
   }
 }
 
@@ -89,9 +84,8 @@ pub fn view_pb_with_child_views(view: Arc<View>, child_views: Vec<Arc<View>>) ->
       .map(view_pb_without_child_views)
       .collect(),
     layout: view.layout.clone().into(),
-    icon_url: view.icon_url.clone(),
-    cover_url: view.cover_url.clone(),
-    is_favorite: view.is_favorite.clone(),
+    icon: view.icon.clone().map(|icon| icon.into()),
+    is_favorite: view.is_favorite,
   }
 }
 
@@ -318,12 +312,6 @@ pub struct UpdateViewPayloadPB {
   pub layout: Option<ViewLayoutPB>,
 
   #[pb(index = 6, one_of)]
-  pub icon_url: Option<String>,
-
-  #[pb(index = 7, one_of)]
-  pub cover_url: Option<String>,
-
-  #[pb(index = 8, one_of)]
   pub is_favorite: Option<bool>,
 }
 
@@ -333,10 +321,8 @@ pub struct UpdateViewParams {
   pub name: Option<String>,
   pub desc: Option<String>,
   pub thumbnail: Option<String>,
-  pub icon_url: Option<String>,
-  pub cover_url: Option<String>,
-  pub is_favorite: Option<bool>,
   pub layout: Option<ViewLayout>,
+  pub is_favorite: Option<bool>,
 }
 
 impl TryInto<UpdateViewParams> for UpdateViewPayloadPB {
@@ -360,8 +346,6 @@ impl TryInto<UpdateViewParams> for UpdateViewPayloadPB {
       Some(thumbnail) => Some(ViewThumbnail::parse(thumbnail)?.0),
     };
 
-    let cover_url = self.cover_url;
-    let icon_url = self.icon_url;
     let is_favorite = self.is_favorite;
 
     Ok(UpdateViewParams {
@@ -369,8 +353,6 @@ impl TryInto<UpdateViewParams> for UpdateViewPayloadPB {
       name,
       desc,
       thumbnail,
-      cover_url,
-      icon_url,
       is_favorite,
       layout: self.layout.map(|ty| ty.into()),
     })

+ 12 - 1
frontend/rust-lib/flowy-folder2/src/event_handler.rs

@@ -139,6 +139,17 @@ pub(crate) async fn update_view_handler(
   Ok(())
 }
 
+#[tracing::instrument(level = "debug", skip(data, folder), err)]
+pub(crate) async fn update_view_icon_handler(
+  data: AFPluginData<UpdateViewIconPayloadPB>,
+  folder: AFPluginState<Weak<FolderManager>>,
+) -> Result<(), FlowyError> {
+  let folder = upgrade_folder(folder)?;
+  let params: UpdateViewIconParams = data.into_inner().try_into()?;
+  folder.update_view_icon_with_params(params).await?;
+  Ok(())
+}
+
 pub(crate) async fn delete_view_handler(
   data: AFPluginData<RepeatedViewIdPB>,
   folder: AFPluginState<Weak<FolderManager>>,
@@ -233,7 +244,7 @@ pub(crate) async fn read_favorites_handler(
         views.push(view);
       },
       Err(err) => {
-        return Err(err.into());
+        return Err(err);
       },
     }
   }

+ 4 - 0
frontend/rust-lib/flowy-folder2/src/event_map.rs

@@ -38,6 +38,7 @@ pub fn init(folder: Weak<FolderManager>) -> AFPlugin {
     .event(FolderEvent::DeleteAllTrash, delete_all_trash_handler)
     .event(FolderEvent::ImportData, import_data_handler)
       .event(FolderEvent::GetFolderSnapshots, get_folder_snapshots_handler)
+      .event(FolderEvent::UpdateViewIcon, update_view_icon_handler)
     .event(FolderEvent::ReadFavorites, read_favorites_handler)
     .event(FolderEvent::ToggleFavorite, toggle_favorites_handler)
 }
@@ -149,4 +150,7 @@ pub enum FolderEvent {
 
   #[event(input = "RepeatedViewIdPB")]
   ToggleFavorite = 34,
+
+  #[event(input = "UpdateViewIconPayloadPB")]
+  UpdateViewIcon = 35,
 }

+ 45 - 23
frontend/rust-lib/flowy-folder2/src/manager.rs

@@ -8,7 +8,7 @@ use collab::core::collab::{CollabRawData, MutexCollab};
 use collab::core::collab_state::SyncState;
 use collab_folder::core::{
   FavoritesInfo, Folder, FolderData, FolderNotify, TrashChange, TrashChangeReceiver, TrashInfo,
-  View, ViewChange, ViewChangeReceiver, ViewLayout, Workspace,
+  View, ViewChange, ViewChangeReceiver, ViewLayout, ViewUpdate, Workspace,
 };
 use parking_lot::Mutex;
 use tokio_stream::wrappers::WatchStream;
@@ -18,6 +18,7 @@ use tracing::{event, Level};
 use flowy_error::{ErrorCode, FlowyError, FlowyResult};
 use flowy_folder_deps::cloud::FolderCloudService;
 
+use crate::entities::icon::UpdateViewIconParams;
 use crate::entities::{
   view_pb_with_child_views, view_pb_without_child_views, ChildViewUpdatePB, CreateViewParams,
   CreateWorkspaceParams, DeletedViewPB, FolderSnapshotPB, FolderSnapshotStatePB, FolderSyncStatePB,
@@ -448,7 +449,7 @@ impl FolderManager {
   pub async fn move_view_to_trash(&self, view_id: &str) -> FlowyResult<()> {
     self.with_folder((), |folder| {
       if let Some(view) = folder.views.get_view(view_id) {
-        self.unfavorite_view_and_decendants(view.clone(), &folder);
+        self.unfavorite_view_and_decendants(view.clone(), folder);
         folder.add_trash(vec![view_id.to_string()]);
         // notify the parent view that the view is moved to trash
         send_notification(view_id, FolderNotification::DidMoveViewToTrash)
@@ -587,34 +588,29 @@ impl FolderManager {
   /// Update the view with the given params.
   #[tracing::instrument(level = "trace", skip(self), err)]
   pub async fn update_view_with_params(&self, params: UpdateViewParams) -> FlowyResult<()> {
-    let value = self.with_folder(None, |folder| {
-      let old_view = folder.views.get_view(&params.view_id);
-      let new_view = folder.views.update_view(&params.view_id, |update| {
+    self
+      .update_view(&params.view_id, |update| {
         update
           .set_name_if_not_none(params.name)
           .set_desc_if_not_none(params.desc)
           .set_layout_if_not_none(params.layout)
-          .set_icon_url_if_not_none(params.icon_url)
-          .set_cover_url_if_not_none(params.cover_url)
           .set_favorite_if_not_none(params.is_favorite)
           .done()
-      });
-
-      Some((old_view, new_view))
-    });
-
-    if let Some((Some(old_view), Some(new_view))) = value {
-      if let Ok(handler) = self.get_handler(&old_view.layout) {
-        handler.did_update_view(&old_view, &new_view).await?;
-      }
-    }
+      })
+      .await
+  }
 
-    if let Ok(view_pb) = self.get_view(&params.view_id).await {
-      send_notification(&view_pb.id, FolderNotification::DidUpdateView)
-        .payload(view_pb)
-        .send();
-    }
-    Ok(())
+  /// Update the icon of the view with the given params.
+  #[tracing::instrument(level = "trace", skip(self), err)]
+  pub async fn update_view_icon_with_params(
+    &self,
+    params: UpdateViewIconParams,
+  ) -> FlowyResult<()> {
+    self
+      .update_view(&params.view_id, |update| {
+        update.set_icon(params.icon).done()
+      })
+      .await
   }
 
   /// Duplicate the view with the given view id.
@@ -815,6 +811,32 @@ impl FolderManager {
     Ok(view)
   }
 
+  /// Update the view with the provided view_id using the specified function.
+  async fn update_view<F>(&self, view_id: &str, f: F) -> FlowyResult<()>
+  where
+    F: FnOnce(ViewUpdate) -> Option<View>,
+  {
+    let value = self.with_folder(None, |folder| {
+      let old_view = folder.views.get_view(view_id);
+      let new_view = folder.views.update_view(view_id, f);
+
+      Some((old_view, new_view))
+    });
+
+    if let Some((Some(old_view), Some(new_view))) = value {
+      if let Ok(handler) = self.get_handler(&old_view.layout) {
+        handler.did_update_view(&old_view, &new_view).await?;
+      }
+    }
+
+    if let Ok(view_pb) = self.get_view(view_id).await {
+      send_notification(&view_pb.id, FolderNotification::DidUpdateView)
+        .payload(view_pb)
+        .send();
+    }
+    Ok(())
+  }
+
   /// Returns a handler that implements the [FolderOperationHandler] trait
   fn get_handler(
     &self,

+ 5 - 9
frontend/rust-lib/flowy-folder2/src/view_operation.rs

@@ -4,7 +4,7 @@ use std::sync::Arc;
 
 use bytes::Bytes;
 pub use collab_folder::core::View;
-use collab_folder::core::{RepeatedViewIdentifier, ViewIdentifier, ViewLayout};
+use collab_folder::core::{RepeatedViewIdentifier, ViewIcon, ViewIdentifier, ViewLayout};
 use tokio::sync::RwLock;
 
 use flowy_error::FlowyError;
@@ -55,8 +55,7 @@ pub struct ViewBuilder {
   layout: ViewLayout,
   child_views: Vec<ParentChildViews>,
   is_favorite: bool,
-  icon_url: Option<String>,
-  cover_url: Option<String>,
+  icon: Option<ViewIcon>,
 }
 
 impl ViewBuilder {
@@ -69,8 +68,7 @@ impl ViewBuilder {
       layout: ViewLayout::Document,
       child_views: vec![],
       is_favorite: false,
-      icon_url: None,
-      cover_url: None,
+      icon: None,
     }
   }
 
@@ -114,8 +112,7 @@ impl ViewBuilder {
       created_at: timestamp(),
       is_favorite: self.is_favorite,
       layout: self.layout,
-      icon_url: self.icon_url,
-      cover_url: self.cover_url,
+      icon: self.icon,
       children: RepeatedViewIdentifier::new(
         self
           .child_views
@@ -257,8 +254,7 @@ pub(crate) fn create_view(params: CreateViewParams, layout: ViewLayout) -> View
     created_at: time,
     is_favorite: false,
     layout,
-    cover_url: None,
-    icon_url: None,
+    icon: None,
   }
 }
 

+ 32 - 5
frontend/rust-lib/flowy-folder2/tests/workspace/folder_test.rs

@@ -1,5 +1,6 @@
 use crate::script::{FolderScript::*, FolderTest};
 use collab_folder::core::ViewLayout;
+use flowy_folder2::entities::icon::{ViewIconPB, ViewIconTypePB};
 
 #[tokio::test]
 async fn read_all_workspace_test() {
@@ -152,6 +153,32 @@ async fn view_update() {
   assert_eq!(test.child_view.name, new_name);
 }
 
+#[tokio::test]
+async fn view_icon_update_test() {
+  let mut test = FolderTest::new().await;
+  let view = test.child_view.clone();
+  let new_icon = ViewIconPB {
+    ty: ViewIconTypePB::Emoji,
+    value: "👍".to_owned(),
+  };
+  assert!(view.icon.is_none());
+  test
+    .run_scripts(vec![
+      UpdateViewIcon {
+        icon: Some(new_icon.clone()),
+      },
+      ReadView(view.id.clone()),
+    ])
+    .await;
+
+  assert_eq!(test.child_view.icon, Some(new_icon));
+
+  test
+    .run_scripts(vec![UpdateViewIcon { icon: None }, ReadView(view.id)])
+    .await;
+  assert_eq!(test.child_view.icon, None);
+}
+
 #[tokio::test]
 #[should_panic]
 async fn view_delete() {
@@ -263,8 +290,8 @@ async fn toggle_favorites() {
       ReadView(view.id.clone()),
     ])
     .await;
-  assert_eq!(test.child_view.is_favorite, true);
-  assert!(test.favorites.len() != 0);
+  assert!(test.child_view.is_favorite);
+  assert_ne!(test.favorites.len(), 0);
   assert_eq!(test.favorites[0].id, view.id);
 
   let view = test.child_view.clone();
@@ -293,12 +320,12 @@ async fn delete_favorites() {
       ReadView(view.id.clone()),
     ])
     .await;
-  assert_eq!(test.child_view.is_favorite, true);
-  assert!(test.favorites.len() != 0);
+  assert!(test.child_view.is_favorite);
+  assert_ne!(test.favorites.len(), 0);
   assert_eq!(test.favorites[0].id, view.id);
 
   test.run_scripts(vec![DeleteView, ReadFavorites]).await;
-  assert!(test.favorites.len() == 0);
+  assert_eq!(test.favorites.len(), 0);
 }
 
 #[tokio::test]

+ 19 - 0
frontend/rust-lib/flowy-folder2/tests/workspace/script.rs

@@ -1,5 +1,6 @@
 use collab_folder::core::ViewLayout;
 
+use flowy_folder2::entities::icon::{UpdateViewIconPayloadPB, ViewIconPB};
 use flowy_folder2::entities::*;
 use flowy_folder2::event_map::FolderEvent::*;
 use flowy_test::event_builder::EventBuilder;
@@ -42,6 +43,9 @@ pub enum FolderScript {
     desc: Option<String>,
     is_favorite: Option<bool>,
   },
+  UpdateViewIcon {
+    icon: Option<ViewIconPB>,
+  },
   DeleteView,
   DeleteViews(Vec<String>),
   MoveView {
@@ -164,6 +168,9 @@ impl FolderTest {
       } => {
         update_view(sdk, &self.child_view.id, name, desc, is_favorite).await;
       },
+      FolderScript::UpdateViewIcon { icon } => {
+        update_view_icon(sdk, &self.child_view.id, icon).await;
+      },
       FolderScript::DeleteView => {
         delete_view(sdk, vec![self.child_view.id.clone()]).await;
       },
@@ -333,6 +340,18 @@ pub async fn update_view(
     .await;
 }
 
+pub async fn update_view_icon(sdk: &FlowyCoreTest, view_id: &str, icon: Option<ViewIconPB>) {
+  let request = UpdateViewIconPayloadPB {
+    view_id: view_id.to_string(),
+    icon,
+  };
+  EventBuilder::new(sdk.clone())
+    .event(UpdateViewIcon)
+    .payload(request)
+    .async_send()
+    .await;
+}
+
 pub async fn delete_view(sdk: &FlowyCoreTest, view_ids: Vec<String>) {
   let request = RepeatedViewIdPB { items: view_ids };
   EventBuilder::new(sdk.clone())

+ 11 - 1
frontend/rust-lib/flowy-test/src/lib.rs

@@ -16,6 +16,7 @@ use flowy_database2::entities::*;
 use flowy_database2::event_map::DatabaseEvent;
 use flowy_document2::entities::{DocumentDataPB, OpenDocumentPayloadPB};
 use flowy_document2::event_map::DocumentEvent;
+use flowy_folder2::entities::icon::UpdateViewIconPayloadPB;
 use flowy_folder2::entities::*;
 use flowy_folder2::event_map::FolderEvent;
 use flowy_notification::entities::SubscribeObject;
@@ -184,6 +185,15 @@ impl FlowyCoreTest {
       .error()
   }
 
+  pub async fn update_view_icon(&self, payload: UpdateViewIconPayloadPB) -> Option<FlowyError> {
+    EventBuilder::new(self.clone())
+      .event(FolderEvent::UpdateViewIcon)
+      .payload(payload)
+      .async_send()
+      .await
+      .error()
+  }
+
   pub async fn create_view(&self, parent_id: &str, name: String) -> ViewPB {
     let payload = CreateViewPayloadPB {
       parent_view_id: parent_id.to_string(),
@@ -797,7 +807,7 @@ impl Cleaner {
     Cleaner(dir)
   }
 
-  fn cleanup(dir: &PathBuf) {
+  fn cleanup(_dir: &PathBuf) {
     // let _ = std::fs::remove_dir_all(dir);
   }
 }

+ 9 - 26
frontend/rust-lib/flowy-test/tests/folder/local_test/test.rs

@@ -1,3 +1,4 @@
+use flowy_folder2::entities::icon::{UpdateViewIconPayloadPB, ViewIconPB, ViewIconTypePB};
 use flowy_folder2::entities::*;
 use flowy_test::event_builder::EventBuilder;
 use flowy_test::FlowyCoreTest;
@@ -83,45 +84,27 @@ async fn update_view_event_with_name_test() {
 }
 
 #[tokio::test]
-async fn update_view_event_with_icon_url_test() {
-  let test = FlowyCoreTest::new_with_guest_user().await;
-  let current_workspace = test.get_current_workspace().await.workspace;
-  let view = test
-    .create_view(&current_workspace.id, "My first view".to_string())
-    .await;
-
-  let error = test
-    .update_view(UpdateViewPayloadPB {
-      view_id: view.id.clone(),
-      icon_url: Some("appflowy.io".to_string()),
-      ..Default::default()
-    })
-    .await;
-  assert!(error.is_none());
-
-  let view = test.get_view(&view.id).await;
-  assert_eq!(view.icon_url.unwrap(), "appflowy.io");
-}
-
-#[tokio::test]
-async fn update_view_event_with_cover_url_test() {
+async fn update_view_icon_event_test() {
   let test = FlowyCoreTest::new_with_guest_user().await;
   let current_workspace = test.get_current_workspace().await.workspace;
   let view = test
     .create_view(&current_workspace.id, "My first view".to_string())
     .await;
 
+  let new_icon = ViewIconPB {
+    ty: ViewIconTypePB::Emoji,
+    value: "👍".to_owned(),
+  };
   let error = test
-    .update_view(UpdateViewPayloadPB {
+    .update_view_icon(UpdateViewIconPayloadPB {
       view_id: view.id.clone(),
-      cover_url: Some("appflowy.io".to_string()),
-      ..Default::default()
+      icon: Some(new_icon.clone()),
     })
     .await;
   assert!(error.is_none());
 
   let view = test.get_view(&view.id).await;
-  assert_eq!(view.cover_url.unwrap(), "appflowy.io");
+  assert_eq!(view.icon, Some(new_icon));
 }
 
 #[tokio::test]

+ 4 - 5
frontend/rust-lib/flowy-user/src/migrations/migration.rs

@@ -42,19 +42,18 @@ impl UserLocalDataMigration {
   pub fn run(self, migrations: Vec<Box<dyn UserDataMigration>>) -> FlowyResult<Vec<String>> {
     let mut applied_migrations = vec![];
     let conn = self.sqlite_pool.get()?;
-    let record = get_all_records(&*conn)?;
+    let record = get_all_records(&conn)?;
     let mut duplicated_names = vec![];
     for migration in migrations {
-      if record
+      if !record
         .iter()
-        .find(|record| record.migration_name == migration.name())
-        .is_none()
+        .any(|record| record.migration_name == migration.name())
       {
         let migration_name = migration.name().to_string();
         if !duplicated_names.contains(&migration_name) {
           migration.run(&self.session, &self.collab_db)?;
           applied_migrations.push(migration.name().to_string());
-          save_record(&*conn, &migration_name);
+          save_record(&conn, &migration_name);
           duplicated_names.push(migration_name);
         } else {
           tracing::error!("Duplicated migration name: {}", migration_name);

+ 1 - 1
frontend/rust-lib/flowy-user/src/services/user_session.rs

@@ -86,7 +86,7 @@ impl UserSession {
             .run(vec![Box::new(HistoricalEmptyDocumentMigration)])
           {
             Ok(applied_migrations) => {
-              if applied_migrations.len() > 0 {
+              if !applied_migrations.is_empty() {
                 tracing::info!("Did apply migrations: {:?}", applied_migrations);
               }
             },