Forráskód Böngészése

feat: support mention a page (#3117)

Kilu.He 1 éve
szülő
commit
923285bfcf
35 módosított fájl, 767 hozzáadás és 295 törlés
  1. 1 1
      frontend/appflowy_tauri/src/appflowy_app/components/document/DocumentBanner/cover/ChangeCoverPopover.tsx
  2. 3 3
      frontend/appflowy_tauri/src/appflowy_app/components/document/ImageBlock/ImageAlign.tsx
  3. 3 3
      frontend/appflowy_tauri/src/appflowy_app/components/document/ImageBlock/ImageToolbar.tsx
  4. 88 0
      frontend/appflowy_tauri/src/appflowy_app/components/document/Mention/Mention.hooks.ts
  5. 84 0
      frontend/appflowy_tauri/src/appflowy_app/components/document/Mention/MentionPopover.tsx
  6. 59 0
      frontend/appflowy_tauri/src/appflowy_app/components/document/Mention/RecentPages.tsx
  7. 2 0
      frontend/appflowy_tauri/src/appflowy_app/components/document/Overlay/index.tsx
  8. 1 1
      frontend/appflowy_tauri/src/appflowy_app/components/document/Root/index.tsx
  9. 9 1
      frontend/appflowy_tauri/src/appflowy_app/components/document/TextActionMenu/menu/ColorPicker.tsx
  10. 3 3
      frontend/appflowy_tauri/src/appflowy_app/components/document/TextActionMenu/menu/FormatButton.tsx
  11. 3 3
      frontend/appflowy_tauri/src/appflowy_app/components/document/TextActionMenu/menu/TurnIntoSelect.tsx
  12. 12 2
      frontend/appflowy_tauri/src/appflowy_app/components/document/TextBlock/useKeyDown.ts
  13. 18 0
      frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/InlineBlock/CodeInline.tsx
  14. 91 0
      frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/InlineBlock/FakeCursorContainer.tsx
  15. 67 0
      frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/InlineBlock/FormulaInline.tsx
  16. 0 143
      frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/InlineBlock/InlineContainer.tsx
  17. 61 0
      frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/InlineBlock/PageInline.tsx
  18. 0 31
      frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/InlineBlock/inline.css
  19. 1 48
      frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SlateEditor/TextElement.tsx
  20. 28 22
      frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SlateEditor/TextLeaf.tsx
  21. 43 10
      frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SlateEditor/useEditor.ts
  22. 0 1
      frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SlateEditor/useSlateYjs.ts
  23. 18 0
      frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SubscribeMention.hooks.ts
  24. 1 0
      frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/TemporaryInput/TemporaryPopover.tsx
  25. 0 17
      frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/ToolbarTooltip/index.tsx
  26. 20 2
      frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/useBindArrowKey.ts
  27. 3 1
      frontend/appflowy_tauri/src/appflowy_app/components/layout/Breadcrumb/index.tsx
  28. 2 0
      frontend/appflowy_tauri/src/appflowy_app/constants/document/name.ts
  29. 90 0
      frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/mention.ts
  30. 36 0
      frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/mention_slice.ts
  31. 2 0
      frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/slice.ts
  32. 0 1
      frontend/appflowy_tauri/src/appflowy_app/utils/document/slate_editor.ts
  33. 5 1
      frontend/appflowy_tauri/src/styles/mui.css
  34. 6 1
      frontend/appflowy_tauri/src/styles/variables/index.css
  35. 7 0
      frontend/resources/translations/en.json

+ 1 - 1
frontend/appflowy_tauri/src/appflowy_app/components/document/DocumentBanner/cover/ChangeCoverPopover.tsx

@@ -43,7 +43,7 @@ function ChangeCoverPopover({
       <div
         style={{
           boxShadow:
-            '0px 5px 5px -3px rgba(0,0,0,0.2),0px 8px 10px 1px rgba(0,0,0,0.14),0px 3px 14px 2px rgba(0,0,0,0.12)',
+            "var(--shadow-resize-popover)",
         }}
         className={'flex flex-col rounded-md bg-bg-body p-4 '}
         ref={ref}

+ 3 - 3
frontend/appflowy_tauri/src/appflowy_app/components/document/ImageBlock/ImageAlign.tsx

@@ -4,9 +4,9 @@ import { useSubscribeDocument } from '$app/components/document/_shared/Subscribe
 import { Align } from '$app/interfaces/document';
 import { FormatAlignCenter, FormatAlignLeft, FormatAlignRight } from '@mui/icons-material';
 import { updateNodeDataThunk } from '$app_reducers/document/async-actions';
-import ToolbarTooltip from '$app/components/document/_shared/ToolbarTooltip';
 import Popover from '@mui/material/Popover';
 import { useTranslation } from 'react-i18next';
+import { Tooltip } from '@mui/material';
 
 function ImageAlign({
   id,
@@ -63,7 +63,7 @@ function ImageAlign({
 
   return (
     <>
-      <ToolbarTooltip title={t('document.plugins.optionAction.align')}>
+      <Tooltip disableInteractive placement={'top'} title={t('document.plugins.optionAction.align')}>
         <div
           ref={ref}
           className='flex items-center justify-center p-1'
@@ -73,7 +73,7 @@ function ImageAlign({
         >
           {renderAlign(align)}
         </div>
-      </ToolbarTooltip>
+      </Tooltip>
       <Popover
         open={popoverOpen}
         anchorOrigin={{

+ 3 - 3
frontend/appflowy_tauri/src/appflowy_app/components/document/ImageBlock/ImageToolbar.tsx

@@ -1,12 +1,12 @@
 import React, { useState } from 'react';
 import { Align } from '$app/interfaces/document';
 import ImageAlign from '$app/components/document/ImageBlock/ImageAlign';
-import ToolbarTooltip from '$app/components/document/_shared/ToolbarTooltip';
 import { DeleteOutline } from '@mui/icons-material';
 import { useAppDispatch } from '$app/stores/store';
 import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks';
 import { deleteNodeThunk } from '$app_reducers/document/async-actions';
 import { useTranslation } from 'react-i18next';
+import Tooltip from '@mui/material/Tooltip';
 
 function ImageToolbar({ id, open, align }: { id: string; open: boolean; align: Align }) {
   const [popoverOpen, setPopoverOpen] = useState(false);
@@ -24,7 +24,7 @@ function ImageToolbar({ id, open, align }: { id: string; open: boolean; align: A
         } absolute right-2 top-2 z-[1px] flex h-[26px] max-w-[calc(100%-16px)] cursor-pointer items-center justify-center whitespace-nowrap rounded bg-bg-body text-sm text-text-title transition-opacity`}
       >
         <ImageAlign id={id} align={align} onOpen={() => setPopoverOpen(true)} onClose={() => setPopoverOpen(false)} />
-        <ToolbarTooltip title={t('button.delete')}>
+        <Tooltip disableInteractive placement={'top'} title={t('button.delete')}>
           <div
             onClick={() => {
               dispatch(deleteNodeThunk({ id, controller }));
@@ -33,7 +33,7 @@ function ImageToolbar({ id, open, align }: { id: string; open: boolean; align: A
           >
             <DeleteOutline />
           </div>
-        </ToolbarTooltip>
+        </Tooltip>
       </div>
     </>
   );

+ 88 - 0
frontend/appflowy_tauri/src/appflowy_app/components/document/Mention/Mention.hooks.ts

@@ -0,0 +1,88 @@
+import { useCallback, useEffect, useRef, useState } from 'react';
+import Delta, { Op } from 'quill-delta';
+import { getDeltaText } from '$app/utils/document/delta';
+import { useSubscribeNode } from '$app/components/document/_shared/SubscribeNode.hooks';
+import { useAppSelector } from '$app/stores/store';
+import { Page } from '$app_reducers/pages/slice';
+
+export function useSubscribeMentionSearchText({ blockId, open }: { blockId: string; open: boolean }) {
+  const [searchText, setSearchText] = useState<string>('');
+  const beforeOpenDeltaRef = useRef<Op[]>([]);
+  const { node } = useSubscribeNode(blockId);
+  const handleSearch = useCallback((newDelta: Delta) => {
+    const diff = new Delta(beforeOpenDeltaRef.current).diff(newDelta);
+    const text = getDeltaText(diff);
+
+    setSearchText(text);
+  }, []);
+
+  useEffect(() => {
+    if (!open) return;
+    handleSearch(new Delta(node?.data?.delta));
+  }, [handleSearch, node?.data?.delta, open]);
+
+  useEffect(() => {
+    if (!open) return;
+    beforeOpenDeltaRef.current = node?.data?.delta;
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, [open]);
+
+  return {
+    searchText,
+  };
+}
+export function useMentionPopoverProps({ open }: { open: boolean }) {
+  const [anchorPosition, setAnchorPosition] = useState<
+    | {
+        top: number;
+        left: number;
+      }
+    | undefined
+  >(undefined);
+  const popoverOpen = Boolean(anchorPosition);
+  const getPosition = useCallback(() => {
+    const range = document.getSelection()?.getRangeAt(0);
+    const rangeRect = range?.getBoundingClientRect();
+    return rangeRect;
+  }, []);
+
+  useEffect(() => {
+    if (open) {
+      const position = getPosition();
+      if (!position) return;
+      setAnchorPosition({
+        top: position.top + position.height || 0,
+        left: position.left + 14 || 0,
+      });
+    } else {
+      setAnchorPosition(undefined);
+    }
+  }, [getPosition, open]);
+
+  return {
+    anchorPosition,
+    popoverOpen,
+  };
+}
+
+export function useLoadRecentPages(searchText: string) {
+  const [recentPages, setRecentPages] = useState<Page[]>([]);
+  const pages = useAppSelector((state) => state.pages.pageMap);
+
+  useEffect(() => {
+    const recentPages = Object.values(pages)
+      .map((page) => {
+        return page;
+      })
+      .filter((page) => {
+        const text = searchText.slice(1, searchText.length);
+        if (!text) return true;
+        return page.name.toLowerCase().includes(text.toLowerCase());
+      });
+    setRecentPages(recentPages);
+  }, [pages, searchText]);
+
+  return {
+    recentPages,
+  };
+}

+ 84 - 0
frontend/appflowy_tauri/src/appflowy_app/components/document/Mention/MentionPopover.tsx

@@ -0,0 +1,84 @@
+import React, { useCallback, useEffect } from 'react';
+import { useSubscribeMentionState } from '$app/components/document/_shared/SubscribeMention.hooks';
+import Popover from '@mui/material/Popover';
+import { useAppDispatch } from '$app/stores/store';
+import { mentionActions } from '$app_reducers/document/mention_slice';
+import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks';
+import { useMentionPopoverProps, useSubscribeMentionSearchText } from '$app/components/document/Mention/Mention.hooks';
+import RecentPages from '$app/components/document/Mention/RecentPages';
+import { formatMention, MentionType } from '$app_reducers/document/async-actions/mention';
+
+function MentionPopover() {
+  const { docId, controller } = useSubscribeDocument();
+  const { open, blockId } = useSubscribeMentionState();
+  const dispatch = useAppDispatch();
+  const onClose = useCallback(() => {
+    dispatch(
+      mentionActions.close({
+        docId,
+      })
+    );
+  }, [dispatch, docId]);
+
+  const { searchText } = useSubscribeMentionSearchText({
+    blockId,
+    open,
+  });
+
+  const { popoverOpen, anchorPosition } = useMentionPopoverProps({
+    open,
+  });
+
+  useEffect(() => {
+    if (searchText === '' && popoverOpen) {
+      onClose();
+    }
+  }, [searchText, popoverOpen, onClose]);
+
+  const onSelectPage = useCallback(
+    async (pageId: string) => {
+      await dispatch(
+        formatMention({
+          controller,
+          type: MentionType.PAGE,
+          value: pageId,
+          searchTextLength: searchText.length,
+        })
+      );
+      onClose();
+    },
+    [controller, dispatch, searchText.length, onClose]
+  );
+
+  if (!open) return null;
+  return (
+    <Popover
+      onClose={onClose}
+      open={popoverOpen}
+      disableAutoFocus
+      disableRestoreFocus={true}
+      anchorReference={'anchorPosition'}
+      anchorPosition={anchorPosition}
+      transformOrigin={{ vertical: 'top', horizontal: 'left' }}
+      PaperProps={{
+        sx: {
+          height: 'auto',
+          overflow: 'visible',
+        },
+        elevation: 0,
+      }}
+    >
+      <div
+        style={{
+          boxShadow:
+            "var(--shadow-resize-popover)",
+        }}
+        className={'flex w-[420px] flex-col rounded-md bg-bg-body px-4 py-2'}
+      >
+        <RecentPages onSelect={onSelectPage} searchText={searchText} />
+      </div>
+    </Popover>
+  );
+}
+
+export default MentionPopover;

+ 59 - 0
frontend/appflowy_tauri/src/appflowy_app/components/document/Mention/RecentPages.tsx

@@ -0,0 +1,59 @@
+import React, { useEffect, useState } from 'react';
+import { List } from '@mui/material';
+import MenuItem from '@mui/material/MenuItem';
+import { useLoadRecentPages } from '$app/components/document/Mention/Mention.hooks';
+import { useTranslation } from 'react-i18next';
+import { Article } from '@mui/icons-material';
+import { useBindArrowKey } from '$app/components/document/_shared/useBindArrowKey';
+
+function RecentPages({ searchText, onSelect }: { searchText: string; onSelect: (pageId: string) => void }) {
+  const { t } = useTranslation();
+  const { recentPages } = useLoadRecentPages(searchText);
+  const [selectOption, setSelectOption] = useState<string | null>(null);
+
+  const { run, stop } = useBindArrowKey({
+    options: recentPages.map((item) => item.id),
+    onChange: (key) => {
+      setSelectOption(key);
+    },
+    selectOption,
+    onEnter: () => selectOption && onSelect(selectOption),
+  });
+
+  useEffect(() => {
+    if (recentPages.length > 0) {
+      run();
+    } else {
+      stop();
+    }
+  }, [recentPages, run, stop]);
+
+  return (
+    <List>
+      <div className={'p-2 text-text-caption'}>{t('document.mention.page.label')}</div>
+      {recentPages.map((page) => (
+        <MenuItem
+          style={{
+            margin: 0,
+            padding: '0.5rem',
+          }}
+          onMouseEnter={() => {
+            setSelectOption(page.id);
+          }}
+          selected={selectOption === page.id}
+          key={page.id}
+          onClick={() => {
+            onSelect(page.id);
+          }}
+        >
+          <div className={'flex items-center'}>
+            <div className={'mr-2'}>{page.icon?.value || <Article />}</div>
+            <div>{page.name || t('menuAppHeader.defaultNewPageName')}</div>
+          </div>
+        </MenuItem>
+      ))}
+    </List>
+  );
+}
+
+export default RecentPages;

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

@@ -6,6 +6,7 @@ import { useCopy } from '$app/components/document/_shared/CopyPasteHooks/useCopy
 import { usePaste } from '$app/components/document/_shared/CopyPasteHooks/usePaste';
 import { useUndoRedo } from '$app/components/document/_shared/UndoHooks/useUndoRedo';
 import TemporaryPopover from '$app/components/document/_shared/TemporaryInput/TemporaryPopover';
+import MentionPopover from '$app/components/document/Mention/MentionPopover';
 
 export default function Overlay({ container }: { container: HTMLDivElement }) {
   useCopy(container);
@@ -17,6 +18,7 @@ export default function Overlay({ container }: { container: HTMLDivElement }) {
       <BlockSelection container={container} />
       <BlockSlash container={container} />
       <TemporaryPopover />
+      <MentionPopover />
     </>
   );
 }

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

@@ -20,7 +20,7 @@ function Root({ documentData }: { documentData: DocumentData }) {
 
   return (
     <>
-      <div id='appflowy-block-doc' className='h-[100%] overflow-hidden text-text-title caret-text-title'>
+      <div id='appflowy-block-doc' className='h-[100%] overflow-hidden text-base text-text-title caret-text-title'>
         <VirtualizedList node={node} childIds={childIds} renderNode={renderNode} />
       </div>
     </>

+ 9 - 1
frontend/appflowy_tauri/src/appflowy_app/components/document/TextActionMenu/menu/ColorPicker.tsx

@@ -119,7 +119,7 @@ function ColorPicker({
     }
   }, [selectOption, formatColor, colors]);
 
-  useBindArrowKey({
+  const { run, stop } = useBindArrowKey({
     options: colors.map((item) => item.key),
     onChange: (key) => {
       setSelectOption(key);
@@ -128,6 +128,14 @@ function ColorPicker({
     onEnter: () => onClick(),
   });
 
+  useEffect(() => {
+    if (open) {
+      run();
+    } else {
+      stop();
+    }
+  }, [open, run, stop]);
+
   return (
     <>
       <div

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

@@ -1,6 +1,5 @@
 import React, { useCallback, useEffect, useMemo } from 'react';
 import { TemporaryType, TextAction } from '$app/interfaces/document';
-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';
@@ -17,6 +16,7 @@ import {
 } from '@mui/icons-material';
 import LinkIcon from '@mui/icons-material/AddLink';
 import { useTranslation } from 'react-i18next';
+import Tooltip from '@mui/material/Tooltip';
 
 export const iconSize = { width: 18, height: 18 };
 
@@ -130,11 +130,11 @@ const FormatButton = ({ format, icon }: { format: TextAction; icon: string }) =>
   }, [icon]);
 
   return (
-    <ToolbarTooltip title={formatTooltips[format]}>
+    <Tooltip disableInteractive placement={'top'} title={formatTooltips[format]}>
       <div className={`${color} cursor-pointer px-1 hover:text-fill-default`} onClick={() => formatClick(format)}>
         {formatIcon}
       </div>
-    </ToolbarTooltip>
+    </Tooltip>
   );
 };
 

+ 3 - 3
frontend/appflowy_tauri/src/appflowy_app/components/document/TextActionMenu/menu/TurnIntoSelect.tsx

@@ -3,7 +3,7 @@ import TurnIntoPopover from '$app/components/document/_shared/TurnInto';
 import ArrowDropDown from '@mui/icons-material/ArrowDropDown';
 import { useSubscribeNode } from '$app/components/document/_shared/SubscribeNode.hooks';
 import { useTranslation } from 'react-i18next';
-import ToolbarTooltip from '../../_shared/ToolbarTooltip';
+import Tooltip from '@mui/material/Tooltip';
 
 function TurnIntoSelect({ id }: { id: string }) {
   const [anchorPosition, setAnchorPosition] = React.useState<{
@@ -30,12 +30,12 @@ function TurnIntoSelect({ id }: { id: string }) {
 
   return (
     <>
-      <ToolbarTooltip title={t('document.plugins.optionAction.turnInto')}>
+      <Tooltip disableInteractive placement={'top'} title={t('document.plugins.optionAction.turnInto')}>
         <div onClick={handleClick} className='flex cursor-pointer items-center px-2 text-sm text-fill-default'>
           <span>{node.type}</span>
           <ArrowDropDown />
         </div>
-      </ToolbarTooltip>
+      </Tooltip>
       <TurnIntoPopover
         id={id}
         open={open}

+ 12 - 2
frontend/appflowy_tauri/src/appflowy_app/components/document/TextBlock/useKeyDown.ts

@@ -10,9 +10,10 @@ import {
 import { useTurnIntoBlockEvents } from './useTurnIntoBlockEvents';
 import { useCommonKeyEvents } from '../_shared/EditorHooks/useCommonKeyEvents';
 import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks';
+import { openMention } from '$app_reducers/document/async-actions/mention';
 
 export function useKeyDown(id: string) {
-  const { controller } = useSubscribeDocument();
+  const { controller, docId } = useSubscribeDocument();
   const dispatch = useAppDispatch();
   const turnIntoEvents = useTurnIntoBlockEvents(id);
   const commonKeyEvents = useCommonKeyEvents(id);
@@ -82,9 +83,18 @@ export function useKeyDown(id: string) {
           );
         },
       },
+      {
+        // handle @ key for mention panel
+        canHandle: (e: React.KeyboardEvent<HTMLDivElement>) => {
+          return e.key === '@';
+        },
+        handler: (e: React.KeyboardEvent<HTMLDivElement>) => {
+          dispatch(openMention({ docId }));
+        },
+      },
       ...turnIntoEvents,
     ];
-  }, [commonKeyEvents, controller, dispatch, id, turnIntoEvents]);
+  }, [docId, commonKeyEvents, controller, dispatch, id, turnIntoEvents]);
 
   const onKeyDown = useCallback(
     (e: React.KeyboardEvent<HTMLDivElement>) => {

+ 18 - 0
frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/InlineBlock/CodeInline.tsx

@@ -0,0 +1,18 @@
+import React from 'react';
+
+function CodeInline({ text, children, selected }: { text: string; children: React.ReactNode; selected: boolean }) {
+  return (
+    <span
+      className={'bg-content-blue-50 py-1'}
+      style={{
+        fontSize: '85%',
+        lineHeight: 'normal',
+        backgroundColor: selected ? 'var(--content-blue-100)' : undefined,
+      }}
+    >
+      {children}
+    </span>
+  );
+}
+
+export default CodeInline;

+ 91 - 0
frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/InlineBlock/FakeCursorContainer.tsx

@@ -0,0 +1,91 @@
+import React, { useContext, useEffect, useRef, useState } from 'react';
+import { useFocused, useRangeRef } from '$app/components/document/_shared/SubscribeSelection.hooks';
+import { NodeIdContext } from '$app/components/document/_shared/SubscribeNode.hooks';
+
+/**
+ * This component is used to wrap the cursor display position for inline block.
+ * Since the children of inline blocks are just single characters,
+ * if not wrapped, the cursor position would follow the character instead of the block's boundary.
+ * This component ensures that when the cursor switches between characters,
+ * it is wrapped to move within the block's boundary.
+ */
+export const FakeCursorContainer = ({
+  isFirst,
+  isLast,
+  onClick,
+  getSelection,
+  children,
+  renderNode,
+}: {
+  onClick?: (node: HTMLSpanElement) => void;
+  getSelection: (element: HTMLElement) => { index: number; length: number } | null;
+  isFirst: boolean;
+  isLast: boolean;
+  children: React.ReactNode;
+  renderNode: () => React.ReactNode;
+}) => {
+  const id = useContext(NodeIdContext);
+  const ref = useRef<HTMLSpanElement>(null);
+  const { focused, focusCaret } = useFocused(id);
+  const rangeRef = useRangeRef();
+  const [position, setPosition] = useState<'left' | 'right' | undefined>();
+
+  useEffect(() => {
+    setPosition(undefined);
+    if (!ref.current) return;
+    if (!focused || !focusCaret || rangeRef.current?.isDragging) {
+      return;
+    }
+
+    const inlineBlockSelection = getSelection(ref.current);
+
+    if (!inlineBlockSelection) return;
+    const distance = inlineBlockSelection.index - focusCaret.index;
+
+    if (distance === 0 && isFirst) {
+      setPosition('left');
+      return;
+    }
+
+    if (distance === -1) {
+      setPosition('right');
+      return;
+    }
+  }, [focused, focusCaret, getSelection, isFirst, rangeRef]);
+
+  useEffect(() => {
+    if (!ref.current) return;
+    const onMouseDown = (e: MouseEvent) => {
+      if (e.target === ref.current) {
+        e.stopPropagation();
+        e.preventDefault();
+      }
+    };
+
+    // prevent page scroll when the caret change by mouse down
+    document.addEventListener('mousedown', onMouseDown, true);
+    return () => {
+      document.removeEventListener('mousedown', onMouseDown, true);
+    };
+  }, []);
+
+  return (
+    <span className={'relative inline-block px-1'} ref={ref} onClick={() => ref.current && onClick?.(ref.current)}>
+      <span
+        style={{
+          pointerEvents: 'none',
+          left: position === 'left' ? '-1px' : undefined,
+          right: position === 'right' ? '-1px' : undefined,
+          caretColor: position === undefined ? 'transparent' : undefined,
+        }}
+        className={`absolute text-transparent`}
+      >
+        {children}
+      </span>
+      <span data-slate-placeholder={true} contentEditable={false} className={'inline-block-content'}>
+        {renderNode()}
+      </span>
+      {isLast && <span data-slate-string={false}>&#xFEFF;</span>}
+    </span>
+  );
+};

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

@@ -0,0 +1,67 @@
+import React, { useCallback, useContext } from 'react';
+import { NodeIdContext } from '$app/components/document/_shared/SubscribeNode.hooks';
+import { RangeStaticNoId, TemporaryType } from '$app/interfaces/document';
+import { useAppDispatch } from '$app/stores/store';
+import { createTemporary } from '$app_reducers/document/async-actions/temporary';
+import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks';
+import KatexMath from '$app/components/document/_shared/KatexMath';
+import { FakeCursorContainer } from '$app/components/document/_shared/InlineBlock/FakeCursorContainer';
+
+function FormulaInline({
+  isFirst,
+  isLast,
+  children,
+  getSelection,
+  selectedText,
+  data,
+}: {
+  getSelection: (node: Element) => RangeStaticNoId | null;
+  children: React.ReactNode;
+  selectedText: string;
+  isLast: boolean;
+  isFirst: boolean;
+  data: {
+    latex?: string;
+  };
+}) {
+  const id = useContext(NodeIdContext);
+  const { docId } = useSubscribeDocument();
+  const dispatch = useAppDispatch();
+  const onClick = useCallback(
+    (node: HTMLSpanElement) => {
+      const selection = getSelection(node);
+
+      if (!selection) return;
+
+      dispatch(
+        createTemporary({
+          docId,
+          state: {
+            id,
+            selection,
+            selectedText,
+            type: TemporaryType.Equation,
+            data: { latex: data.latex },
+          },
+        })
+      );
+    },
+    [getSelection, data.latex, dispatch, docId, id, selectedText]
+  );
+
+  if (!selectedText) return null;
+
+  return (
+    <FakeCursorContainer
+      onClick={onClick}
+      getSelection={getSelection}
+      isFirst={isFirst}
+      isLast={isLast}
+      renderNode={() => <KatexMath latex={data.latex!} isInline />}
+    >
+      {children}
+    </FakeCursorContainer>
+  );
+}
+
+export default FormulaInline;

+ 0 - 143
frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/InlineBlock/InlineContainer.tsx

@@ -1,143 +0,0 @@
-import React, { useCallback, useContext, useEffect, useRef } from 'react';
-import './inline.css';
-import { NodeIdContext } from '$app/components/document/_shared/SubscribeNode.hooks';
-import { useFocused, useRangeRef } from '$app/components/document/_shared/SubscribeSelection.hooks';
-import { RangeStaticNoId, TemporaryType } from '$app/interfaces/document';
-import { useAppDispatch } from '$app/stores/store';
-import { createTemporary } from '$app_reducers/document/async-actions/temporary';
-import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks';
-import KatexMath from '$app/components/document/_shared/KatexMath';
-
-const LEFT_CARET_CLASS = 'inline-block-with-cursor-left';
-const RIGHT_CARET_CLASS = 'inline-block-with-cursor-right';
-
-function InlineContainer({
-  isFirst,
-  isLast,
-  children,
-  getSelection,
-  selectedText,
-  data,
-  temporaryType,
-}: {
-  getSelection: (node: Element) => RangeStaticNoId | null;
-  children: React.ReactNode;
-  selectedText: string;
-  isLast: boolean;
-  isFirst: boolean;
-  data: {
-    latex?: string;
-  };
-  temporaryType: TemporaryType;
-}) {
-  const id = useContext(NodeIdContext);
-  const { docId } = useSubscribeDocument();
-  const { focused, focusCaret } = useFocused(id);
-  const rangeRef = useRangeRef();
-  const ref = useRef<HTMLSpanElement>(null);
-  const dispatch = useAppDispatch();
-  const onClick = useCallback(
-    (node: HTMLSpanElement) => {
-      const selection = getSelection(node);
-
-      if (!selection) return;
-      const temporaryData = temporaryType === TemporaryType.Equation ? { latex: data.latex } : {};
-
-      dispatch(
-        createTemporary({
-          docId,
-          state: {
-            id,
-            selection,
-            selectedText,
-            type: temporaryType,
-            data: temporaryData
-          },
-        })
-      );
-    },
-    [getSelection, temporaryType, data.latex, dispatch, docId, id, selectedText]
-  );
-
-  const renderNode = useCallback(() => {
-    switch (temporaryType) {
-      case TemporaryType.Equation:
-        return <KatexMath latex={data.latex!} isInline />;
-      default:
-        return null;
-    }
-  }, [data, temporaryType]);
-
-  const resetCaret = useCallback(() => {
-    if (!ref.current) return;
-    ref.current.classList.remove(RIGHT_CARET_CLASS);
-    ref.current.classList.remove(LEFT_CARET_CLASS);
-  }, []);
-
-  useEffect(() => {
-    resetCaret();
-    if (!ref.current) return;
-    if (!focused || !focusCaret || rangeRef.current?.isDragging) {
-      return;
-    }
-
-    const inlineBlockSelection = getSelection(ref.current);
-
-    if (!inlineBlockSelection) return;
-    const distance = inlineBlockSelection.index - focusCaret.index;
-
-    if (distance === 0 && isFirst) {
-      ref.current.classList.add(LEFT_CARET_CLASS);
-      return;
-    }
-
-    if (distance === -1) {
-      ref.current.classList.add(RIGHT_CARET_CLASS);
-      return;
-    }
-  }, [focused, focusCaret, getSelection, resetCaret, isFirst, rangeRef]);
-
-  useEffect(() => {
-    if (!ref.current) return;
-    const onMouseDown = (e: MouseEvent) => {
-      if (e.target === ref.current) {
-        e.stopPropagation();
-        e.preventDefault();
-      }
-    };
-
-    // prevent page scroll when the caret change by mouse down
-    document.addEventListener('mousedown', onMouseDown, true);
-    return () => {
-      document.removeEventListener('mousedown', onMouseDown, true);
-    };
-  }, []);
-
-  if (!selectedText) return null;
-
-  return (
-    <span className={'inline-block-with-cursor relative'} ref={ref} onClick={() => onClick(ref.current!)}>
-      <span
-        style={{
-          pointerEvents: 'none',
-        }}
-        className={`absolute caret-transparent opacity-0`}
-      >
-        {children}
-      </span>
-      <span
-        data-slate-placeholder={true}
-        contentEditable={false}
-        style={{
-          pointerEvents: 'none',
-        }}
-        className={'inline-block-content'}
-      >
-        {renderNode()}
-      </span>
-      {isLast && <span data-slate-string={false}>&#xFEFF;</span>}
-    </span>
-  );
-}
-
-export default InlineContainer;

+ 61 - 0
frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/InlineBlock/PageInline.tsx

@@ -0,0 +1,61 @@
+import React, { useCallback, useEffect, useState } from 'react';
+import { useAppSelector } from '$app/stores/store';
+import { Article } from '@mui/icons-material';
+import { PageController } from '$app/stores/effects/workspace/page/page_controller';
+import { Page } from '$app_reducers/pages/slice';
+import { useTranslation } from 'react-i18next';
+import { useNavigate } from 'react-router-dom';
+import { pageTypeMap } from '$app/constants';
+import { LinearProgress } from '@mui/material';
+import Tooltip from "@mui/material/Tooltip";
+
+function PageInline({ pageId }: { pageId: string }) {
+  const { t } = useTranslation();
+  const page = useAppSelector((state) => state.pages.pageMap[pageId]);
+  const navigate = useNavigate();
+  const [currentPage, setCurrentPage] = useState<Page | null>(null);
+  const loadPage = useCallback(async (id: string) => {
+    const controller = new PageController(id);
+    const page = await controller.getPage();
+    setCurrentPage(page);
+  }, []);
+
+  const navigateToPage = useCallback(
+    (page: Page) => {
+      const pageType = pageTypeMap[page.layout];
+      navigate(`/page/${pageType}/${page.id}`);
+    },
+    [navigate]
+  );
+
+  useEffect(() => {
+    if (page) {
+      setCurrentPage(page);
+      return;
+    }
+    void loadPage(pageId);
+  }, [page, loadPage, pageId]);
+
+  return currentPage ? (
+    <Tooltip arrow title={t('document.mention.page.tooltip')} placement={'top'}>
+      <span
+        onClick={() => {
+          if (!currentPage) return;
+
+          navigateToPage(currentPage);
+        }}
+        className={'inline-block cursor-pointer rounded px-1 hover:bg-content-blue-100'}
+      >
+      <span className={'mr-1'}>{currentPage.icon?.value || <Article />}</span>
+      <span className={'font-medium underline '}>{currentPage.name || t('menuAppHeader.defaultNewPageName')}</span>
+    </span>
+    </Tooltip>
+
+  ) : (
+    <span>
+      <LinearProgress />
+    </span>
+  );
+}
+
+export default PageInline;

+ 0 - 31
frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/InlineBlock/inline.css

@@ -1,31 +0,0 @@
-.inline-block-with-cursor {
-    position: relative;
-    display: inline-block;
-    padding: 0 2px;
-}
-
-.inline-block-with-cursor-left::before,
-.inline-block-with-cursor-right::after {
-    content: '';
-    position: absolute;
-    top: 0px;
-    width: 1px;
-    height: 100%;
-    background-color: rgb(55, 53, 47);
-    opacity: 0.5;
-    animation: cursor-blink 1s infinite;
-}
-
-.inline-block-with-cursor-left::before {
-    left: -1px;
-}
-
-.inline-block-with-cursor-right::after {
-    right: -1px;
-}
-
-@keyframes cursor-blink {
-    0% { opacity: 0; }
-    50% { opacity: 1; }
-    100% { opacity: 0; }
-}

+ 1 - 48
frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SlateEditor/TextElement.tsx

@@ -1,12 +1,8 @@
 import { RenderElementProps } from 'slate-react';
-import React, { useEffect, useRef } from 'react';
+import React, { useRef } from 'react';
 
 export function TextElement(props: RenderElementProps) {
   const ref = useRef<HTMLDivElement | null>(null);
-  useEffect(() => {
-    if (!ref.current) return;
-    amendCodeLeafs(ref.current);
-  });
 
   return (
     <div
@@ -20,46 +16,3 @@ export function TextElement(props: RenderElementProps) {
     </div>
   );
 }
-
-function amendCodeLeafs(textElement: Element) {
-  const leafNodes = textElement.querySelectorAll(`[data-slate-leaf="true"]`);
-  let codeLeafNodes: Element[] = [];
-  leafNodes?.forEach((leafNode, index) => {
-    const isCodeLeaf = leafNode.classList.contains('inline-code');
-    if (isCodeLeaf) {
-      codeLeafNodes.push(leafNode);
-    } else {
-      if (codeLeafNodes.length > 0) {
-        addStyleToCodeLeafs(codeLeafNodes);
-        codeLeafNodes = [];
-      }
-    }
-    if (codeLeafNodes.length > 0 && index === leafNodes.length - 1) {
-      addStyleToCodeLeafs(codeLeafNodes);
-      codeLeafNodes = [];
-    }
-  });
-}
-
-function addStyleToCodeLeafs(codeLeafs: Element[]) {
-  if (codeLeafs.length === 0) return;
-  if (codeLeafs.length === 1) {
-    const codeNode = codeLeafs[0].firstChild as Element;
-    codeNode.classList.add('rounded', 'px-1.5');
-    return;
-  }
-  codeLeafs.forEach((codeLeaf, index) => {
-    const codeNode = codeLeaf.firstChild as Element;
-    codeNode.classList.remove('rounded', 'px-1.5');
-    codeNode.classList.remove('rounded-l', 'pl-1.5');
-    codeNode.classList.remove('rounded-r', 'pr-1.5');
-    if (index === 0) {
-      codeNode.classList.add('rounded-l', 'pl-1.5');
-      return;
-    }
-    if (index === codeLeafs.length - 1) {
-      codeNode.classList.add('rounded-r', 'pr-1.5');
-      return;
-    }
-  });
-}

+ 28 - 22
frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SlateEditor/TextLeaf.tsx

@@ -3,9 +3,13 @@ import { BaseText } from 'slate';
 import { useCallback, useRef } from 'react';
 import { converToIndexLength } from '$app/utils/document/slate_editor';
 import TemporaryInput from '$app/components/document/_shared/TemporaryInput';
-import InlineContainer from '$app/components/document/_shared/InlineBlock/InlineContainer';
+import FormulaInline from '$app/components/document/_shared/InlineBlock/FormulaInline';
 import { TemporaryType } from '$app/interfaces/document';
 import LinkInline from '$app/components/document/_shared/InlineBlock/LinkInline';
+import { MentionType } from '$app_reducers/document/async-actions/mention';
+import PageInline from '$app/components/document/_shared/InlineBlock/PageInline';
+import { FakeCursorContainer } from '$app/components/document/_shared/InlineBlock/FakeCursorContainer';
+import CodeInline from '$app/components/document/_shared/InlineBlock/CodeInline';
 
 interface Attributes {
   bold?: boolean;
@@ -20,6 +24,7 @@ interface Attributes {
   formula?: string;
   font_color?: string;
   bg_color?: string;
+  mention?: Record<string, string>;
 }
 interface TextLeafProps extends RenderLeafProps {
   leaf: BaseText & Attributes;
@@ -30,7 +35,10 @@ interface TextLeafProps extends RenderLeafProps {
 const TextLeaf = (props: TextLeafProps) => {
   const { attributes, children, leaf, isCodeBlock, editor } = props;
   const ref = useRef<HTMLSpanElement>(null);
+  const { isLast, text, parent } = children.props;
+  const isSelected = Boolean(leaf.selection_high_lighted);
 
+  const isFirst = text === parent?.children?.[0];
   const customAttributes = {
     ...attributes,
   };
@@ -38,15 +46,9 @@ const TextLeaf = (props: TextLeafProps) => {
 
   if (leaf.code && !leaf.temporary) {
     newChildren = (
-      <span
-        className={`bg-content-blue-50 text-text-title`}
-        style={{
-          fontSize: '85%',
-          lineHeight: 'normal',
-        }}
-      >
+      <CodeInline selected={isSelected} text={text}>
         {newChildren}
-      </span>
+      </CodeInline>
     );
   }
 
@@ -79,34 +81,38 @@ const TextLeaf = (props: TextLeafProps) => {
     );
   }
 
-  if (leaf.formula) {
-    const { isLast, text, parent } = children.props;
-    const temporaryType = TemporaryType.Equation;
+  if (leaf.formula && leaf.text) {
     const data = { latex: leaf.formula };
 
     newChildren = (
-      <InlineContainer
-        isLast={isLast}
-        isFirst={text === parent.children[0]}
+      <FormulaInline isLast={isLast} isFirst={isFirst} getSelection={getSelection} data={data} selectedText={leaf.text}>
+        {newChildren}
+      </FormulaInline>
+    );
+  }
+
+  const mention = leaf.mention;
+  if (mention && mention.type === MentionType.PAGE && leaf.text) {
+    newChildren = (
+      <FakeCursorContainer
         getSelection={getSelection}
-        data={data}
-        temporaryType={temporaryType}
-        selectedText={leaf.text}
+        isFirst={isFirst}
+        isLast={isLast}
+        renderNode={() => <PageInline pageId={mention.page} />}
       >
         {newChildren}
-      </InlineContainer>
+      </FakeCursorContainer>
     );
   }
 
   const className = [
     isCodeBlock && 'token',
     leaf.prism_token && leaf.prism_token,
-    leaf.strikethrough && 'line-through',
-    leaf.selection_high_lighted && 'bg-content-blue-100',
-    leaf.code && !leaf.temporary && 'inline-code',
+    isSelected && 'bg-content-blue-100',
     leaf.bold && 'font-bold',
     leaf.italic && 'italic',
     leaf.underline && 'underline',
+    leaf.strikethrough && 'line-through',
   ].filter(Boolean);
 
   if (leaf.temporary) {

+ 43 - 10
frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SlateEditor/useEditor.ts

@@ -42,21 +42,53 @@ export function useEditor({
   const onChangeHandler = useCallback(
     (slateValue: Descendant[]) => {
       const oldContents = delta || new Delta();
-
-      onChange?.(convertToDelta(slateValue), oldContents);
+      const newContents = convertToDelta(slateValue);
+      onChange?.(newContents, oldContents);
       onSelectionChangeHandler(editor.selection);
     },
     [delta, editor, onChange, onSelectionChangeHandler]
   );
 
-  const onDOMBeforeInput = useCallback((e: InputEvent) => {
-    // COMPAT: in Apple, `compositionend` is dispatched after the `beforeinput` for "insertFromComposition".
-    // It will cause repeated characters when inputting Chinese.
-    // Here, prevent the beforeInput event and wait for the compositionend event to take effect.
-    if (e.inputType === 'insertFromComposition') {
-      e.preventDefault();
+  // Prevent attributes from being applied when entering text at the beginning or end of an inline block.
+  // For example, when entering text before or after a mentioned page,
+  // we expect plain text instead of applying mention attributes.
+  // Similarly, when entering text before or after inline code,
+  // we also expect plain text that is not confined within the inline code scope.
+  const preventInlineBlockAttributeOverride = useCallback(() => {
+    const marks = editor.getMarks();
+    const markKeys = marks
+      ? Object.keys(marks).filter((mark) => ['mention', 'formula', 'href', 'code'].includes(mark))
+      : [];
+    const currentSelection = editor.selection || [];
+    let removeMark = markKeys.length > 0;
+    const [_, path] = editor.node(currentSelection);
+    if (removeMark) {
+      const selectionStart = editor.start(currentSelection);
+      const selectionEnd = editor.end(currentSelection);
+      const isNodeEnd = editor.isEnd(selectionEnd, path);
+      const isNodeStart = editor.isStart(selectionStart, path);
+      removeMark = isNodeStart || isNodeEnd;
+    }
+
+    if (removeMark) {
+      markKeys.forEach((mark) => {
+        editor.removeMark(mark);
+      });
     }
-  }, []);
+  }, [editor]);
+
+  const onDOMBeforeInput = useCallback(
+    (e: InputEvent) => {
+      // COMPAT: in Apple, `compositionend` is dispatched after the `beforeinput` for "insertFromComposition".
+      // It will cause repeated characters when inputting Chinese.
+      // Here, prevent the beforeInput event and wait for the compositionend event to take effect.
+      if (e.inputType === 'insertFromComposition') {
+        e.preventDefault();
+      }
+      preventInlineBlockAttributeOverride();
+    },
+    [preventInlineBlockAttributeOverride]
+  );
 
   const getDecorateRange = useCallback(
     (
@@ -162,7 +194,8 @@ export function useEditor({
 
     if (!slateSelection) return;
 
-    if (isFocused && JSON.stringify(slateSelection) === JSON.stringify(editor.selection)) return;
+    const isEqual = JSON.stringify(slateSelection) === JSON.stringify(editor.selection);
+    if (isFocused && isEqual) return;
 
     // why we didn't use slate api to change selection?
     // because the slate must be focused before change selection,

+ 0 - 1
frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SlateEditor/useSlateYjs.ts

@@ -35,7 +35,6 @@ export function useSlateYjs({ delta }: { delta?: Delta }) {
     if (!yText) return;
     const oldContents = new Delta(yText.toDelta());
     const diffDelta = oldContents.diff(delta || new Delta());
-
     if (diffDelta.ops.length === 0) return;
     yText.applyDelta(diffDelta.ops);
   }, [delta, editor, yText]);

+ 18 - 0
frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SubscribeMention.hooks.ts

@@ -0,0 +1,18 @@
+import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks';
+import { useAppSelector } from '$app/stores/store';
+import { MENTION_NAME } from '$app/constants/document/name';
+import { MentionState } from '$app_reducers/document/mention_slice';
+
+const initialState: MentionState = {
+  open: false,
+  blockId: '',
+};
+export function useSubscribeMentionState() {
+  const { docId } = useSubscribeDocument();
+
+  const state = useAppSelector((state) => {
+    return state[MENTION_NAME][docId] || initialState;
+  });
+
+  return state;
+}

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

@@ -17,6 +17,7 @@ function TemporaryPopover() {
   const anchorPosition = useMemo(() => temporaryState?.popoverPosition, [temporaryState]);
   const open = Boolean(anchorPosition);
   const id = temporaryState?.id;
+  const type = temporaryState?.type;
   const dispatch = useAppDispatch();
   const { docId, controller } = useSubscribeDocument();
 

+ 0 - 17
frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/ToolbarTooltip/index.tsx

@@ -1,17 +0,0 @@
-import React from 'react';
-import Tooltip from '@mui/material/Tooltip';
-
-function ToolbarTooltip({ title, children }: { children: JSX.Element; title?: string }) {
-  return (
-    <Tooltip
-      disableInteractive
-      slotProps={{ tooltip: { style: { background: 'var(--bg-tips)', borderRadius: 8 } } }}
-      title={title}
-      placement='top-start'
-    >
-      <div>{children}</div>
-    </Tooltip>
-  );
-}
-
-export default ToolbarTooltip;

+ 20 - 2
frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/useBindArrowKey.ts

@@ -16,6 +16,7 @@ export const useBindArrowKey = ({
   onChange?: (key: string) => void;
   selectOption?: string | null;
 }) => {
+  const [isRun, setIsRun] = useState(false);
   const onUp = useCallback(() => {
     const getSelected = () => {
       const index = options.findIndex((item) => item === selectOption);
@@ -68,10 +69,27 @@ export const useBindArrowKey = ({
     [onDown, onEnter, onLeft, onRight, onUp]
   );
 
+  const run = useCallback(() => {
+    setIsRun(true);
+  }, []);
+
+  const stop = useCallback(() => {
+    setIsRun(false);
+  }, []);
+
   useEffect(() => {
-    document.addEventListener('keydown', handleArrowKey, true);
+    if (isRun) {
+      document.addEventListener('keydown', handleArrowKey, true);
+    } else {
+      document.removeEventListener('keydown', handleArrowKey, true);
+    }
     return () => {
       document.removeEventListener('keydown', handleArrowKey, true);
     };
-  }, [handleArrowKey]);
+  }, [handleArrowKey, isRun]);
+
+  return {
+    run,
+    stop,
+  };
 };

+ 3 - 1
frontend/appflowy_tauri/src/appflowy_app/components/layout/Breadcrumb/index.tsx

@@ -6,8 +6,10 @@ import Typography from '@mui/material/Typography';
 import { Page } from '$app_reducers/pages/slice';
 import { useNavigate } from 'react-router-dom';
 import { pageTypeMap } from '$app/constants';
+import { useTranslation } from 'react-i18next';
 
 function Breadcrumb() {
+  const { t } = useTranslation();
   const { pagePath } = useLoadExpandedPages();
   const navigate = useNavigate();
   const activePage = useMemo(() => pagePath[pagePath.length - 1], [pagePath]);
@@ -35,7 +37,7 @@ function Breadcrumb() {
           {page.name}
         </Link>
       ))}
-      <Typography color='text.primary'>{activePage?.name}</Typography>
+      <Typography color='text.primary'>{activePage?.name || t('menuAppHeader.defaultNewPageName')}</Typography>
     </Breadcrumbs>
   );
 }

+ 2 - 0
frontend/appflowy_tauri/src/appflowy_app/constants/document/name.ts

@@ -3,6 +3,8 @@ export const TEMPORARY_NAME = 'document/temporary';
 export const BLOCK_EDIT_NAME = 'document/block_edit';
 export const RANGE_NAME = 'document/range';
 
+export const MENTION_NAME = 'document/mention';
+
 export const RECT_RANGE_NAME = 'document/rect_range';
 export const SLASH_COMMAND_NAME = 'document/slash_command';
 export const TEXT_LINK_NAME = 'document/text_link';

+ 90 - 0
frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/mention.ts

@@ -0,0 +1,90 @@
+import { createAsyncThunk } from '@reduxjs/toolkit';
+import { RootState } from '$app/stores/store';
+import { DOCUMENT_NAME, MENTION_NAME, RANGE_NAME } from '$app/constants/document/name';
+import Delta from 'quill-delta';
+import { getDeltaText } from '$app/utils/document/delta';
+import { mentionActions } from '$app_reducers/document/mention_slice';
+import { DocumentController } from '$app/stores/effects/document/document_controller';
+import { rangeActions } from '$app_reducers/document/slice';
+
+export enum MentionType {
+  PAGE = 'page',
+}
+export const openMention = createAsyncThunk('document/mention/open', async (payload: { docId: string }, thunkAPI) => {
+  const { docId } = payload;
+  const { dispatch, getState } = thunkAPI;
+  const state = getState() as RootState;
+  const rangeState = state[RANGE_NAME][docId];
+  const { caret } = rangeState;
+  if (!caret) return;
+  const { id, index } = caret;
+  const node = state[DOCUMENT_NAME][docId].nodes[id];
+  if (!node.parent) {
+    return;
+  }
+  const nodeDelta = new Delta(node.data?.delta);
+
+  const beforeDelta = nodeDelta.slice(0, index);
+  const beforeText = getDeltaText(beforeDelta);
+  let canOpenMention = !beforeText;
+  if (!canOpenMention) {
+    if (index === 1) {
+      canOpenMention = beforeText.endsWith('@');
+    } else {
+      canOpenMention = beforeText.endsWith(' ');
+    }
+  }
+
+  if (!canOpenMention) return;
+
+  dispatch(
+    mentionActions.open({
+      docId,
+      blockId: id,
+    })
+  );
+});
+
+export const formatMention = createAsyncThunk(
+  'document/mention/format',
+  async (
+    payload: { controller: DocumentController; type: MentionType; value: string; searchTextLength: number },
+    thunkAPI
+  ) => {
+    const { controller, type, value, searchTextLength } = payload;
+    const docId = controller.documentId;
+    const { dispatch, getState } = thunkAPI;
+    const state = getState() as RootState;
+    const mentionState = state[MENTION_NAME][docId];
+    const { blockId } = mentionState;
+    const rangeState = state[RANGE_NAME][docId];
+    const caret = rangeState.caret;
+    if (!caret) return;
+    const index = caret.index - searchTextLength;
+
+    const node = state[DOCUMENT_NAME][docId].nodes[blockId];
+    const nodeDelta = new Delta(node.data?.delta);
+    const diffDelta = new Delta()
+      .retain(index)
+      .delete(searchTextLength)
+      .insert(`@`, {
+        mention: {
+          type,
+          [type]: value,
+        },
+      });
+    const newDelta = nodeDelta.compose(diffDelta);
+    const updateAction = controller.getUpdateAction({
+      ...node,
+      data: {
+        ...node.data,
+        delta: newDelta.ops,
+      },
+    });
+
+    await controller.applyActions([updateAction]);
+
+    dispatch(rangeActions.initialState(docId));
+    dispatch(rangeActions.setCaret({ docId, caret: { id: blockId, index, length: 0 } }));
+  }
+);

+ 36 - 0
frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/mention_slice.ts

@@ -0,0 +1,36 @@
+import { MENTION_NAME } from '$app/constants/document/name';
+import { createSlice } from '@reduxjs/toolkit';
+
+export interface MentionState {
+  open: boolean;
+  blockId: string;
+}
+const initialState: Record<string, MentionState> = {};
+
+export const mentionSlice = createSlice({
+  name: MENTION_NAME,
+  initialState,
+  reducers: {
+    open: (
+      state,
+      action: {
+        payload: {
+          docId: string;
+          blockId: string;
+        };
+      }
+    ) => {
+      const { docId, blockId } = action.payload;
+      state[docId] = {
+        open: true,
+        blockId,
+      };
+    },
+    close: (state, action: { payload: { docId: string } }) => {
+      const { docId } = action.payload;
+      delete state[docId];
+    },
+  },
+});
+
+export const mentionActions = mentionSlice.actions;

+ 2 - 0
frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/slice.ts

@@ -14,6 +14,7 @@ import { temporarySlice } from '$app_reducers/document/temporary_slice';
 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';
+import { mentionSlice } from '$app_reducers/document/mention_slice';
 
 const initialState: Record<string, DocumentState> = {};
 
@@ -386,6 +387,7 @@ export const documentReducers = {
   [slashCommandSlice.name]: slashCommandSlice.reducer,
   [temporarySlice.name]: temporarySlice.reducer,
   [blockEditSlice.name]: blockEditSlice.reducer,
+  [mentionSlice.name]: mentionSlice.reducer,
 };
 
 export const documentActions = documentSlice.actions;

+ 0 - 1
frontend/appflowy_tauri/src/appflowy_app/utils/document/slate_editor.ts

@@ -107,7 +107,6 @@ export function convertToSlateValue(delta: Delta): Descendant[] {
 export function convertToDelta(slateValue: Descendant[]) {
   const ops = (slateValue[0] as Element).children.map((child) => {
     const { text, ...attributes } = child as Text;
-
     return {
       insert: text,
       attributes,

+ 5 - 1
frontend/appflowy_tauri/src/styles/mui.css

@@ -53,6 +53,10 @@
     font-weight: 400 !important;
 }
 
+.MuiTooltip-arrow {
+    color: var(--bg-tips) !important;
+}
+
 .MuiInput-input[class$='-MuiSelect-select-MuiInputBase-input-MuiInput-input']:focus {
     background: transparent;
 }
@@ -65,4 +69,4 @@
 
 .MuiDivider-root.MuiDivider-fullWidth {
     border-color: var(--line-divider);
-}
+}

+ 6 - 1
frontend/appflowy_tauri/src/styles/variables/index.css

@@ -1,2 +1,7 @@
 @import "./light.variables.css";
-@import "./dark.variables.css";
+@import "./dark.variables.css";
+
+:root {
+    /* resize popover shadow */
+    --shadow-resize-popover: 0px 5px 5px -3px rgba(0,0,0,0.2),0px 8px 10px 1px rgba(0,0,0,0.14),0px 3px 14px 2px rgba(0,0,0,0.12);
+}

+ 7 - 0
frontend/resources/translations/en.json

@@ -580,6 +580,13 @@
         "label": "Link Title",
         "placeholder": "Enter link title"
       }
+    },
+    "mention": {
+      "placeholder": "Mention a person or a page or date...",
+      "page": {
+        "label": "Link to page",
+        "tooltip": "Click to open page"
+      }
     }
   },
   "board": {