Browse Source

Support text block add `link` (#2730)

* feat: support text block href attribute

* fix: double click didn't select range

* fix: link update

* chore: ts lint

* chore: add new line

Co-authored-by: Lucas.Xu <[email protected]>

* chore: update get word indices function

---------

Co-authored-by: Lucas.Xu <[email protected]>
Kilu.He 1 year ago
parent
commit
00c0934df6
45 changed files with 981 additions and 210 deletions
  1. 4 2
      frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSelection/BlockRangeSelection.hooks.ts
  2. 6 2
      frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSelection/RangeKeyDown.hooks.ts
  3. 4 4
      frontend/appflowy_tauri/src/appflowy_app/components/document/CodeBlock/index.tsx
  4. 10 7
      frontend/appflowy_tauri/src/appflowy_app/components/document/Node/index.tsx
  5. 2 0
      frontend/appflowy_tauri/src/appflowy_app/components/document/Overlay/index.tsx
  6. 1 1
      frontend/appflowy_tauri/src/appflowy_app/components/document/TextActionMenu/index.hooks.ts
  7. 19 10
      frontend/appflowy_tauri/src/appflowy_app/components/document/TextActionMenu/index.tsx
  8. 23 1
      frontend/appflowy_tauri/src/appflowy_app/components/document/TextActionMenu/menu/FormatButton.tsx
  9. 13 0
      frontend/appflowy_tauri/src/appflowy_app/components/document/TextActionMenu/menu/FormatIcon.tsx
  10. 4 3
      frontend/appflowy_tauri/src/appflowy_app/components/document/TextActionMenu/menu/index.hooks.ts
  11. 1 0
      frontend/appflowy_tauri/src/appflowy_app/components/document/TextActionMenu/menu/index.tsx
  12. 2 10
      frontend/appflowy_tauri/src/appflowy_app/components/document/TextBlock/index.tsx
  13. 0 1
      frontend/appflowy_tauri/src/appflowy_app/components/document/TextBlock/useKeyDown.ts
  14. 3 1
      frontend/appflowy_tauri/src/appflowy_app/components/document/TextBlock/useTurnIntoBlockEvents.ts
  15. 12 9
      frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/EditorHooks/useSelection.ts
  16. 47 0
      frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/Message/index.tsx
  17. 6 7
      frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SlateEditor/CodeEditor.tsx
  18. 1 43
      frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SlateEditor/CodeElements.tsx
  19. 3 6
      frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SlateEditor/TextEditor.tsx
  20. 55 23
      frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SlateEditor/TextLeaf.tsx
  21. 87 23
      frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SlateEditor/useEditor.ts
  22. 5 7
      frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SlateEditor/useSlateYjs.ts
  23. 3 1
      frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SubscribeNode.hooks.ts
  24. 19 9
      frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SubscribeSelection.hooks.ts
  25. 38 0
      frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/TextLink/EditLink.tsx
  26. 89 0
      frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/TextLink/EditLinkToolbar.tsx
  27. 22 0
      frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/TextLink/LinkButton.tsx
  28. 131 0
      frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/TextLink/LinkEditPopover.tsx
  29. 33 0
      frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/TextLink/LinkHighLight.tsx
  30. 27 0
      frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/TextLink/TextLink.hooks.ts
  31. 76 0
      frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/TextLink/index.tsx
  32. 14 18
      frontend/appflowy_tauri/src/appflowy_app/constants/document/config.ts
  33. 21 1
      frontend/appflowy_tauri/src/appflowy_app/interfaces/document.ts
  34. 1 1
      frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/update.ts
  35. 5 4
      frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/format.ts
  36. 1 0
      frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/index.ts
  37. 5 1
      frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/keydown.ts
  38. 103 0
      frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/link.ts
  39. 1 2
      frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/range.ts
  40. 41 5
      frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/slice.ts
  41. 0 1
      frontend/appflowy_tauri/src/appflowy_app/utils/document/action.ts
  42. 4 0
      frontend/appflowy_tauri/src/appflowy_app/utils/document/copy_paste.ts
  43. 37 3
      frontend/appflowy_tauri/src/appflowy_app/utils/document/node.ts
  44. 0 2
      frontend/appflowy_tauri/src/appflowy_app/utils/document/toolbar.ts
  45. 2 2
      frontend/appflowy_tauri/src/styles/template.css

+ 4 - 2
frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSelection/BlockRangeSelection.hooks.ts

@@ -30,6 +30,7 @@ export function useBlockRangeSelection(container: HTMLDivElement) {
 
   const reset = useCallback(() => {
     dispatch(rangeActions.clearRange());
+    setForward(true);
   }, [dispatch]);
 
   // display caret color
@@ -85,7 +86,6 @@ export function useBlockRangeSelection(container: HTMLDivElement) {
 
   const handleDragStart = useCallback(
     (e: MouseEvent) => {
-      // reset the range
       reset();
       // skip if the target is not a block
       const blockId = getBlockIdByPoint(e.target as HTMLElement);
@@ -150,6 +150,8 @@ export function useBlockRangeSelection(container: HTMLDivElement) {
 
   const handleDragEnd = useCallback(() => {
     if (!isDragging) return;
+    setFocus(null);
+    anchorRef.current = null;
     dispatch(rangeActions.setDragging(false));
   }, [dispatch, isDragging]);
 
@@ -164,7 +166,7 @@ export function useBlockRangeSelection(container: HTMLDivElement) {
       document.removeEventListener('mouseup', handleDragEnd);
       container.removeEventListener('keydown', onKeyDown, true);
     };
-  }, [handleDragStart, handleDragEnd, handleDraging, container, onKeyDown]);
+  }, [reset, handleDragStart, handleDragEnd, handleDraging, container, onKeyDown]);
 
   return null;
 }

+ 6 - 2
frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSelection/RangeKeyDown.hooks.ts

@@ -121,7 +121,9 @@ export function useRangeKeyDown() {
         return;
       }
       const { anchor, focus } = rangeRef.current;
-      if (anchor?.id === focus?.id) {
+      if (!anchor || !focus) return;
+
+      if (anchor.id === focus.id) {
         return;
       }
       e.stopPropagation();
@@ -131,7 +133,9 @@ export function useRangeKeyDown() {
         return;
       }
       const lastEvent = filteredEvents[lastIndex];
-      lastEvent?.handler(e);
+      if (!lastEvent) return;
+      e.preventDefault();
+      lastEvent.handler(e);
     },
     [interceptEvents, rangeRef]
   );

+ 4 - 4
frontend/appflowy_tauri/src/appflowy_app/components/document/CodeBlock/index.tsx

@@ -5,6 +5,7 @@ import { useChange } from '$app/components/document/_shared/EditorHooks/useChang
 import { useKeyDown } from './useKeyDown';
 import CodeEditor from '$app/components/document/_shared/SlateEditor/CodeEditor';
 import { useSelection } from '$app/components/document/_shared/EditorHooks/useSelection';
