Bläddra i källkod

feat: support Inline page reference #2196 (#2898)

Muhammad Rizwan 1 år sedan
förälder
incheckning
30b52a29fd

+ 17 - 16
frontend/appflowy_flutter/integration_test/document/document_with_database_test.dart

@@ -1,4 +1,5 @@
 import 'package:appflowy/plugins/database_view/board/presentation/board_page.dart';
 import 'package:appflowy/plugins/database_view/board/presentation/board_page.dart';
+import 'package:appflowy/plugins/database_view/calendar/presentation/calendar_page.dart';
 import 'package:appflowy/plugins/database_view/grid/presentation/grid_page.dart';
 import 'package:appflowy/plugins/database_view/grid/presentation/grid_page.dart';
 import 'package:appflowy/plugins/document/presentation/editor_plugins/base/link_to_page_widget.dart';
 import 'package:appflowy/plugins/document/presentation/editor_plugins/base/link_to_page_widget.dart';
 import 'package:appflowy_backend/protobuf/flowy-folder2/protobuf.dart';
 import 'package:appflowy_backend/protobuf/flowy-folder2/protobuf.dart';
@@ -56,21 +57,21 @@ void main() {
       );
       );
     });
     });
 
 
-    // testWidgets('insert a referenced calendar', (tester) async {
-    //   await tester.initializeAppFlowy();
-    //   await tester.tapGoButton();
-
-    //   await insertReferenceDatabase(tester, ViewLayoutPB.Calendar);
-
-    //   // validate the referenced grid is inserted
-    //   expect(
-    //     find.descendant(
-    //       of: find.byType(AppFlowyEditor),
-    //       matching: find.byType(CalendarPage),
-    //     ),
-    //     findsOneWidget,
-    //   );
-    // });
+    testWidgets('insert a referenced calendar', (tester) async {
+      await tester.initializeAppFlowy();
+      await tester.tapGoButton();
+
+      await insertReferenceDatabase(tester, ViewLayoutPB.Calendar);
+
+      // validate the referenced grid is inserted
+      expect(
+        find.descendant(
+          of: find.byType(AppFlowyEditor),
+          matching: find.byType(CalendarPage),
+        ),
+        findsOneWidget,
+      );
+    });
   });
   });
 }
 }
 
 
@@ -93,7 +94,7 @@ Future<void> insertReferenceDatabase(
   );
   );
   // tap the first line of the document
   // tap the first line of the document
   await tester.editor.tapLineOfEditorAt(0);
   await tester.editor.tapLineOfEditorAt(0);
-  // insert a referenced grid
+  // insert a referenced view
   await tester.editor.showSlashMenu();
   await tester.editor.showSlashMenu();
   await tester.editor.tapSlashMenuItemWithName(
   await tester.editor.tapSlashMenuItemWithName(
     layout.referencedMenuName,
     layout.referencedMenuName,

+ 129 - 0
frontend/appflowy_flutter/integration_test/document/document_with_inline_page_test.dart

@@ -0,0 +1,129 @@
+import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mention_page_block.dart';
+import 'package:appflowy_backend/protobuf/flowy-folder2/protobuf.dart';
+import 'package:flowy_infra/uuid.dart';
+import 'package:flowy_infra_ui/widget/error_page.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:integration_test/integration_test.dart';
+
+import '../util/util.dart';
+
+void main() {
+  IntegrationTestWidgetsFlutterBinding.ensureInitialized();
+
+  group('inline page view in document', () {
+    const location = 'inline_page';
+
+    setUp(() async {
+      await TestFolder.cleanTestLocation(location);
+      await TestFolder.setTestLocation(location);
+    });
+
+    tearDown(() async {
+      await TestFolder.cleanTestLocation(null);
+    });
+
+    testWidgets('insert a inline page - grid', (tester) async {
+      await tester.initializeAppFlowy();
+      await tester.tapGoButton();
+
+      await insertingInlinePage(tester, ViewLayoutPB.Grid);
+
+      final mentionBlock = find.byType(MentionPageBlock);
+      expect(mentionBlock, findsOneWidget);
+      await tester.tapButton(mentionBlock);
+    });
+
+    testWidgets('insert a inline page - board', (tester) async {
+      await tester.initializeAppFlowy();
+      await tester.tapGoButton();
+
+      await insertingInlinePage(tester, ViewLayoutPB.Board);
+
+      final mentionBlock = find.byType(MentionPageBlock);
+      expect(mentionBlock, findsOneWidget);
+      await tester.tapButton(mentionBlock);
+    });
+
+    testWidgets('insert a inline page - calendar', (tester) async {
+      await tester.initializeAppFlowy();
+      await tester.tapGoButton();
+
+      await insertingInlinePage(tester, ViewLayoutPB.Calendar);
+
+      final mentionBlock = find.byType(MentionPageBlock);
+      expect(mentionBlock, findsOneWidget);
+      await tester.tapButton(mentionBlock);
+    });
+
+    testWidgets('insert a inline page - document', (tester) async {
+      await tester.initializeAppFlowy();
+      await tester.tapGoButton();
+
+      await insertingInlinePage(tester, ViewLayoutPB.Document);
+
+      final mentionBlock = find.byType(MentionPageBlock);
+      expect(mentionBlock, findsOneWidget);
+      await tester.tapButton(mentionBlock);
+    });
+
+    testWidgets('insert a inline page and rename it', (tester) async {
+      await tester.initializeAppFlowy();
+      await tester.tapGoButton();
+
+      final pageName = await insertingInlinePage(tester, ViewLayoutPB.Document);
+
+      // rename
+      await tester.hoverOnPageName(pageName);
+      const newName = 'RenameToNewPageName';
+      await tester.renamePage(newName);
+      final finder = find.descendant(
+        of: find.byType(MentionPageBlock),
+        matching: find.findTextInFlowyText(newName),
+      );
+      expect(finder, findsOneWidget);
+    });
+
+    testWidgets('insert a inline page and delete it', (tester) async {
+      await tester.initializeAppFlowy();
+      await tester.tapGoButton();
+
+      final pageName = await insertingInlinePage(tester, ViewLayoutPB.Grid);
+
+      // rename
+      await tester.hoverOnPageName(pageName);
+      await tester.tapDeletePageButton();
+      final finder = find.descendant(
+        of: find.byType(MentionPageBlock),
+        matching: find.findTextInFlowyText(pageName),
+      );
+      expect(finder, findsOneWidget);
+      await tester.tapButton(finder);
+      expect(find.byType(FlowyErrorPage), findsOneWidget);
+    });
+  });
+}
+
+/// Insert a referenced database of [layout] into the document
+Future<String> insertingInlinePage(
+  WidgetTester tester,
+  ViewLayoutPB layout,
+) async {
+  // create a new grid
+  final id = uuid();
+  final name = '${layout.name}_$id';
+  await tester.createNewPageWithName(
+    layout,
+    name,
+  );
+  // create a new document
+  await tester.createNewPageWithName(
+    ViewLayoutPB.Document,
+    'insert_a_inline_page_${layout.name}',
+  );
+  // tap the first line of the document
+  await tester.editor.tapLineOfEditorAt(0);
+  // insert a inline page
+  await tester.editor.showAtMenu();
+  await tester.editor.tapAtMenuItemWithName(name);
+  return name;
+}

+ 17 - 0
frontend/appflowy_flutter/integration_test/util/editor_test_operations.dart

@@ -32,6 +32,7 @@ class EditorOperations {
   Future<void> tapLineOfEditorAt(int index) async {
   Future<void> tapLineOfEditorAt(int index) async {
     final textBlocks = find.byType(TextBlockComponentWidget);
     final textBlocks = find.byType(TextBlockComponentWidget);
     await tester.tapAt(tester.getTopRight(textBlocks.at(index)));
     await tester.tapAt(tester.getTopRight(textBlocks.at(index)));
+    await tester.pumpAndSettle();
   }
   }
 
 
   /// Hover on cover plugin button above the document
   /// Hover on cover plugin button above the document
@@ -114,6 +115,11 @@ class EditorOperations {
     await tester.ime.insertCharacter('/');
     await tester.ime.insertCharacter('/');
   }
   }
 
 
+  /// trigger the slash command (selection menu)
+  Future<void> showAtMenu() async {
+    await tester.ime.insertCharacter('@');
+  }
+
   /// Tap the slash menu item with [name]
   /// Tap the slash menu item with [name]
   ///
   ///
   /// Must call [showSlashMenu] first.
   /// Must call [showSlashMenu] first.
@@ -121,4 +127,15 @@ class EditorOperations {
     final slashMenuItem = find.text(name, findRichText: true);
     final slashMenuItem = find.text(name, findRichText: true);
     await tester.tapButton(slashMenuItem);
     await tester.tapButton(slashMenuItem);
   }
   }
+
+  /// Tap the at menu item with [name]
+  ///
+  /// Must call [showAtMenu] first.
+  Future<void> tapAtMenuItemWithName(String name) async {
+    final atMenuItem = find.descendant(
+      of: find.byType(SelectionMenuWidget),
+      matching: find.text(name, findRichText: true),
+    );
+    await tester.tapButton(atMenuItem);
+  }
 }
 }