+import { useSubscribeDecorate } from '$app/components/document/_shared/SubscribeSelection.hooks';
 
 export default function CodeBlock({
   node,
@@ -16,7 +17,8 @@ export default function CodeBlock({
   const onKeyDown = useKeyDown(id);
   const className = props.className ? ` ${props.className}` : '';
   const { value, onChange } = useChange(node);
-  const { onSelectionChange, selection, lastSelection } = useSelection(id);
+  const selectionProps = useSelection(id);
+
   return (
     <div {...props} className={`rounded bg-shade-6 p-6 ${className}`}>
       <div className={'mb-2 w-[100%]'}>
@@ -28,9 +30,7 @@ export default function CodeBlock({
         placeholder={placeholder}
         language={language}
         onKeyDown={onKeyDown}
-        onSelectionChange={onSelectionChange}
-        selection={selection}
-        lastSelection={lastSelection}
+        {...selectionProps}
       />
     </div>
   );

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

@@ -16,6 +16,7 @@ import DividerBlock from '$app/components/document/DividerBlock';
 import CalloutBlock from '$app/components/document/CalloutBlock';
 import BlockOverlay from '$app/components/document/Overlay/BlockOverlay';
 import CodeBlock from '$app/components/document/CodeBlock';
+import { NodeIdContext } from '$app/components/document/_shared/SubscribeNode.hooks';
 
 function NodeComponent({ id, ...props }: { id: string } & React.HTMLAttributes<HTMLDivElement>) {
   const { node, childIds, isSelected, ref } = useNode(id);
@@ -60,13 +61,15 @@ function NodeComponent({ id, ...props }: { id: string } & React.HTMLAttributes<H
   if (!node) return null;
 
   return (
-    <div {...props} ref={ref} data-block-id={node.id} className={`relative ${className}`}>
-      {renderBlock()}
-      <BlockOverlay id={id} />
-      {isSelected ? (
-        <div className='pointer-events-none absolute inset-0 z-[-1] m-[1px] rounded-[4px] bg-[#E0F8FF]' />
-      ) : null}
-    </div>
+    <NodeIdContext.Provider value={id}>
+      <div {...props} ref={ref} data-block-id={node.id} className={`relative ${className}`}>
+        {renderBlock()}
+        <BlockOverlay id={id} />
+        {isSelected ? (
+          <div className='pointer-events-none absolute inset-0 z-[-1] m-[1px] rounded-[4px] bg-[#E0F8FF]' />
+        ) : null}
+      </div>
+    </NodeIdContext.Provider>
   );
 }
 

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

@@ -5,6 +5,7 @@ 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';
 
 export default function Overlay({ container }: { container: HTMLDivElement }) {
   useCopy(container);
@@ -15,6 +16,7 @@ export default function Overlay({ container }: { container: HTMLDivElement }) {
       <TextActionMenu container={container} />
       <BlockSelection container={container} />
       <BlockSlash />
+      <LinkEditPopover />
     </>
   );
 }

+ 1 - 1
frontend/appflowy_tauri/src/appflowy_app/components/document/TextActionMenu/index.hooks.ts

@@ -6,7 +6,7 @@ import { debounce } from '$app/utils/tool';
 
 export function useMenuStyle(container: HTMLDivElement) {
   const ref = useRef<HTMLDivElement | null>(null);
-  const id = useAppSelector((state) => state.documentRange.focus?.id);
+  const id = useAppSelector((state) => state.documentRange.caret?.id);
   const [isScrolling, setIsScrolling] = useState(false);
 
   const reCalculatePosition = useCallback(() => {

+ 19 - 10
frontend/appflowy_tauri/src/appflowy_app/components/document/TextActionMenu/index.tsx

@@ -2,6 +2,7 @@ import { useMenuStyle } from './index.hooks';
 import { useAppSelector } from '$app/stores/store';
 import TextActionMenuList from '$app/components/document/TextActionMenu/menu';
 import BlockPortal from '$app/components/document/BlockPortal';
+import { useMemo } from 'react';
 
 const TextActionComponent = ({ container }: { container: HTMLDivElement }) => {
   const { ref, id } = useMenuStyle(container);
@@ -14,7 +15,7 @@ const TextActionComponent = ({ container }: { container: HTMLDivElement }) => {
         style={{
           opacity: 0,
         }}
-        className='absolute mt-[-6px] inline-flex h-[32px] min-w-[100px] items-stretch overflow-hidden rounded-[8px] bg-[#333] leading-tight shadow-lg transition-opacity duration-100'
+        className='absolute mt-[-6px] inline-flex h-[32px] min-w-[100px] items-stretch overflow-hidden rounded-[8px] bg-black leading-tight text-white shadow-lg transition-opacity duration-100'
         onMouseDown={(e) => {
           // prevent toolbar from taking focus away from editor
           e.preventDefault();
@@ -27,16 +28,24 @@ const TextActionComponent = ({ container }: { container: HTMLDivElement }) => {
   );
 };
 const TextActionMenu = ({ container }: { container: HTMLDivElement }) => {
-  const canShow = useAppSelector((state) => {
-    const { isDragging, focus, anchor, ranges } = state.documentRange;
+  const range = useAppSelector((state) => state.documentRange);
+  const canShow = useMemo(() => {
+    const { isDragging, focus, anchor, ranges, caret } = range;
+    // don't show if dragging
     if (isDragging) return false;
-    if (!focus || !anchor) return false;
-    const isSameLine = anchor.id === focus.id;
-    const anchorRange = ranges[anchor.id];
-    if (!anchorRange) return false;
-    const isCollapsed = isSameLine && anchorRange.length === 0;
-    return !isCollapsed;
-  });
+    // don't show if no focus or anchor
+    if (!caret) return false;
+    const isSameLine = anchor?.id === focus?.id;
+
+    // show toolbar if range has multiple nodes
+    if (!isSameLine) return true;
+    const caretRange = ranges[caret.id];
+    // don't show if no caret range
+    if (!caretRange) return false;
+    // show toolbar if range is not collapsed
+    return caretRange.length > 0;
+  }, [range]);
+
   if (!canShow) return null;
 
   return <TextActionComponent container={container} />;

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

@@ -7,6 +7,7 @@ import { getFormatActiveThunk, toggleFormatThunk } from '$app_reducers/document/
 import { useAppDispatch, useAppSelector } from '$app/stores/store';
 import { DocumentControllerContext } from '$app/stores/effects/document/document_controller';
 import { useSubscribeNode } from '$app/components/document/_shared/SubscribeNode.hooks';
+import { newLinkThunk } from '$app_reducers/document/async-actions/link';
 
 const FormatButton = ({ format, icon }: { format: TextAction; icon: string }) => {
   const dispatch = useAppDispatch();
@@ -25,6 +26,7 @@ const FormatButton = ({ format, icon }: { format: TextAction; icon: string }) =>
       [TextAction.Underline]: 'Underline',
       [TextAction.Strikethrough]: 'Strike through',
       [TextAction.Code]: 'Mark as Code',
+      [TextAction.Link]: 'Add Link',
     }),
     []
   );
@@ -49,6 +51,26 @@ const FormatButton = ({ format, icon }: { format: TextAction; icon: string }) =>
     [controller, dispatch, isActive]
   );
 
+  const addLink = useCallback(() => {
+    dispatch(newLinkThunk());
+  }, [dispatch]);
+
+  const formatClick = useCallback(
+    (format: TextAction) => {
+      switch (format) {
+        case TextAction.Bold:
+        case TextAction.Italic:
+        case TextAction.Underline:
+        case TextAction.Strikethrough:
+        case TextAction.Code:
+          return toggleFormat(format);
+        case TextAction.Link:
+          return addLink();
+      }
+    },
+    [addLink, toggleFormat]
+  );
+
   useEffect(() => {
     void (async () => {
       const isActive = await isFormatActive();
@@ -58,7 +80,7 @@ const FormatButton = ({ format, icon }: { format: TextAction; icon: string }) =>
 
   return (
     <MenuTooltip title={formatTooltips[format]}>
-      <IconButton size='small' sx={{ color }} onClick={() => toggleFormat(format)}>
+      <IconButton size='small' sx={{ color }} onClick={() => formatClick(format)}>
         <FormatIcon icon={icon} />
       </IconButton>
     </MenuTooltip>

+ 13 - 0
frontend/appflowy_tauri/src/appflowy_app/components/document/TextActionMenu/menu/FormatIcon.tsx

@@ -1,6 +1,7 @@
 import React from 'react';
 import { FormatBold, FormatUnderlined, FormatItalic, CodeOutlined, StrikethroughSOutlined } from '@mui/icons-material';
 import { TextAction } from '$app/interfaces/document';
+import LinkIcon from '@mui/icons-material/AddLink';
 export const iconSize = { width: 18, height: 18 };
 
 export default function FormatIcon({ icon }: { icon: string }) {
@@ -15,6 +16,18 @@ export default function FormatIcon({ icon }: { icon: string }) {
       return <CodeOutlined sx={iconSize} />;
     case TextAction.Strikethrough:
       return <StrikethroughSOutlined sx={iconSize} />;
+    case TextAction.Link:
+      return (
+        <div className={'flex items-center justify-center px-1 text-[0.8rem]'}>
+          <LinkIcon
+            sx={{
+              fontSize: '1.2rem',
+              marginRight: '0.25rem',
+            }}
+          />
+          <div className={'underline'}>Link</div>
+        </div>
+      );
     default:
       return null;
   }

+ 4 - 3
frontend/appflowy_tauri/src/appflowy_app/components/document/TextActionMenu/menu/index.hooks.ts

@@ -15,13 +15,14 @@ export function useTextActionMenu() {
   const isSingleLine = useMemo(() => {
     return range.focus?.id === range.anchor?.id;
   }, [range]);
-  const focusId = range.focus?.id;
+  const focusId = range.caret?.id;
 
   const { node } = useSubscribeNode(focusId || '');
 
   const items = useMemo(() => {
+    if (!node) return [];
     if (isSingleLine) {
-      const config = blockConfig[node?.type];
+      const config = blockConfig[node.type];
       const { customItems, excludeItems } = {
         ...defaultTextActionProps,
         ...config.textActionMenuProps,
@@ -30,7 +31,7 @@ export function useTextActionMenu() {
     } else {
       return multiLineTextActionProps.customItems || [];
     }
-  }, [isSingleLine, node?.type]);
+  }, [isSingleLine, node]);
 
   // the groups have default items, so we need to filter the items if this node has excluded items
   const groupItems: TextAction[][] = useMemo(() => {

+ 1 - 0
frontend/appflowy_tauri/src/appflowy_app/components/document/TextActionMenu/menu/index.tsx

@@ -11,6 +11,7 @@ function TextActionMenuList() {
       switch (action) {
         case TextAction.Turn:
           return isSingleLine && focusId ? <TurnIntoSelect id={focusId} /> : null;
+        case TextAction.Link:
         case TextAction.Bold:
         case TextAction.Italic:
         case TextAction.Underline:

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

@@ -13,20 +13,12 @@ interface Props {
 }
 function TextBlock({ node, childIds, placeholder }: Props) {
   const { value, onChange } = useChange(node);
-  const { onSelectionChange, selection, lastSelection } = useSelection(node.id);
+  const selectionProps = useSelection(node.id);
   const { onKeyDown } = useKeyDown(node.id);
 
   return (
     <>
-      <Editor
-        value={value}
-        onChange={onChange}
-        onSelectionChange={onSelectionChange}
-        selection={selection}
-        lastSelection={lastSelection}
-        onKeyDown={onKeyDown}
-        placeholder={placeholder}
-      />
+      <Editor value={value} onChange={onChange} {...selectionProps} onKeyDown={onKeyDown} placeholder={placeholder} />
       <NodeChildren className='pl-[1.5em]' childIds={childIds} />
     </>
   );

+ 0 - 1
frontend/appflowy_tauri/src/appflowy_app/components/document/TextBlock/useKeyDown.ts

@@ -89,7 +89,6 @@ export function useKeyDown(id: string) {
   const onKeyDown = useCallback(
     (e: React.KeyboardEvent<HTMLDivElement>) => {
       e.stopPropagation();
-
       const filteredEvents = interceptEvents.filter((event) => event.canHandle(e));
       filteredEvents.forEach((event) => event.handler(e));
     },

+ 3 - 1
frontend/appflowy_tauri/src/appflowy_app/components/document/TextBlock/useTurnIntoBlockEvents.ts

@@ -74,8 +74,10 @@ export function useTurnIntoBlockEvents(id: string) {
       [BlockType.HeadingBlock]: () => {
         const flag = getFlag();
         if (!flag) return;
+        const level = flag.match(/#/g)?.length;
+        if (!level || level > 3) return;
         return {
-          level: flag.match(/#/g)?.length,
+          level,
           ...getTurnIntoBlockDelta(),
         };
       },

+ 12 - 9
frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/EditorHooks/useSelection.ts

@@ -2,12 +2,17 @@ import { useCallback, useEffect, useState } from 'react';
 import { RangeStatic } from 'quill';
 import { useAppDispatch } from '$app/stores/store';
 import { rangeActions } from '$app_reducers/document/slice';
-import { useFocused, useRangeRef } from '$app/components/document/_shared/SubscribeSelection.hooks';
+import {
+  useFocused,
+  useRangeRef,
+  useSubscribeDecorate,
+} from '$app/components/document/_shared/SubscribeSelection.hooks';
 import { storeRangeThunk } from '$app_reducers/document/async-actions/range';
 
 export function useSelection(id: string) {
   const rangeRef = useRangeRef();
-  const { focusCaret, lastSelection } = useFocused(id);
+  const { focusCaret } = useFocused(id);
+  const decorateProps = useSubscribeDecorate(id);
   const [selection, setSelection] = useState<RangeStatic | undefined>(undefined);
   const dispatch = useAppDispatch();
 
@@ -21,7 +26,6 @@ export function useSelection(id: string) {
   const onSelectionChange = useCallback(
     (range: RangeStatic | null, _oldRange: RangeStatic | null, _source?: string) => {
       if (!range) return;
-
       dispatch(
         rangeActions.setCaret({
           id,
@@ -36,20 +40,19 @@ export function useSelection(id: string) {
 
   useEffect(() => {
     if (rangeRef.current && rangeRef.current?.isDragging) return;
-    const caret = focusCaret;
-    if (!caret) {
+    if (!focusCaret) {
+      setSelection(undefined);
       return;
     }
-
     setSelection({
-      index: caret.index,
-      length: caret.length,
+      index: focusCaret.index,
+      length: focusCaret.length,
     });
   }, [rangeRef, focusCaret]);
 
   return {
     onSelectionChange,
     selection,
-    lastSelection,
+    ...decorateProps,
   };
 }

+ 47 - 0
frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/Message/index.tsx

@@ -0,0 +1,47 @@
+import React, { useCallback, useMemo, useState } from 'react';
+import { Portal, Snackbar } from '@mui/material';
+import { TransitionProps } from '@mui/material/transitions';
+import Slide, { SlideProps } from '@mui/material/Slide';
+
+function SlideTransition(props: SlideProps) {
+  return <Slide {...props} direction='up' />;
+}
+
+interface MessageProps {
+  message?: string;
+  key?: string;
+  duration?: number;
+}
+export function useMessage() {
+  const [state, setState] = useState<MessageProps>();
+  const show = useCallback((message: MessageProps) => {
+    setState(message);
+  }, []);
+  const hide = useCallback(() => {
+    setState(undefined);
+  }, []);
+
+  const contentHolder = useMemo(() => {
+    const open = !!state;
+    return (
+      <Portal>
+        <Snackbar
+          autoHideDuration={state?.duration}
+          anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
+          open={open}
+          onClose={hide}
+          TransitionProps={{ onExited: hide }}
+          message={state?.message}
+          key={state?.key}
+          TransitionComponent={SlideTransition}
+        />
+      </Portal>
+    );
+  }, [hide, state]);
+
+  return {
+    show,
+    hide,
+    contentHolder,
+  };
+}

+ 6 - 7
frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SlateEditor/CodeEditor.tsx

@@ -3,10 +3,11 @@ import { CodeEditorProps } from '$app/interfaces/document';
 import { Editable, Slate } from 'slate-react';
 import { useEditor } from '$app/components/document/_shared/SlateEditor/useEditor';
 import { decorateCode } from '$app/components/document/_shared/SlateEditor/decorateCode';
-import { CodeLeaf, CodeBlockElement } from '$app/components/document/_shared/SlateEditor/CodeElements';
+import { CodeBlockElement } from '$app/components/document/_shared/SlateEditor/CodeElements';
+import TextLeaf from '$app/components/document/_shared/SlateEditor/TextLeaf';
 
 function CodeEditor({ language, ...props }: CodeEditorProps) {
-  const { editor, onChange, value, onDOMBeforeInput, decorate, ref, onKeyDown, onBlur } = useEditor({
+  const { editor, onChange, value, ref, ...editableProps } = useEditor({
     ...props,
     isCodeBlock: true,
   });
@@ -15,16 +16,14 @@ function CodeEditor({ language, ...props }: CodeEditorProps) {
     <div ref={ref}>
       <Slate editor={editor} onChange={onChange} value={value}>
         <Editable
+          {...editableProps}
           decorate={(entry) => {
             const codeRange = decorateCode(entry, language);
-            const range = decorate?.(entry) || [];
+            const range = editableProps.decorate?.(entry) || [];
             return [...range, ...codeRange];
           }}
-          renderLeaf={CodeLeaf}
+          renderLeaf={(leafProps) => <TextLeaf editor={editor} {...leafProps} isCodeBlock={true} />}
           renderElement={CodeBlockElement}
-          onKeyDown={onKeyDown}
-          onDOMBeforeInput={onDOMBeforeInput}
-          onBlur={onBlur}
         />
       </Slate>
     </div>

+ 1 - 43
frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SlateEditor/CodeElements.tsx

@@ -1,46 +1,4 @@
-import { RenderLeafProps, RenderElementProps } from 'slate-react';
-import { BaseText } from 'slate';
-
-interface CodeLeafProps extends RenderLeafProps {
-  leaf: BaseText & {
-    bold?: boolean;
-    italic?: boolean;
-    underline?: boolean;
-    strikethrough?: boolean;
-    prism_token?: string;
-    selection_high_lighted?: boolean;
-  };
-}
-
-export const CodeLeaf = (props: CodeLeafProps) => {
-  const { attributes, children, leaf } = props;
-
-  let newChildren = children;
-  if (leaf.bold) {
-    newChildren = <strong>{children}</strong>;
-  }
-
-  if (leaf.italic) {
-    newChildren = <em>{newChildren}</em>;
-  }
-
-  if (leaf.underline) {
-    newChildren = <u>{newChildren}</u>;
-  }
-
-  const className = [
-    'token',
-    leaf.prism_token && leaf.prism_token,
-    leaf.strikethrough && 'line-through',
-    leaf.selection_high_lighted && 'bg-main-secondary',
-  ].filter(Boolean);
-
-  return (
-    <span {...attributes} className={className.join(' ')}>
-      {newChildren}
-    </span>
-  );
-};
+import { RenderElementProps } from 'slate-react';
 
 export const CodeBlockElement = (props: RenderElementProps) => {
   return (

+ 3 - 6
frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SlateEditor/TextEditor.tsx

@@ -6,19 +6,16 @@ import TextLeaf from '$app/components/document/_shared/SlateEditor/TextLeaf';
 import { TextElement } from '$app/components/document/_shared/SlateEditor/TextElement';
 
 function TextEditor({ placeholder = "Type '/' for commands", ...props }: EditorProps) {
-  const { editor, onChange, value, onDOMBeforeInput, decorate, ref, onKeyDown, onBlur } = useEditor(props);
+  const { editor, onChange, value, ref, ...editableProps } = useEditor(props);
 
   return (
     <div ref={ref} className={'py-0.5'}>
       <Slate editor={editor} onChange={onChange} value={value}>
         <Editable
-          onKeyDown={onKeyDown}
-          onDOMBeforeInput={onDOMBeforeInput}
-          decorate={decorate}
-          renderLeaf={TextLeaf}
+          renderLeaf={(leafProps) => <TextLeaf {...leafProps} editor={editor} />}
           placeholder={placeholder}
-          onBlur={onBlur}
           renderElement={TextElement}
+          {...editableProps}
         />
       </Slate>
     </div>

+ 55 - 23
frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SlateEditor/TextLeaf.tsx

@@ -1,35 +1,33 @@
-import { RenderLeafProps } from 'slate-react';
+import { ReactEditor, RenderLeafProps } from 'slate-react';
 import { BaseText } from 'slate';
-import { useRef } from 'react';
+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';
 
+interface Attributes {
+  bold?: boolean;
+  italic?: boolean;
+  underline?: boolean;
+  strikethrough?: boolean;
+  code?: string;
+  selection_high_lighted?: boolean;
+  href?: string;
+  prism_token?: string;
+  link_selection_lighted?: boolean;
+  link_placeholder?: string;
+}
 interface TextLeafProps extends RenderLeafProps {
-  leaf: BaseText & {
-    bold?: boolean;
-    italic?: boolean;
-    underline?: boolean;
-    strikethrough?: boolean;
-    code?: string;
-    selection_high_lighted?: boolean;
-  };
+  leaf: BaseText & Attributes;
+  isCodeBlock?: boolean;
+  editor: ReactEditor;
 }
 
 const TextLeaf = (props: TextLeafProps) => {
-  const { attributes, children, leaf } = props;
-
+  const { attributes, children, leaf, isCodeBlock, editor } = props;
   const ref = useRef<HTMLSpanElement>(null);
 
   let newChildren = children;
-  if (leaf.bold) {
-    newChildren = <strong>{children}</strong>;
-  }
-
-  if (leaf.italic) {
-    newChildren = <em>{newChildren}</em>;
-  }
-
-  if (leaf.underline) {
-    newChildren = <u>{newChildren}</u>;
-  }
 
   if (leaf.code) {
     newChildren = (
@@ -45,12 +43,46 @@ const TextLeaf = (props: TextLeafProps) => {
     );
   }
 
+  const getSelection = useCallback(
+    (node: Element) => {
+      const slateNode = ReactEditor.toSlateNode(editor, node);
+      const path = ReactEditor.findPath(editor, slateNode);
+      const selection = converToIndexLength(editor, {
+        anchor: { path, offset: 0 },
+        focus: { path, offset: leaf.text.length },
+      });
+      return selection;
+    },
+    [editor, leaf]
+  );
+
+  if (leaf.href) {
+    newChildren = (
+      <TextLink getSelection={getSelection} title={leaf.text} href={leaf.href}>
+        {newChildren}
+      </TextLink>
+    );
+  }
+
   const className = [
+    isCodeBlock && 'token',
+    leaf.prism_token && leaf.prism_token,
     leaf.strikethrough && 'line-through',
     leaf.selection_high_lighted && 'bg-main-secondary',
+    leaf.link_selection_lighted && 'text-link bg-main-secondary',
     leaf.code && '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>
+    );
+  }
   return (
     <span ref={ref} {...attributes} className={className.join(' ')}>
       {newChildren}

+ 87 - 23
frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SlateEditor/useEditor.ts

@@ -9,7 +9,7 @@ import {
   indent,
   outdent,
 } from '$app/utils/document/slate_editor';
-import { focusNodeByIndex } from '$app/utils/document/node';
+import { focusNodeByIndex, getWordIndices } from '$app/utils/document/node';
 import { Keyboard } from '$app/constants/document/keyboard';
 import Delta from 'quill-delta';
 import isHotkey from 'is-hotkey';
@@ -20,13 +20,13 @@ export function useEditor({
   onSelectionChange,
   selection,
   value: delta,
-  lastSelection,
+  decorateSelection,
   onKeyDown,
   isCodeBlock,
+  linkDecorateSelection,
 }: EditorProps) {
-  const editor = useSlateYjs({ delta });
+  const { editor } = useSlateYjs({ delta });
   const ref = useRef<HTMLDivElement | null>(null);
-
   const newValue = useMemo(() => [], []);
   const onSelectionChangeHandler = useCallback(
     (slateSelection: Selection) => {
@@ -42,7 +42,7 @@ export function useEditor({
       onChange?.(convertToDelta(slateValue), oldContents);
       onSelectionChangeHandler(editor.selection);
     },
-    [delta, editor.selection, onChange, onSelectionChangeHandler]
+    [delta, editor, onChange, onSelectionChangeHandler]
   );
 
   const onDOMBeforeInput = useCallback((e: InputEvent) => {
@@ -54,27 +54,50 @@ export function useEditor({
     }
   }, []);
 
+  const getDecorateRange = useCallback(
+    (
+      path: number[],
+      selection:
+        | {
+            index: number;
+            length: number;
+          }
+        | undefined,
+      value: Record<string, boolean | string | undefined>
+    ) => {
+      if (!selection) return null;
+      const range = convertToSlateSelection(selection.index, selection.length, editor.children) as BaseRange;
+      if (range && !Range.isCollapsed(range)) {
+        const intersection = Range.intersection(range, Editor.range(editor, path));
+        if (intersection) {
+          return {
+            ...intersection,
+            ...value,
+          };
+        }
+      }
+      return null;
+    },
+    [editor]
+  );
+
   const decorate = useCallback(
     (entry: NodeEntry) => {
       const [node, path] = entry;
-      if (!lastSelection) return [];
-      const slateSelection = convertToSlateSelection(lastSelection.index, lastSelection.length, editor.children);
-      if (slateSelection && !Range.isCollapsed(slateSelection as BaseRange)) {
-        const intersection = Range.intersection(slateSelection, Editor.range(editor, path));
 
-        if (!intersection) {
-          return [];
-        }
-        const range = {
+      const ranges: Range[] = [
+        getDecorateRange(path, decorateSelection, {
           selection_high_lighted: true,
-          ...intersection,
-        };
+        }),
+        getDecorateRange(path, linkDecorateSelection?.selection, {
+          link_selection_lighted: true,
+          link_placeholder: linkDecorateSelection?.placeholder,
+        }),
+      ].filter((range) => range !== null) as Range[];
 
-        return [range];
-      }
-      return [];
+      return ranges;
     },
-    [editor, lastSelection]
+    [decorateSelection, linkDecorateSelection, getDecorateRange]
   );
 
   const onKeyDownRewrite = useCallback(
@@ -116,14 +139,53 @@ export function useEditor({
     [editor]
   );
 
+  // This is a hack to fix the bug that the editor decoration is updated cause selection is lost
+  const onMouseDownCapture = useCallback(
+    (event: React.MouseEvent) => {
+      editor.deselect();
+      requestAnimationFrame(() => {
+        const range = document.caretRangeFromPoint(event.clientX, event.clientY);
+        if (!range) return;
+        const selection = window.getSelection();
+        if (!selection) return;
+        selection.removeAllRanges();
+        selection.addRange(range);
+      });
+    },
+    [editor]
+  );
+
+  // double click to select a word
+  // This is a hack to fix the bug that mouse down event deselect the selection
+  const onDoubleClick = useCallback((event: React.MouseEvent) => {
+    const selection = window.getSelection();
+    if (!selection) return;
+    const range = selection.rangeCount > 0 ? selection.getRangeAt(0) : null;
+    if (!range) return;
+    const node = range.startContainer;
+    const offset = range.startOffset;
+    const wordIndices = getWordIndices(node, offset);
+    if (wordIndices.length === 0) return;
+    range.setStart(node, wordIndices[0].startIndex);
+    range.setEnd(node, wordIndices[0].endIndex);
+    selection.removeAllRanges();
+    selection.addRange(range);
+  }, []);
+
   useEffect(() => {
-    if (!selection || !ref.current) return;
+    if (!ref.current) return;
+    const isFocused = ReactEditor.isFocused(editor);
+    if (!selection) {
+      isFocused && editor.deselect();
+      return;
+    }
     const slateSelection = convertToSlateSelection(selection.index, selection.length, editor.children);
     if (!slateSelection) return;
-    const isFocused = ReactEditor.isFocused(editor);
     if (isFocused && JSON.stringify(slateSelection) === JSON.stringify(editor.selection)) return;
-    focusNodeByIndex(ref.current, selection.index, selection.length);
-    Transforms.select(editor, slateSelection);
+    const isSuccess = focusNodeByIndex(ref.current, selection.index, selection.length);
+    if (!isSuccess) {
+      Transforms.select(editor, slateSelection);
+    }
   }, [editor, selection]);
 
   return {
@@ -135,5 +197,7 @@ export function useEditor({
     ref,
     onKeyDown: onKeyDownRewrite,
     onBlur,
+    onMouseDownCapture,
+    onDoubleClick,
   };
 }

+ 5 - 7
frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SlateEditor/useSlateYjs.ts

@@ -1,5 +1,5 @@
 import Delta from 'quill-delta';
-import { useEffect, useMemo, useRef } from 'react';
+import { useEffect, useMemo, useState } from 'react';
 import * as Y from 'yjs';
 import { convertToSlateValue } from '$app/utils/document/slate_editor';
 import { slateNodesToInsertDelta, withYjs, YjsEditor } from '@slate-yjs/core';
@@ -7,14 +7,14 @@ import { withReact } from 'slate-react';
 import { createEditor } from 'slate';
 
 export function useSlateYjs({ delta }: { delta?: Delta }) {
-  const yTextRef = useRef<Y.Text>();
+  const [yText, setYText] = useState<Y.Text | undefined>(undefined);
   const sharedType = useMemo(() => {
     const yDoc = new Y.Doc();
     const sharedType = yDoc.get('content', Y.XmlText) as Y.XmlText;
     const value = convertToSlateValue(delta || new Delta());
     const insertDelta = slateNodesToInsertDelta(value);
     sharedType.applyDelta(insertDelta);
-    yTextRef.current = insertDelta[0].insert as Y.Text;
+    setYText(insertDelta[0].insert as Y.Text);
     return sharedType;
     // eslint-disable-next-line react-hooks/exhaustive-deps
   }, []);
@@ -25,19 +25,17 @@ export function useSlateYjs({ delta }: { delta?: Delta }) {
   useEffect(() => {
     YjsEditor.connect(editor);
     return () => {
-      yTextRef.current = undefined;
       YjsEditor.disconnect(editor);
     };
   }, [editor]);
 
   useEffect(() => {
-    const yText = yTextRef.current;
     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]);
+  }, [delta, editor, yText]);
 
-  return editor;
+  return { editor };
 }

+ 3 - 1
frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SubscribeNode.hooks.ts

@@ -1,5 +1,5 @@
 import { store, useAppSelector } from '@/appflowy_app/stores/store';
-import { useEffect, useMemo, useRef } from 'react';
+import { createContext, useEffect, useMemo, useRef } from 'react';
 import { Node } from '$app/interfaces/document';
 
 /**
@@ -35,3 +35,5 @@ export function useSubscribeNode(id: string) {
 export function getBlock(id: string) {
   return store.getState().document.nodes[id];
 }
+
+export const NodeIdContext = createContext<string>('');

+ 19 - 9
frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SubscribeSelection.hooks.ts

@@ -2,6 +2,25 @@ import { useAppSelector } from '$app/stores/store';
 import { RangeState, RangeStatic } from '$app/interfaces/document';
 import { useMemo, useRef } from 'react';
 
+export function useSubscribeDecorate(id: string) {
+  const decorateSelection = useAppSelector((state) => {
+    return state.documentRange.ranges[id];
+  });
+
+  const linkDecorateSelection = useAppSelector((state) => {
+    const linkPopoverState = state.documentLinkPopover;
+    if (!linkPopoverState.open || linkPopoverState.id !== id) return;
+    return {
+      selection: linkPopoverState.selection,
+      placeholder: linkPopoverState.title,
+    };
+  });
+
+  return {
+    decorateSelection,
+    linkDecorateSelection,
+  };
+}
 export function useFocused(id: string) {
   const caretRef = useRef<RangeStatic>();
   const focusCaret = useAppSelector((state) => {
@@ -13,23 +32,14 @@ export function useFocused(id: string) {
     return null;
   });
 
-  const lastSelection = useAppSelector((state) => {
-    return state.documentRange.ranges[id];
-  });
-
   const focused = useMemo(() => {
     return focusCaret && focusCaret?.id === id;
   }, [focusCaret, id]);
 
-  const memoizedLastSelection = useMemo(() => {
-    return lastSelection;
-  }, [JSON.stringify(lastSelection)]);
-
   return {
     focused,
     caretRef,
     focusCaret,
-    lastSelection: memoizedLastSelection,
   };
 }
 

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

@@ -0,0 +1,38 @@
+import React, { useEffect, useState } from 'react';
+
+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 text-sm'}>
+      <div className={'mb-1 text-shade-2'}>{text}</div>
+      <div className={'flex rounded border bg-main-selector p-1 focus-within:border-main-hovered'}>
+        <input
+          autoFocus={autoFocus}
+          className={'flex-1 outline-none'}
+          onChange={(e) => {
+            const newValue = e.target.value;
+            setVal(newValue);
+          }}
+          value={val}
+        />
+      </div>
+    </div>
+  );
+}
+
+export default EditLink;

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

@@ -0,0 +1,89 @@
+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';
+
+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 { 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-white leading-tight text-black 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: 'Copied!', duration: 6000 });
+                  } catch {
+                    show({ message: 'Copy failed!', duration: 6000 });
+                  }
+                }}
+                className={'mr-2 cursor-pointer'}
+              >
+                <CopyIcon sx={iconSize} />
+              </div>
+              <div onClick={onEdit} className={'cursor-pointer'}>
+                Edit
+              </div>
+            </div>
+          </div>
+        </BlockPortal>
+      )}
+      {contentHolder}
+    </>
+  );
+}
+
+export default EditLinkToolbar;

+ 22 - 0
frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/TextLink/LinkButton.tsx

@@ -0,0 +1,22 @@
+import React from 'react';
+import Button from '@mui/material/Button';
+import { DeleteOutline } from '@mui/icons-material';
+
+function LinkButton({ icon, title, onClick }: { icon: React.ReactNode; title: string; onClick: () => void }) {
+  return (
+    <div className={'pt-1'}>
+      <Button
+        className={'w-[100%]'}
+        style={{
+          justifyContent: 'flex-start',
+        }}
+        startIcon={icon}
+        onClick={onClick}
+      >
+        {title}
+      </Button>
+    </div>
+  );
+}
+
+export default LinkButton;

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

@@ -0,0 +1,131 @@
+import React, { useCallback, useContext } from 'react';
+import Popover from '@mui/material/Popover';
+import { Divider } from '@mui/material';
+import { DeleteOutline, Done } from '@mui/icons-material';
+import EditLink from '$app/components/document/_shared/TextLink/EditLink';
+import { useAppDispatch, useAppSelector } from '$app/stores/store';
+import { linkPopoverActions, rangeActions } from '$app_reducers/document/slice';
+import { DocumentControllerContext } from '$app/stores/effects/document/document_controller';
+import { updateLinkThunk } from '$app_reducers/document/async-actions';
+import { formatLinkThunk } from '$app_reducers/document/async-actions/link';
+import LinkButton from '$app/components/document/_shared/TextLink/LinkButton';
+
+function LinkEditPopover() {
+  const dispatch = useAppDispatch();
+  const controller = useContext(DocumentControllerContext);
+  const popoverState = useAppSelector((state) => state.documentLinkPopover);
+  const { anchorPosition, id, selection, title = '', href = '', open = false } = popoverState;
+
+  const onClose = useCallback(() => {
+    dispatch(linkPopoverActions.closeLinkPopover());
+  }, [dispatch]);
+
+  const onExited = useCallback(() => {
+    if (!id || !selection) return;
+    const newSelection = {
+      index: selection.index,
+      length: title.length,
+    };
+    dispatch(
+      rangeActions.setRange({
+        id,
+        rangeStatic: newSelection,
+      })
+    );
+    dispatch(
+      rangeActions.setCaret({
+        id,
+        ...newSelection,
+      })
+    );
+  }, [id, selection, title, dispatch]);
+
+  const onChange = useCallback(
+    (newVal: { href?: string; title: string }) => {
+      if (!id) return;
+      if (newVal.title === title && newVal.href === href) return;
+      dispatch(
+        updateLinkThunk({
+          id,
+          href: newVal.href,
+          title: newVal.title,
+        })
+      );
+    },
+    [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={'URL'}
+          value={href}
+          onChange={(link) => {
+            onChange({
+              href: link,
+              title,
+            });
+          }}
+        />
+        <EditLink
+          text={'Link title'}
+          value={title}
+          onChange={(text) =>
+            onChange({
+              href,
+              title: text,
+            })
+          }
+        />
+        <Divider />
+        <LinkButton
+          title={'Remove link'}
+          icon={<DeleteOutline />}
+          onClick={() => {
+            onChange({
+              title,
+            });
+            onDone();
+          }}
+        />
+        <LinkButton title={'Done'} icon={<Done />} onClick={onDone} />
+      </div>
+    </Popover>
+  );
+}
+
+export default LinkEditPopover;

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

@@ -0,0 +1,33 @@
+import React from 'react';
+
+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
+        style={{
+          display: 'none',
+        }}
+      >
+        {children}
+      </span>
+    </>
+  );
+}
+
+export default LinkHighLight;
+
+function isOverlappingPrefix(first: string, second: string): boolean {
+  if (first.length === 0 || second.length === 0) return false;
+  let i = 0;
+  while (i < first.length) {
+    const chars = first.substring(i);
+    if (chars.length > second.length) return false;
+    if (second.startsWith(chars)) return true;
+    i++;
+  }
+  return false;
+}

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

@@ -0,0 +1,27 @@
+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,
+  };
+}

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

@@ -0,0 +1,76 @@
+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';
+
+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 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({
+        anchorPosition: {
+          top: rect.top + rect.height,
+          left: rect.left + rect.width / 2,
+        },
+        id: blockId,
+        selection,
+        title,
+        href,
+        open: true,
+      })
+    );
+  }, [blockId, dispatch, 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-main-hovered'
+      >
+        <span className={' border-b-[1px] border-b-main-hovered '}>{children}</span>
+      </a>
+      {ref.current && (
+        <EditLinkToolbar
+          editing={editing}
+          href={href}
+          onMouseLeave={onMouseLeave}
+          onMouseEnter={onMouseEnter}
+          linkElement={ref.current}
+          blockId={blockId}
+          onEdit={onEdit}
+        />
+      )}
+    </>
+  );
+}
+
+export default TextLink;

+ 14 - 18
frontend/appflowy_tauri/src/appflowy_app/constants/document/config.ts

@@ -144,6 +144,7 @@ export const blockConfig: Record<string, BlockConfig> = {
 export const defaultTextActionProps: TextActionMenuProps = {
   customItems: [
     TextAction.Turn,
+    TextAction.Link,
     TextAction.Bold,
     TextAction.Italic,
     TextAction.Underline,
@@ -154,12 +155,9 @@ export const defaultTextActionProps: TextActionMenuProps = {
   excludeItems: [],
 };
 
-export const multiLineTextActionProps: TextActionMenuProps = {
-  customItems: [TextAction.Bold, TextAction.Italic, TextAction.Underline, TextAction.Strikethrough, TextAction.Code],
-};
-
-export const multiLineTextActionGroups = [
-  [
+const groupKeys = {
+  comment: [],
+  format: [
     TextAction.Bold,
     TextAction.Italic,
     TextAction.Underline,
@@ -167,16 +165,14 @@ export const multiLineTextActionGroups = [
     TextAction.Code,
     TextAction.Equation,
   ],
-];
+  link: [TextAction.Link],
+  turn: [TextAction.Turn],
+};
 
-export const textActionGroups = [
-  [TextAction.Turn],
-  [
-    TextAction.Bold,
-    TextAction.Italic,
-    TextAction.Underline,
-    TextAction.Strikethrough,
-    TextAction.Code,
-    TextAction.Equation,
-  ],
-];
+export const multiLineTextActionProps: TextActionMenuProps = {
+  customItems: [TextAction.Bold, TextAction.Italic, TextAction.Underline, TextAction.Strikethrough, TextAction.Code],
+};
+
+export const multiLineTextActionGroups = [groupKeys.format];
+
+export const textActionGroups = [groupKeys.turn, groupKeys.format, groupKeys.link];

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

@@ -184,6 +184,7 @@ export enum TextAction {
   Strikethrough = 'strikethrough',
   Code = 'code',
   Equation = 'equation',
+  Link = 'href',
 }
 export interface TextActionMenuProps {
   /**
@@ -253,7 +254,14 @@ export interface EditorProps {
   placeholder?: string;
   value?: Delta;
   selection?: RangeStaticNoId;
-  lastSelection?: RangeStaticNoId;
+  decorateSelection?: RangeStaticNoId;
+  linkDecorateSelection?: {
+    selection?: {
+      index: number;
+      length: number;
+    };
+    placeholder?: string;
+  };
   onSelectionChange?: (range: RangeStaticNoId | null, oldRange: RangeStaticNoId | null, source?: Sources) => void;
   onChange?: (delta: Delta, oldDelta: Delta, source?: Sources) => void;
   onKeyDown?: (event: React.KeyboardEvent<HTMLDivElement>) => void;
@@ -264,3 +272,15 @@ export interface BlockCopyData {
   text: string;
   html: string;
 }
+
+export interface LinkPopoverState {
+  anchorPosition?: { top: number; left: number };
+  id?: string;
+  selection?: {
+    index: number;
+    length: number;
+  };
+  open?: boolean;
+  href?: string;
+  title?: string;
+}

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

@@ -1,4 +1,4 @@
-import { DocumentState, BlockData } from '$app/interfaces/document';
+import { BlockData, DocumentState } from '$app/interfaces/document';
 import { DocumentController } from '$app/stores/effects/document/document_controller';
 import { createAsyncThunk } from '@reduxjs/toolkit';
 import Delta, { Op } from 'quill-delta';

+ 5 - 4
frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/format.ts

@@ -12,7 +12,7 @@ export const getFormatActiveThunk = createAsyncThunk<boolean, TextAction>(
     const { document, documentRange } = state;
     const { ranges } = documentRange;
     const match = (delta: Delta, format: TextAction) => {
-      return delta.ops.every((op) => op.attributes?.[format] === true);
+      return delta.ops.every((op) => op.attributes?.[format]);
     };
     return Object.entries(ranges).every(([id, range]) => {
       const node = document.nodes[id];
@@ -36,15 +36,16 @@ export const toggleFormatThunk = createAsyncThunk(
       const { payload: active } = await dispatch(getFormatActiveThunk(format));
       isActive = !!active;
     }
+    const formatValue = isActive ? undefined : true;
     const state = getState() as RootState;
     const { document } = state;
     const { ranges } = state.documentRange;
 
-    const toggle = (delta: Delta, format: TextAction) => {
+    const toggle = (delta: Delta, format: TextAction, value: string | boolean | undefined) => {
       const newOps = delta.ops.map((op) => {
         const attributes = {
           ...op.attributes,
-          [format]: isActive ? undefined : true,
+          [format]: value,
         };
         return {
           insert: op.insert,
@@ -62,7 +63,7 @@ export const toggleFormatThunk = createAsyncThunk(
       const beforeDelta = delta.slice(0, index);
       const afterDelta = delta.slice(index + length);
       const rangeDelta = delta.slice(index, index + length);
-      const toggleFormatDelta = toggle(rangeDelta, format);
+      const toggleFormatDelta = toggle(rangeDelta, format, formatValue);
       const newDelta = beforeDelta.concat(toggleFormatDelta).concat(afterDelta);
 
       return controller.getUpdateAction({

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

@@ -2,3 +2,4 @@ export * from './blocks';
 export * from './turn_to';
 export * from './keydown';
 export * from './range';
+export { updateLinkThunk } from '$app_reducers/document/async-actions/link';

+ 5 - 1
frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/keydown.ts

@@ -60,6 +60,7 @@ export const backspaceDeleteActionForBlockThunk = createAsyncThunk(
           controller,
         })
       );
+      dispatch(rangeActions.clearRange());
       dispatch(rangeActions.setCaret(caret));
       return;
     }
@@ -99,7 +100,6 @@ export const enterActionForBlockThunk = createAsyncThunk(
 
     const children = state.document.children[node.children];
     const needMoveChildren = blockConfig[node.type].splitProps?.nextLineRelationShip === SplitRelationship.NextSibling;
-    console.log('needMoveChildren', needMoveChildren);
     const moveChildrenAction = needMoveChildren
       ? controller.getMoveChildrenAction(
           children.map((id) => state.document.nodes[id]),
@@ -150,6 +150,7 @@ export const upDownActionForBlockThunk = createAsyncThunk(
     if (!newCaret) {
       return;
     }
+    dispatch(rangeActions.clearRange());
     dispatch(rangeActions.setCaret(newCaret));
   }
 );
@@ -193,6 +194,7 @@ export const leftActionForBlockThunk = createAsyncThunk(
     if (!newCaret) {
       return;
     }
+    dispatch(rangeActions.clearRange());
     dispatch(rangeActions.setCaret(newCaret));
   }
 );
@@ -238,6 +240,8 @@ export const rightActionForBlockThunk = createAsyncThunk(
     if (!newCaret) {
       return;
     }
+    dispatch(rangeActions.clearRange());
+
     dispatch(rangeActions.setCaret(newCaret));
   }
 );

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

@@ -0,0 +1,103 @@
+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';
+
+export const formatLinkThunk = createAsyncThunk<
+  boolean,
+  {
+    controller: DocumentController;
+  }
+>('document/formatLink', async (payload, thunkAPI) => {
+  const { controller } = payload;
+  const { getState } = thunkAPI;
+  const state = getState() as RootState;
+  const linkPopover = state.documentLinkPopover;
+  if (!linkPopover) return false;
+  const { selection, id, href, title = '' } = linkPopover;
+  if (!selection || !id) return false;
+  const document = state.document;
+  const node = document.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 !== undefined && !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 updateLinkThunk = createAsyncThunk<
+  void,
+  {
+    id: string;
+    href?: string;
+    title: string;
+  }
+>('document/updateLink', async (payload, thunkAPI) => {
+  const { id, href, title } = payload;
+  const { dispatch } = thunkAPI;
+
+  dispatch(
+    linkPopoverActions.updateLinkPopover({
+      id,
+      href,
+      title,
+    })
+  );
+});
+
+export const newLinkThunk = createAsyncThunk<void>('document/newLink', async (payload, thunkAPI) => {
+  const { getState, dispatch } = thunkAPI;
+  const { documentRange, document } = getState() as RootState;
+
+  const { caret } = documentRange;
+  if (!caret) return;
+  const { index, length, id } = caret;
+
+  const block = document.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.clearRange());
+  dispatch(
+    linkPopoverActions.setLinkPopover({
+      anchorPosition: {
+        top: top + height,
+        left: left + width / 2,
+      },
+      id,
+      selection: {
+        index,
+        length,
+      },
+      title,
+      href,
+      open: true,
+    })
+  );
+});

+ 1 - 2
frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/range.ts

@@ -104,8 +104,7 @@ export const deleteRangeAndInsertThunk = createAsyncThunk(
     const { getState, dispatch } = thunkAPI;
     const state = getState() as RootState;
     const rangeState = state.documentRange;
-    // if no range, just return
-    if (rangeState.caret && rangeState.caret.length === 0) return;
+
     const actions = [];
     // get merge actions
     const mergeActions = getMergeEndDeltaToStartActionsByRange(state, controller, insertDelta);

+ 41 - 5
frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/slice.ts

@@ -5,6 +5,7 @@ import {
   SlashCommandState,
   RangeState,
   RangeStatic,
+  LinkPopoverState,
 } from '@/appflowy_app/interfaces/document';
 import { BlockEventPayloadPB } from '@/services/backend';
 import { createSlice, PayloadAction } from '@reduxjs/toolkit';
@@ -29,6 +30,8 @@ const slashCommandInitialState: SlashCommandState = {
   isSlashCommand: false,
 };
 
+const linkPopoverState: LinkPopoverState = {};
+
 export const documentSlice = createSlice({
   name: 'document',
   initialState: initialState,
@@ -158,7 +161,11 @@ export const rangeSlice = createSlice({
     setDragging: (state, action: PayloadAction<boolean>) => {
       state.isDragging = action.payload;
     },
-    setCaret: (state, action: PayloadAction<RangeStatic>) => {
+    setCaret: (state, action: PayloadAction<RangeStatic | null>) => {
+      if (!action.payload) {
+        state.caret = undefined;
+        return;
+      }
       const id = action.payload.id;
       state.ranges[id] = {
         index: action.payload.index,
@@ -167,10 +174,7 @@ export const rangeSlice = createSlice({
       state.caret = action.payload;
     },
     clearRange: (state, _: PayloadAction) => {
-      state.isDragging = false;
-      state.ranges = {};
-      state.anchor = undefined;
-      state.focus = undefined;
+      return rangeInitialState;
     },
   },
 });
@@ -197,14 +201,46 @@ export const slashCommandSlice = createSlice({
   },
 });
 
+export const linkPopoverSlice = createSlice({
+  name: 'documentLinkPopover',
+  initialState: linkPopoverState,
+  reducers: {
+    setLinkPopover: (state, action: PayloadAction<LinkPopoverState>) => {
+      return {
+        ...state,
+        ...action.payload,
+      };
+    },
+    updateLinkPopover: (state, action: PayloadAction<LinkPopoverState>) => {
+      const { id } = action.payload;
+      if (!state.open || state.id !== id) return;
+      return {
+        ...state,
+        ...action.payload,
+      };
+    },
+    closeLinkPopover: (state, _: PayloadAction) => {
+      return {
+        ...state,
+        open: false,
+      };
+    },
+    resetLinkPopover: (state, _: PayloadAction) => {
+      return linkPopoverState;
+    },
+  },
+});
+
 export const documentReducers = {
   [documentSlice.name]: documentSlice.reducer,
   [rectSelectionSlice.name]: rectSelectionSlice.reducer,
   [rangeSlice.name]: rangeSlice.reducer,
   [slashCommandSlice.name]: slashCommandSlice.reducer,
+  [linkPopoverSlice.name]: linkPopoverSlice.reducer,
 };
 
 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;

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

@@ -57,7 +57,6 @@ export function getMiddleIdsByRange(rangeState: RangeState, document: DocumentSt
 export function getAfterMergeCaretByRange(rangeState: RangeState, insertDelta?: Delta) {
   const { anchor, focus, ranges } = rangeState;
   if (!anchor || !focus) return;
-  if (anchor.id === focus.id) return;
 
   const isForward = anchor.point.y < focus.point.y;
   const startId = isForward ? anchor.id : focus.id;

+ 4 - 0
frontend/appflowy_tauri/src/appflowy_app/utils/document/copy_paste.ts

@@ -80,3 +80,7 @@ export function getAppendBlockDeltaAction(
     },
   });
 }
+
+export function copyText(text: string) {
+  return navigator.clipboard.writeText(text);
+}

+ 37 - 3
frontend/appflowy_tauri/src/appflowy_app/utils/document/node.ts

@@ -12,7 +12,7 @@ export function exclude(node: Element) {
   return isPlaceholder;
 }
 
-function findFirstTextNode(node: Node): Node | null {
+export function findFirstTextNode(node: Node): Node | null {
   if (isTextNode(node)) {
     return node;
   }
@@ -45,7 +45,7 @@ export function setCursorAtStartOfNode(node: Node): void {
   selection?.addRange(range);
 }
 
-function findLastTextNode(node: Node): Node | null {
+export function findLastTextNode(node: Node): Node | null {
   if (isTextNode(node)) {
     return node;
   }
@@ -174,7 +174,7 @@ export function findTextNode(
   return { remainingIndex };
 }
 
-export function focusNodeByIndex(node: Element, index: number, length: number) {
+export function getRangeByIndex(node: Element, index: number, length: number) {
   const textBoxNode = node.querySelector(`[role="textbox"]`);
   if (!textBoxNode) return;
   const anchorNode = findTextNode(textBoxNode, index);
@@ -185,10 +185,16 @@ export function focusNodeByIndex(node: Element, index: number, length: number) {
   const range = document.createRange();
   range.setStart(anchorNode.node, anchorNode.offset || 0);
   range.setEnd(focusNode.node, focusNode.offset || 0);
+  return range;
+}
 
+export function focusNodeByIndex(node: Element, index: number, length: number) {
+  const range = getRangeByIndex(node, index, length);
+  if (!range) return false;
   const selection = window.getSelection();
   selection?.removeAllRanges();
   selection?.addRange(range);
+  return true;
 }
 
 export function getNodeTextBoxByBlockId(blockId: string) {
@@ -229,3 +235,31 @@ export function findParent(node: Element, parentSelector: string) {
   }
   return null;
 }
+
+export function getWordIndices(startContainer: Node, startOffset: number) {
+  const textNode = startContainer;
+  const textContent = textNode.textContent || '';
+
+  const wordRegex = /\b\w+\b/g;
+  let match;
+  const wordIndices = [];
+
+  while ((match = wordRegex.exec(textContent)) !== null) {
+    const word = match[0];
+    const wordIndex = match.index;
+    const wordEndIndex = wordIndex + word.length;
+
+    // If the startOffset is greater than the wordIndex and less than the wordEndIndex, then the startOffset is
+    if (startOffset > wordIndex && startOffset <= wordEndIndex) {
+      wordIndices.push({
+        word: word,
+        startIndex: wordIndex,
+        endIndex: wordEndIndex,
+      });
+      break;
+    }
+  }
+
+  // If there are no matches, then the startOffset is greater than the last wordEndIndex
+  return wordIndices;
+}

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

@@ -9,7 +9,6 @@ export function calcToolbarPosition(toolbarDom: HTMLDivElement, node: Element, c
 
   const nodeRect = node.getBoundingClientRect();
   const rect = domRange?.getBoundingClientRect() || { top: 0, left: 0, width: 0, height: 0 };
-
   const top = rect.top - nodeRect.top - toolbarDom.offsetHeight;
   let left = rect.left - nodeRect.left - toolbarDom.offsetWidth / 2 + rect.width / 2;
 
@@ -25,7 +24,6 @@ export function calcToolbarPosition(toolbarDom: HTMLDivElement, node: Element, c
     left = rightBound - toolbarDom.offsetWidth - nodeRect.left - rightThreshold;
   }
 
-
   return {
     top,
     left,

+ 2 - 2
frontend/appflowy_tauri/src/styles/template.css

@@ -20,8 +20,8 @@ body {
   @apply bg-[#E0F8FF]
 }
 
-#appflowy-block-doc ::selection {
-  @apply bg-[transparent]
+div[role="textbox"] ::selection {
+  @apply bg-transparent;
 }
 
 .btn {