+ 13 - 19
frontend/appflowy_flutter/integration_test/util/ime.dart

@@ -9,11 +9,11 @@ extension IME on WidgetTester {
 
 
 class IMESimulator {
 class IMESimulator {
   IMESimulator(this.tester) {
   IMESimulator(this.tester) {
-    client = findDeltaTextInputClient();
+    client = findTextInputClient();
   }
   }
 
 
   final WidgetTester tester;
   final WidgetTester tester;
-  late final DeltaTextInputClient client;
+  late final TextInputClient client;
 
 
   Future<void> insertText(String text) async {
   Future<void> insertText(String text) async {
     for (final c in text.characters) {
     for (final c in text.characters) {
@@ -27,28 +27,22 @@ class IMESimulator {
       assert(false);
       assert(false);
       return;
       return;
     }
     }
-    final deltas = [
-      TextEditingDeltaInsertion(
-        textInserted: character,
-        oldText: value.text.replaceRange(
-          value.selection.start,
-          value.selection.end,
-          '',
-        ),
-        insertionOffset: value.selection.baseOffset,
-        selection: TextSelection.collapsed(
-          offset: value.selection.baseOffset + 1,
-        ),
-        composing: TextRange.empty,
+    final text = value.text
+        .replaceRange(value.selection.start, value.selection.end, character);
+    final textEditingValue = TextEditingValue(
+      text: text,
+      selection: TextSelection.collapsed(
+        offset: value.selection.baseOffset + 1,
       ),
       ),
-    ];
-    client.updateEditingValueWithDeltas(deltas);
+      composing: TextRange.empty,
+    );
+    client.updateEditingValue(textEditingValue);
     await tester.pumpAndSettle();
     await tester.pumpAndSettle();
   }
   }
 
 
-  DeltaTextInputClient findDeltaTextInputClient() {
+  TextInputClient findTextInputClient() {
     final finder = find.byType(KeyboardServiceWidget);
     final finder = find.byType(KeyboardServiceWidget);
     final KeyboardServiceWidgetState state = tester.state(finder);
     final KeyboardServiceWidgetState state = tester.state(finder);
-    return state.textInputService as DeltaTextInputClient;
+    return state.textInputService as TextInputClient;
   }
   }
 }
 }

+ 19 - 0
frontend/appflowy_flutter/lib/plugins/document/presentation/editor_page.dart

@@ -4,6 +4,7 @@ import 'package:appflowy/plugins/document/presentation/editor_plugins/actions/bl
 import 'package:appflowy/plugins/document/presentation/editor_plugins/database/referenced_database_menu_tem.dart';
 import 'package:appflowy/plugins/document/presentation/editor_plugins/database/referenced_database_menu_tem.dart';
 import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart';
 import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart';
 import 'package:appflowy/plugins/document/presentation/editor_style.dart';
 import 'package:appflowy/plugins/document/presentation/editor_style.dart';
+import 'package:appflowy/plugins/document/presentation/editor_plugins/inline_page/inline_page_reference.dart';
 import 'package:appflowy_editor/appflowy_editor.dart';
 import 'package:appflowy_editor/appflowy_editor.dart';
 import 'package:flowy_infra_ui/flowy_infra_ui.dart';
 import 'package:flowy_infra_ui/flowy_infra_ui.dart';
 import 'package:flutter/material.dart';
 import 'package:flutter/material.dart';
@@ -35,6 +36,8 @@ class AppFlowyEditorPage extends StatefulWidget {
 class _AppFlowyEditorPageState extends State<AppFlowyEditorPage> {
 class _AppFlowyEditorPageState extends State<AppFlowyEditorPage> {
   late final ScrollController effectiveScrollController;
   late final ScrollController effectiveScrollController;
 
 
+  final inlinePageReferenceService = InlinePageReferenceService();
+
   final List<CommandShortcutEvent> commandShortcutEvents = [
   final List<CommandShortcutEvent> commandShortcutEvents = [
     ...codeBlockCommands,
     ...codeBlockCommands,
     ...standardCommandShortcutEvents,
     ...standardCommandShortcutEvents,
@@ -69,7 +72,11 @@ class _AppFlowyEditorPageState extends State<AppFlowyEditorPage> {
 
 
   late final Map<String, BlockComponentBuilder> blockComponentBuilders =
   late final Map<String, BlockComponentBuilder> blockComponentBuilders =
       _customAppFlowyBlockComponentBuilders();
       _customAppFlowyBlockComponentBuilders();
+
   List<CharacterShortcutEvent> get characterShortcutEvents => [
   List<CharacterShortcutEvent> get characterShortcutEvents => [
+        // inline page reference list
+        ...inlinePageReferenceShortcuts,
+
         // code block
         // code block
         ...codeBlockCharacterEvents,
         ...codeBlockCharacterEvents,
 
 
@@ -88,6 +95,18 @@ class _AppFlowyEditorPageState extends State<AppFlowyEditorPage> {
           ), // remove the default slash command.
           ), // remove the default slash command.
       ];
       ];
 
 
+  late final inlinePageReferenceShortcuts = [
+    inlinePageReferenceService.customPageLinkMenu(
+      character: '@',
+      style: styleCustomizer.selectionMenuStyleBuilder(),
+    ),
+    // uncomment this to enable the inline page reference list
+    // inlinePageReferenceService.customPageLinkMenu(
+    //   character: '+',
+    //   style: styleCustomizer.selectionMenuStyleBuilder(),
+    // ),
+  ];
+
   late final showSlashMenu = customSlashCommand(
   late final showSlashMenu = customSlashCommand(
     slashMenuItems,
     slashMenuItems,
     shouldInsertSlash: false,
     shouldInsertSlash: false,

+ 2 - 1
frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/base/link_to_page_widget.dart

@@ -87,7 +87,8 @@ class _LinkToPageMenuState extends State<LinkToPageMenu> {
   final Map<int, (ViewPB, ViewPB)> _items = {};
   final Map<int, (ViewPB, ViewPB)> _items = {};
 
 
   Future<List<(ViewPB, List<ViewPB>)>> fetchItems() async {
   Future<List<(ViewPB, List<ViewPB>)>> fetchItems() async {
-    final items = await ViewBackendService().fetchViews(widget.layoutType);
+    final items =
+        await ViewBackendService().fetchViewsWithLayoutType(widget.layoutType);
 
 
     int index = 0;
     int index = 0;
     for (final (app, children) in items) {
     for (final (app, children) in items) {

+ 164 - 0
frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/inline_page/inline_page_reference.dart

@@ -0,0 +1,164 @@
+import 'package:appflowy/plugins/document/presentation/editor_plugins/base/selectable_svg_widget.dart';
+import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mention_page_block.dart';
+import 'package:appflowy/workspace/application/view/view_ext.dart';
+import 'package:appflowy/workspace/application/view/view_service.dart';
+import 'package:appflowy_backend/protobuf/flowy-folder2/view.pb.dart';
+import 'package:appflowy_editor/appflowy_editor.dart';
+
+enum MentionType {
+  page;
+
+  static MentionType fromString(String value) {
+    switch (value) {
+      case 'page':
+        return page;
+      default:
+        throw UnimplementedError();
+    }
+  }
+}
+
+class MentionBlockKeys {
+  const MentionBlockKeys._();
+
+  static const mention = 'mention';
+  static const type = 'type'; // MentionType, String
+  static const pageId = 'page_id';
+  static const pageType = 'page_type';
+  static const pageName = 'page_name';
+}
+
+class InlinePageReferenceService {
+  customPageLinkMenu({
+    bool shouldInsertKeyword = false,
+    SelectionMenuStyle style = SelectionMenuStyle.light,
+    String character = '@',
+  }) {
+    return CharacterShortcutEvent(
+      key: 'show page link menu',
+      character: character,
+      handler: (editorState) async {
+        final items = await generatePageItems(character);
+        return _showPageSelectionMenu(
+          editorState,
+          items,
+          shouldInsertKeyword: shouldInsertKeyword,
+          style: style,
+          character: character,
+        );
+      },
+    );
+  }
+
+  SelectionMenuService? _selectionMenuService;
+  Future<bool> _showPageSelectionMenu(
+    EditorState editorState,
+    List<SelectionMenuItem> items, {
+    bool shouldInsertKeyword = true,
+    SelectionMenuStyle style = SelectionMenuStyle.light,
+    String character = '@',
+  }) async {
+    if (PlatformExtension.isMobile) {
+      return false;
+    }
+
+    final selection = editorState.selection;
+    if (selection == null) {
+      return false;
+    }
+
+    // delete the selection
+    await editorState.deleteSelection(selection);
+
+    final afterSelection = editorState.selection;
+    if (afterSelection == null || !afterSelection.isCollapsed) {
+      assert(false, 'the selection should be collapsed');
+      return true;
+    }
+    await editorState.insertTextAtPosition(
+      character,
+      position: selection.start,
+    );
+
+    () {
+      final context = editorState.getNodeAtPath(selection.start.path)?.context;
+      if (context != null) {
+        _selectionMenuService = SelectionMenu(
+          context: context,
+          editorState: editorState,
+          selectionMenuItems: items,
+          deleteSlashByDefault: false,
+          style: style,
+          itemCountFilter: 5,
+        );
+        _selectionMenuService?.show();
+      }
+    }();
+
+    return true;
+  }
+
+  Future<List<SelectionMenuItem>> generatePageItems(String character) async {
+    final service = ViewBackendService();
+    final List<(ViewPB, List<ViewPB>)> pbViews = await service.fetchViews(
+      (_, __) => true,
+    );
+    if (pbViews.isEmpty) {
+      return [];
+    }
+    final List<SelectionMenuItem> pages = [];
+    final List<ViewPB> views = [];
+    for (final element in pbViews) {
+      views.addAll(element.$2);
+    }
+    views.sort(((a, b) => b.createTime.compareTo(a.createTime)));
+
+    for (final view in views) {
+      final SelectionMenuItem pageSelectionMenuItem = SelectionMenuItem(
+        icon: (editorState, isSelected, style) => SelectableSvgWidget(
+          name: view.iconName,
+          isSelected: isSelected,
+          style: style,
+        ),
+        keywords: [
+          view.name.toLowerCase(),
+        ],
+        name: view.name,
+        handler: (editorState, menuService, context) async {
+          final selection = editorState.selection;
+          if (selection == null || !selection.isCollapsed) {
+            return;
+          }
+          final node = editorState.getNodeAtPath(selection.end.path);
+          final delta = node?.delta;
+          if (node == null || delta == null) {
+            return;
+          }
+          final index = selection.endIndex;
+          final lastKeywordIndex =
+              delta.toPlainText().substring(0, index).lastIndexOf(character);
+          // @page name -> $
+          // preload the page infos
+          pageMemorizer[view.id] = view;
+          final transaction = editorState.transaction
+            ..replaceText(
+              node,
+              lastKeywordIndex,
+              index - lastKeywordIndex,
+              '\$',
+              attributes: {
+                MentionBlockKeys.mention: {
+                  MentionBlockKeys.type: MentionType.page.name,
+                  MentionBlockKeys.pageId: view.id,
+                }
+              },
+            );
+          await editorState.apply(transaction);
+        },
+      );
+      pages.add(pageSelectionMenuItem);
+    }
+
+    return pages;
+  }
+}

+ 22 - 0
frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mention/mention_block.dart

@@ -0,0 +1,22 @@
+import 'package:appflowy/plugins/document/presentation/editor_plugins/inline_page/inline_page_reference.dart';
+import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mention_page_block.dart';
+import 'package:flutter/material.dart';
+
+class MentionBlock extends StatelessWidget {
+  const MentionBlock({
+    super.key,
+    required this.mention,
+  });
+
+  final Map mention;
+
+  @override
+  Widget build(BuildContext context) {
+    final type = MentionType.fromString(mention[MentionBlockKeys.type]);
+    if (type == MentionType.page) {
+      final pageId = mention[MentionBlockKeys.pageId];
+      return MentionPageBlock(key: ValueKey(pageId), pageId: pageId);
+    }
+    throw UnimplementedError();
+  }
+}

+ 143 - 0
frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mention/mention_page_block.dart

@@ -0,0 +1,143 @@
+import 'package:appflowy/plugins/document/presentation/more/cubit/document_appearance_cubit.dart';
+import 'package:appflowy/plugins/trash/application/trash_service.dart';
+import 'package:appflowy/startup/startup.dart';
+import 'package:appflowy/workspace/application/view/prelude.dart';
+import 'package:appflowy/workspace/application/view/view_ext.dart';
+import 'package:appflowy/workspace/presentation/home/menu/menu.dart';
+import 'package:appflowy_backend/log.dart';
+import 'package:appflowy_backend/protobuf/flowy-folder2/protobuf.dart';
+import 'package:appflowy_editor/appflowy_editor.dart'
+    show EditorState, SelectionUpdateReason;
+import 'package:collection/collection.dart';
+import 'package:flowy_infra/image.dart';
+import 'package:flowy_infra_ui/flowy_infra_ui.dart';
+import 'package:flowy_infra_ui/style_widget/hover.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter_bloc/flutter_bloc.dart';
+import 'package:provider/provider.dart';
+
+final pageMemorizer = <String, ViewPB?>{};
+
+class MentionPageBlock extends StatefulWidget {
+  const MentionPageBlock({
+    super.key,
+    required this.pageId,
+  });
+
+  final String pageId;
+
+  @override
+  State<MentionPageBlock> createState() => _MentionPageBlockState();
+}
+
+class _MentionPageBlockState extends State<MentionPageBlock> {
+  late final EditorState editorState;
+  late final Future<ViewPB?> viewPBFuture;
+  ViewListener? viewListener;
+
+  @override
+  void initState() {
+    super.initState();
+
+    editorState = context.read<EditorState>();
+    viewPBFuture = fetchView(widget.pageId);
+    viewListener = ViewListener(viewId: widget.pageId)
+      ..start(
+        onViewUpdated: (p0) {
+          pageMemorizer[p0.id] = p0;
+          editorState.reload();
+        },
+      );
+  }
+
+  @override
+  void dispose() {
+    viewListener?.stop();
+    super.dispose();
+  }
+
+  @override
+  Widget build(BuildContext context) {
+    final fontSize = context.read<DocumentAppearanceCubit>().state.fontSize;
+    return FutureBuilder<ViewPB?>(
+      initialData: pageMemorizer[widget.pageId],
+      future: viewPBFuture,
+      builder: (context, state) {
+        final view = state.data;
+        // memorize the result
+        pageMemorizer[widget.pageId] = view;
+        if (view == null) {
+          return const SizedBox.shrink();
+        }
+        updateSelection();
+        return Padding(
+          padding: const EdgeInsets.symmetric(horizontal: 2),
+          child: FlowyHover(
+            cursor: SystemMouseCursors.click,
+            child: GestureDetector(
+              onTap: () => openPage(widget.pageId),
+              behavior: HitTestBehavior.translucent,
+              child: Row(
+                mainAxisSize: MainAxisSize.min,
+                children: [
+                  const HSpace(4),
+                  FlowySvg(
+                    name: view.layout.iconName,
+                    size: const Size.square(18.0),
+                  ),
+                  const HSpace(2),
+                  FlowyText(
+                    view.name,
+                    decoration: TextDecoration.underline,
+                    fontSize: fontSize,
+                  ),
+                  const HSpace(2),
+                ],
+              ),
+            ),
+          ),
+        );
+      },
+    );
+  }
+
+  void openPage(String pageId) async {
+    final view = await fetchView(pageId);
+    if (view == null) {
+      Log.error('Page($pageId) not found');
+      return;
+    }
+    getIt<MenuSharedState>().latestOpenView = view;
+  }
+
+  Future<ViewPB?> fetchView(String pageId) async {
+    final views = await ViewBackendService().fetchViews((_, __) => true);
+    final flattenViews = views.expand((e) => [e.$1, ...e.$2]).toList();
+    final view = flattenViews.firstWhereOrNull(
+      (element) => element.id == pageId,
+    );
+    if (view == null) {
+      // try to fetch from trash
+      final trashViews = await TrashService().readTrash();
+      final trash = trashViews.fold(
+        (l) => l.items.firstWhereOrNull((element) => element.id == pageId),
+        (r) => null,
+      );
+      if (trash != null) {
+        return ViewPB()
+          ..id = trash.id
+          ..name = trash.name;
+      }
+    }
+    return view;
+  }
+
+  void updateSelection() {
+    WidgetsBinding.instance.addPostFrameCallback((timeStamp) {
+      editorState.updateSelectionWithReason(
+        editorState.selection,
+        reason: SelectionUpdateReason.transaction,
+      );
+    });
+  }
+}

+ 28 - 1
frontend/appflowy_flutter/lib/plugins/document/presentation/editor_style.dart

@@ -1,5 +1,7 @@
+import 'package:appflowy/plugins/document/presentation/editor_plugins/inline_page/inline_page_reference.dart';
+import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mention_block.dart';
 import 'package:appflowy/plugins/document/presentation/more/cubit/document_appearance_cubit.dart';
 import 'package:appflowy/plugins/document/presentation/more/cubit/document_appearance_cubit.dart';
-import 'package:appflowy_editor/appflowy_editor.dart';
+import 'package:appflowy_editor/appflowy_editor.dart' hide FlowySvg, Log;
 import 'package:collection/collection.dart';
 import 'package:collection/collection.dart';
 import 'package:flutter/material.dart';
 import 'package:flutter/material.dart';
 import 'package:flutter_bloc/flutter_bloc.dart';
 import 'package:flutter_bloc/flutter_bloc.dart';
@@ -57,6 +59,7 @@ class EditorStyleCustomizer {
           ),
           ),
         ),
         ),
       ),
       ),
+      textSpanDecorator: customizeAttributeDecorator,
     );
     );
   }
   }
 
 
@@ -142,4 +145,28 @@ class EditorStyleCustomizer {
       backgroundColor: theme.colorScheme.onTertiary,
       backgroundColor: theme.colorScheme.onTertiary,
     );
     );
   }
   }
+
+  InlineSpan customizeAttributeDecorator(
+    TextInsert textInsert,
+    TextSpan textSpan,
+  ) {
+    final attributes = textInsert.attributes;
+    if (attributes == null) {
+      return textSpan;
+    }
+    final mention = attributes[MentionBlockKeys.mention] as Map?;
+    if (mention != null) {
+      final type = mention[MentionBlockKeys.type];
+      if (type == MentionType.page.name) {
+        return WidgetSpan(
+          alignment: PlaceholderAlignment.middle,
+          child: MentionBlock(
+            key: ValueKey(mention[MentionBlockKeys.pageId]),
+            mention: mention,
+          ),
+        );
+      }
+    }
+    return textSpan;
+  }
 }
 }

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

@@ -94,13 +94,21 @@ extension ViewExtension on ViewPB {
   }
   }
 
 
   String get iconName {
   String get iconName {
-    switch (layout) {
+    return layout.iconName;
+  }
+}
+
+extension ViewLayoutExtension on ViewLayoutPB {
+  String get iconName {
+    switch (this) {
       case ViewLayoutPB.Grid:
       case ViewLayoutPB.Grid:
         return 'editor/grid';
         return 'editor/grid';
       case ViewLayoutPB.Board:
       case ViewLayoutPB.Board:
         return 'editor/board';
         return 'editor/board';
       case ViewLayoutPB.Calendar:
       case ViewLayoutPB.Calendar:
         return 'editor/calendar';
         return 'editor/calendar';
+      case ViewLayoutPB.Document:
+        return 'editor/documents';
       default:
       default:
         throw Exception('Unknown layout type');
         throw Exception('Unknown layout type');
     }
     }

+ 13 - 2
frontend/appflowy_flutter/lib/workspace/application/view/view_service.dart

@@ -154,8 +154,19 @@ class ViewBackendService {
     return FolderEventMoveView(payload).send();
     return FolderEventMoveView(payload).send();
   }
   }
 
 
+  Future<List<(ViewPB, List<ViewPB>)>> fetchViewsWithLayoutType(
+    ViewLayoutPB? layoutType,
+  ) async {
+    return fetchViews((workspace, view) {
+      if (layoutType != null) {
+        return view.layout == layoutType;
+      }
+      return true;
+    });
+  }
+
   Future<List<(ViewPB, List<ViewPB>)>> fetchViews(
   Future<List<(ViewPB, List<ViewPB>)>> fetchViews(
-    ViewLayoutPB layoutType,
+    bool Function(WorkspaceSettingPB workspace, ViewPB view) filter,
   ) async {
   ) async {
     final result = <(ViewPB, List<ViewPB>)>[];
     final result = <(ViewPB, List<ViewPB>)>[];
     return FolderEventGetCurrentWorkspace().send().then((value) async {
     return FolderEventGetCurrentWorkspace().send().then((value) async {
@@ -166,7 +177,7 @@ class ViewBackendService {
           final childViews = await getChildViews(viewId: view.id).then(
           final childViews = await getChildViews(viewId: view.id).then(
             (value) => value
             (value) => value
                 .getLeftOrNull<List<ViewPB>>()
                 .getLeftOrNull<List<ViewPB>>()
-                ?.where((e) => e.layout == layoutType)
+                ?.where((e) => filter(workspaces, e))
                 .toList(),
                 .toList(),
           );
           );
           if (childViews != null && childViews.isNotEmpty) {
           if (childViews != null && childViews.isNotEmpty) {

+ 3 - 3
frontend/appflowy_flutter/pubspec.lock

@@ -53,9 +53,9 @@ packages:
     dependency: "direct main"
     dependency: "direct main"
     description:
     description:
       path: "."
       path: "."
-      ref: cd0f67a
-      resolved-ref: cd0f67a48e40188114800fae9a0f59cafe15b0f2
-      url: "https://github.com/AppFlowy-IO/appflowy-editor.git"
+      ref: "250b1a5"
+      resolved-ref: "250b1a59856b337fc2d4b26a1dabdec265e80acf"
+      url: "https://github.com/AppFlowy-IO/appflowy-editor"
     source: git
     source: git
     version: "1.0.4"
     version: "1.0.4"
   appflowy_popover:
   appflowy_popover:

+ 3 - 2
frontend/appflowy_flutter/pubspec.yaml

@@ -45,8 +45,9 @@ dependencies:
   # appflowy_editor: ^1.0.4
   # appflowy_editor: ^1.0.4
   appflowy_editor:
   appflowy_editor:
     git:
     git:
-      url: https://github.com/AppFlowy-IO/appflowy-editor.git
-      ref: cd0f67a
+      url: https://github.com/AppFlowy-IO/appflowy-editor
+      ref: 250b1a5
+
   appflowy_popover:
   appflowy_popover:
     path: packages/appflowy_popover
     path: packages/appflowy_popover
 
 

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

@@ -85,6 +85,7 @@ checksum = "7de8ce5e0f9f8d88245311066a578d72b7af3e7088f32783804676302df237e4"
 [[package]]
 [[package]]
 name = "appflowy-integrate"
 name = "appflowy-integrate"
 version = "0.1.0"
 version = "0.1.0"
+source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=d1882d#d1882d6784a8863419727be92c29923cd175fd50"
 dependencies = [
 dependencies = [
  "anyhow",
  "anyhow",
  "collab",
  "collab",
@@ -886,6 +887,7 @@ dependencies = [
 [[package]]
 [[package]]
 name = "collab"
 name = "collab"
 version = "0.1.0"
 version = "0.1.0"
+source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=d1882d#d1882d6784a8863419727be92c29923cd175fd50"
 dependencies = [
 dependencies = [
  "anyhow",
  "anyhow",
  "bytes",
  "bytes",
@@ -903,6 +905,7 @@ dependencies = [
 [[package]]
 [[package]]
 name = "collab-client-ws"
 name = "collab-client-ws"
 version = "0.1.0"
 version = "0.1.0"
+source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=d1882d#d1882d6784a8863419727be92c29923cd175fd50"
 dependencies = [
 dependencies = [
  "bytes",
  "bytes",
  "collab-sync",
  "collab-sync",
@@ -920,6 +923,7 @@ dependencies = [
 [[package]]
 [[package]]
 name = "collab-database"
 name = "collab-database"
 version = "0.1.0"
 version = "0.1.0"
+source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=d1882d#d1882d6784a8863419727be92c29923cd175fd50"
 dependencies = [
 dependencies = [
  "anyhow",
  "anyhow",
  "async-trait",
  "async-trait",
@@ -945,6 +949,7 @@ dependencies = [
 [[package]]
 [[package]]
 name = "collab-derive"
 name = "collab-derive"
 version = "0.1.0"
 version = "0.1.0"
+source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=d1882d#d1882d6784a8863419727be92c29923cd175fd50"
 dependencies = [
 dependencies = [
  "proc-macro2",
  "proc-macro2",
  "quote",
  "quote",
@@ -956,6 +961,7 @@ dependencies = [
 [[package]]
 [[package]]
 name = "collab-document"
 name = "collab-document"
 version = "0.1.0"
 version = "0.1.0"
+source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=d1882d#d1882d6784a8863419727be92c29923cd175fd50"
 dependencies = [
 dependencies = [
  "anyhow",
  "anyhow",
  "collab",
  "collab",
@@ -973,6 +979,7 @@ dependencies = [
 [[package]]
 [[package]]
 name = "collab-folder"
 name = "collab-folder"
 version = "0.1.0"
 version = "0.1.0"
+source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=d1882d#d1882d6784a8863419727be92c29923cd175fd50"
 dependencies = [
 dependencies = [
  "anyhow",
  "anyhow",
  "chrono",
  "chrono",
@@ -992,6 +999,7 @@ dependencies = [
 [[package]]
 [[package]]
 name = "collab-persistence"
 name = "collab-persistence"
 version = "0.1.0"
 version = "0.1.0"
+source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=d1882d#d1882d6784a8863419727be92c29923cd175fd50"
 dependencies = [
 dependencies = [
  "bincode",
  "bincode",
  "chrono",
  "chrono",
@@ -1011,6 +1019,7 @@ dependencies = [
 [[package]]
 [[package]]
 name = "collab-plugins"
 name = "collab-plugins"
 version = "0.1.0"
 version = "0.1.0"
+source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=d1882d#d1882d6784a8863419727be92c29923cd175fd50"
 dependencies = [
 dependencies = [
  "anyhow",
  "anyhow",
  "async-trait",
  "async-trait",
@@ -1041,6 +1050,7 @@ dependencies = [
 [[package]]
 [[package]]
 name = "collab-sync"
 name = "collab-sync"
 version = "0.1.0"
 version = "0.1.0"
+source = "git+https://github.com/AppFlowy-IO/AppFlowy-Collab?rev=d1882d#d1882d6784a8863419727be92c29923cd175fd50"
 dependencies = [
 dependencies = [
  "bytes",
  "bytes",
  "collab",
  "collab",