Browse Source

Support to show text action toolbar when the selection exists and the range is not collapsed (#2525)

* feat: support text action menu

* fix: selection bugs

* fix: review suggestions

* fix: ci tsc failed
Kilu.He 2 years ago
parent
commit
f23c6098a7
50 changed files with 1465 additions and 656 deletions
  1. 216 192
      frontend/appflowy_tauri/src-tauri/Cargo.lock
  2. 0 32
      frontend/appflowy_tauri/src/appflowy_app/components/document/BlockHorizontalToolbar/FormatButton.tsx
  3. 0 33
      frontend/appflowy_tauri/src/appflowy_app/components/document/BlockHorizontalToolbar/index.hooks.ts
  4. 0 30
      frontend/appflowy_tauri/src/appflowy_app/components/document/BlockHorizontalToolbar/index.tsx
  5. 104 0
      frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSelection/BlockRangeSelection.hooks.ts
  6. 13 32
      frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSelection/BlockRectSelection.hooks.ts
  7. 13 0
      frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSelection/BlockRectSelection.tsx
  8. 6 15
      frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSelection/index.tsx
  9. 5 1
      frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSideToolbar/index.tsx
  10. 5 3
      frontend/appflowy_tauri/src/appflowy_app/components/document/CalloutBlock/index.tsx
  11. 9 1
      frontend/appflowy_tauri/src/appflowy_app/components/document/CodeBlock/elements.tsx
  12. 5 3
      frontend/appflowy_tauri/src/appflowy_app/components/document/CodeBlock/index.tsx
  13. 5 4
      frontend/appflowy_tauri/src/appflowy_app/components/document/Overlay/index.tsx
  14. 5 12
      frontend/appflowy_tauri/src/appflowy_app/components/document/Root/index.tsx
  15. 43 0
      frontend/appflowy_tauri/src/appflowy_app/components/document/TextActionMenu/index.hooks.ts
  16. 46 0
      frontend/appflowy_tauri/src/appflowy_app/components/document/TextActionMenu/index.tsx
  17. 68 0
      frontend/appflowy_tauri/src/appflowy_app/components/document/TextActionMenu/menu/FormatButton.tsx
  18. 1 1
      frontend/appflowy_tauri/src/appflowy_app/components/document/TextActionMenu/menu/FormatIcon.tsx
  19. 20 0
      frontend/appflowy_tauri/src/appflowy_app/components/document/TextActionMenu/menu/MenuTooltip.tsx
  20. 50 0
      frontend/appflowy_tauri/src/appflowy_app/components/document/TextActionMenu/menu/TurnIntoSelect.tsx
  21. 47 0
      frontend/appflowy_tauri/src/appflowy_app/components/document/TextActionMenu/menu/index.hooks.ts
  22. 39 0
      frontend/appflowy_tauri/src/appflowy_app/components/document/TextActionMenu/menu/index.tsx
  23. 7 17
      frontend/appflowy_tauri/src/appflowy_app/components/document/TextBlock/Leaf.tsx
  24. 13 8
      frontend/appflowy_tauri/src/appflowy_app/components/document/TextBlock/events/Events.hooks.ts
  25. 1 3
      frontend/appflowy_tauri/src/appflowy_app/components/document/TextBlock/index.tsx
  26. 6 5
      frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SubscribeNode.hooks.ts
  27. 3 3
      frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/Text/TextEvents.hooks.ts
  28. 23 31
      frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/Text/TextInput.hooks.ts
  29. 38 53
      frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/Text/TextSelection.hooks.ts
  30. 49 0
      frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/TurnInto/TurnInto.hooks.ts
  31. 138 0
      frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/TurnInto/index.tsx
  32. 46 37
      frontend/appflowy_tauri/src/appflowy_app/constants/document/config.ts
  33. 0 25
      frontend/appflowy_tauri/src/appflowy_app/constants/document/toolbar.ts
  34. 73 8
      frontend/appflowy_tauri/src/appflowy_app/interfaces/document.ts
  35. 4 2
      frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/text/backspace.ts
  36. 3 3
      frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/text/split.ts
  37. 89 0
      frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/format.ts
  38. 49 16
      frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/range_selection.ts
  39. 39 9
      frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/slice.ts
  40. 1 1
      frontend/appflowy_tauri/src/appflowy_app/utils/document/blocks/code/decorate.ts
  41. 49 8
      frontend/appflowy_tauri/src/appflowy_app/utils/document/blocks/common.ts
  42. 22 0
      frontend/appflowy_tauri/src/appflowy_app/utils/document/blocks/selection.ts
  43. 83 2
      frontend/appflowy_tauri/src/appflowy_app/utils/document/blocks/text/delta.ts
  44. 0 25
      frontend/appflowy_tauri/src/appflowy_app/utils/document/blocks/text/format.ts
  45. 0 11
      frontend/appflowy_tauri/src/appflowy_app/utils/document/blocks/text/hotkey.ts
  46. 0 27
      frontend/appflowy_tauri/src/appflowy_app/utils/document/blocks/text/toolbar.ts
  47. 19 0
      frontend/appflowy_tauri/src/appflowy_app/utils/document/toolbar.ts
  48. 8 2
      frontend/appflowy_tauri/src/appflowy_app/views/DocumentPage.tsx
  49. 2 0
      frontend/appflowy_tauri/src/services/backend/index.ts
  50. 0 1
      frontend/appflowy_tauri/src/styles/template.css

File diff suppressed because it is too large
+ 216 - 192
frontend/appflowy_tauri/src-tauri/Cargo.lock


+ 0 - 32
frontend/appflowy_tauri/src/appflowy_app/components/document/BlockHorizontalToolbar/FormatButton.tsx

@@ -1,32 +0,0 @@
-import { toggleFormat, isFormatActive } from '$app/utils/document/blocks/text/format';
-import IconButton from '@mui/material/IconButton';
-import Tooltip from '@mui/material/Tooltip';
-
-import { command } from '$app/constants/document/toolbar';
-import FormatIcon from './FormatIcon';
-import { BaseEditor } from 'slate';
-
-const FormatButton = ({ editor, format, icon }: { editor: BaseEditor; format: string; icon: string }) => {
-  return (
-    <Tooltip
-      slotProps={{ tooltip: { style: { background: '#E0F8FF', borderRadius: 8 } } }}
-      title={
-        <div className='flex flex-col'>
-          <span className='text-base font-medium text-black'>{command[format].title}</span>
-          <span className='text-sm text-slate-400'>{command[format].key}</span>
-        </div>
-      }
-      placement='top-start'
-    >
-      <IconButton
-        size='small'
-        sx={{ color: isFormatActive(editor, format) ? '#00BCF0' : 'white' }}
-        onClick={() => toggleFormat(editor, format)}
-      >
-        <FormatIcon icon={icon} />
-      </IconButton>
-    </Tooltip>
-  );
-};
-
-export default FormatButton;

+ 0 - 33
frontend/appflowy_tauri/src/appflowy_app/components/document/BlockHorizontalToolbar/index.hooks.ts

@@ -1,33 +0,0 @@
-import { useEffect, useRef } from 'react';
-import { useFocused, useSlate } from 'slate-react';
-import { calcToolbarPosition } from '$app/utils/document/blocks/text/toolbar';
-export function useHoveringToolbar(id: string) {
-  const editor = useSlate();
-  const inFocus = useFocused();
-  const ref = useRef<HTMLDivElement | null>(null);
-
-  useEffect(() => {
-    const el = ref.current;
-    if (!el) return;
-    const nodeRect = document.querySelector(`[data-block-id="${id}"]`)?.getBoundingClientRect();
-
-    if (!nodeRect) return;
-    const position = calcToolbarPosition(editor, el, nodeRect);
-
-    if (!position) {
-      el.style.opacity = '0';
-      el.style.pointerEvents = 'none';
-    } else {
-      el.style.opacity = '1';
-      el.style.pointerEvents = 'auto';
-      el.style.top = position.top;
-      el.style.left = position.left;
-    }
-  });
-
-  return {
-    ref,
-    inFocus,
-    editor,
-  };
-}

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

@@ -1,30 +0,0 @@
-import FormatButton from './FormatButton';
-import Portal from '../BlockPortal';
-import { useHoveringToolbar } from './index.hooks';
-
-const BlockHorizontalToolbar = ({ id }: { id: string }) => {
-  const { inFocus, ref, editor } = useHoveringToolbar(id);
-  if (!inFocus) return null;
-
-  return (
-    <Portal blockId={id}>
-      <div
-        ref={ref}
-        style={{
-          opacity: 0,
-        }}
-        className='absolute mt-[-6px] inline-flex h-[32px] items-stretch overflow-hidden rounded-[8px] bg-[#333] p-2 leading-tight shadow-lg transition-opacity duration-700'
-        onMouseDown={(e) => {
-          // prevent toolbar from taking focus away from editor
-          e.preventDefault();
-        }}
-      >
-        {['bold', 'italic', 'underlined', 'strikethrough', 'code'].map((format) => (
-          <FormatButton key={format} editor={editor} format={format} icon={format} />
-        ))}
-      </div>
-    </Portal>
-  );
-};
-
-export default BlockHorizontalToolbar;

+ 104 - 0
frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSelection/BlockRangeSelection.hooks.ts

@@ -0,0 +1,104 @@
+import { useCallback, useEffect, useRef, useState } from 'react';
+import { getBlockIdByPoint } from '$app/utils/document/blocks/selection';
+import { rangeSelectionActions } from '$app_reducers/document/slice';
+import { useAppDispatch } from '$app/stores/store';
+import { getNodesInRange } from '$app/utils/document/blocks/common';
+import { setRangeSelectionThunk } from '$app_reducers/document/async-actions/range_selection';
+
+export function useBlockRangeSelection(container: HTMLDivElement) {
+  const dispatch = useAppDispatch();
+  const anchorRef = useRef<{
+    id: string;
+    point: { x: number; y: number };
+    range?: Range;
+  } | null>(null);
+
+  const [isDragging, setDragging] = useState(false);
+
+  const reset = useCallback(() => {
+    dispatch(rangeSelectionActions.clearRange());
+  }, [dispatch]);
+
+  useEffect(() => {
+    dispatch(rangeSelectionActions.setDragging(isDragging));
+  }, [dispatch, isDragging]);
+
+  const handleDragStart = useCallback(
+    (e: MouseEvent) => {
+      reset();
+      const blockId = getBlockIdByPoint(e.target as HTMLElement);
+      if (!blockId) {
+        return;
+      }
+
+      const startX = e.clientX + container.scrollLeft;
+      const startY = e.clientY + container.scrollTop;
+      anchorRef.current = {
+        id: blockId,
+        point: {
+          x: startX,
+          y: startY,
+        },
+      };
+      setDragging(true);
+    },
+    [container.scrollLeft, container.scrollTop, reset]
+  );
+
+  const handleDraging = useCallback(
+    (e: MouseEvent) => {
+      if (!isDragging || !anchorRef.current) return;
+
+      const blockId = getBlockIdByPoint(e.target as HTMLElement);
+      if (!blockId) {
+        return;
+      }
+
+      const anchorId = anchorRef.current.id;
+      if (anchorId === blockId) {
+        const endX = e.clientX + container.scrollTop;
+        const isForward = endX > anchorRef.current.point.x;
+        dispatch(rangeSelectionActions.setForward(isForward));
+        return;
+      }
+
+      const endY = e.clientY + container.scrollTop;
+      const isForward = endY > anchorRef.current.point.y;
+      dispatch(rangeSelectionActions.setForward(isForward));
+    },
+    [container.scrollTop, dispatch, isDragging]
+  );
+
+  const handleDragEnd = useCallback(() => {
+    if (!isDragging) return;
+    setDragging(false);
+    dispatch(setRangeSelectionThunk());
+  }, [dispatch, isDragging]);
+
+  // TODO: This is a hack to fix the issue that the selection is lost when scrolling
+  const handleScroll = useCallback(() => {
+    if (isDragging || !anchorRef.current) return;
+    const selection = window.getSelection();
+    if (!selection?.rangeCount && anchorRef.current.range) {
+      selection?.addRange(anchorRef.current.range);
+    } else {
+      anchorRef.current.range = selection?.getRangeAt(0);
+    }
+  }, [isDragging]);
+
+  useEffect(() => {
+    document.addEventListener('mousedown', handleDragStart);
+    document.addEventListener('mousemove', handleDraging, true);
+    document.addEventListener('mouseup', handleDragEnd);
+    container.addEventListener('scroll', handleScroll);
+
+    return () => {
+      document.removeEventListener('mousedown', handleDragStart);
+      document.removeEventListener('mousemove', handleDraging, true);
+      document.removeEventListener('mouseup', handleDragEnd);
+      container.removeEventListener('scroll', handleScroll);
+    };
+  }, [handleDragStart, handleDragEnd, handleDraging, container, handleScroll]);
+
+  return null;
+}

+ 13 - 32
frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSelection/BlockSelection.hooks.tsx → frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSelection/BlockRectSelection.hooks.ts

@@ -1,18 +1,12 @@
 import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
 import { useAppDispatch } from '$app/stores/store';
-import { rectSelectionActions } from "@/appflowy_app/stores/reducers/document/slice";
+import { rectSelectionActions } from '@/appflowy_app/stores/reducers/document/slice';
 import { useNodesRect } from '$app/components/document/BlockSelection/NodesRect.hooks';
-import { setRectSelectionThunk } from "$app_reducers/document/async-actions/rect_selection";
-
-export function useBlockSelection({
-  container,
-  onDragging,
-}: {
-  container: HTMLDivElement;
-  onDragging?: (_isDragging: boolean) => void;
-}) {
-  const ref = useRef<HTMLDivElement | null>(null);
-  const disaptch = useAppDispatch();
+import { setRectSelectionThunk } from '$app_reducers/document/async-actions/rect_selection';
+import { isPointInBlock } from '$app/utils/document/blocks/selection';
+
+export function useBlockRectSelection({ container }: { container: HTMLDivElement }) {
+  const dispatch = useAppDispatch();
 
   const [isDragging, setDragging] = useState(false);
   const startPointRef = useRef<number[]>([]);
@@ -20,8 +14,8 @@ export function useBlockSelection({
   const { getIntersectedBlockIds } = useNodesRect(container);
 
   useEffect(() => {
-    onDragging?.(isDragging);
-  }, [isDragging, onDragging]);
+    dispatch(rectSelectionActions.setDragging(isDragging));
+  }, [dispatch, isDragging]);
 
   const [rect, setRect] = useState<{
     startX: number;
@@ -45,17 +39,6 @@ export function useBlockSelection({
     };
   }, [container.scrollLeft, container.scrollTop, rect]);
 
-  const isPointInBlock = useCallback((target: HTMLElement | null) => {
-    let node = target;
-    while (node) {
-      if (node.getAttribute('data-block-id')) {
-        return true;
-      }
-      node = node.parentElement;
-    }
-    return false;
-  }, []);
-
   const handleDragStart = useCallback(
     (e: MouseEvent) => {
       if (isPointInBlock(e.target as HTMLElement)) {
@@ -74,7 +57,7 @@ export function useBlockSelection({
         endY: startY,
       });
     },
-    [container.scrollLeft, container.scrollTop, isPointInBlock]
+    [container.scrollLeft, container.scrollTop]
   );
 
   const updateSelctionsByPoint = useCallback(
@@ -92,9 +75,9 @@ export function useBlockSelection({
       };
       const blockIds = getIntersectedBlockIds(newRect);
       setRect(newRect);
-      disaptch(setRectSelectionThunk(blockIds));
+      dispatch(setRectSelectionThunk(blockIds));
     },
-    [container.scrollLeft, container.scrollTop, disaptch, getIntersectedBlockIds, isDragging]
+    [container.scrollLeft, container.scrollTop, dispatch, getIntersectedBlockIds, isDragging]
   );
 
   const handleDraging = useCallback(
@@ -119,7 +102,7 @@ export function useBlockSelection({
   const handleDragEnd = useCallback(
     (e: MouseEvent) => {
       if (isPointInBlock(e.target as HTMLElement) && !isDragging) {
-        disaptch(rectSelectionActions.updateSelections([]));
+        dispatch(rectSelectionActions.updateSelections([]));
         return;
       }
       if (!isDragging) return;
@@ -128,11 +111,10 @@ export function useBlockSelection({
       setDragging(false);
       setRect(null);
     },
-    [disaptch, isDragging, isPointInBlock, updateSelctionsByPoint]
+    [dispatch, isDragging, updateSelctionsByPoint]
   );
 
   useEffect(() => {
-    if (!ref.current) return;
     document.addEventListener('mousedown', handleDragStart);
     document.addEventListener('mousemove', handleDraging);
     document.addEventListener('mouseup', handleDragEnd);
@@ -147,6 +129,5 @@ export function useBlockSelection({
   return {
     isDragging,
     style,
-    ref,
   };
 }

+ 13 - 0
frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSelection/BlockRectSelection.tsx

@@ -0,0 +1,13 @@
+import React from 'react';
+import { useBlockRectSelection } from '$app/components/document/BlockSelection/BlockRectSelection.hooks';
+
+function BlockRectSelection({ container }: { container: HTMLDivElement }) {
+  const { isDragging, style } = useBlockRectSelection({
+    container,
+  });
+
+  if (!isDragging) return null;
+  return <div className='z-99 absolute bg-[#00d5ff] opacity-25' style={style} />;
+}
+
+export default BlockRectSelection;

+ 6 - 15
frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSelection/index.tsx

@@ -1,21 +1,12 @@
-import { useBlockSelection } from './BlockSelection.hooks';
 import React from 'react';
+import BlockRectSelection from '$app/components/document/BlockSelection/BlockRectSelection';
+import { useBlockRangeSelection } from '$app/components/document/BlockSelection/BlockRangeSelection.hooks';
 
-function BlockSelection({
-  container,
-  onDragging,
-}: {
-  container: HTMLDivElement;
-  onDragging?: (_isDragging: boolean) => void;
-}) {
-  const { isDragging, style, ref } = useBlockSelection({
-    container,
-    onDragging,
-  });
-
+function BlockSelection({ container }: { container: HTMLDivElement }) {
+  useBlockRangeSelection(container);
   return (
-    <div ref={ref} className='appflowy-block-selection-overlay z-1 pointer-events-none fixed inset-0 overflow-hidden'>
-      {isDragging ? <div className='z-99 absolute bg-[#00d5ff] opacity-25' style={style} /> : null}
+    <div className='appflowy-block-selection-overlay z-1 pointer-events-none fixed inset-0 overflow-hidden'>
+      <BlockRectSelection container={container} />
     </div>
   );
 }

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

@@ -5,13 +5,17 @@ import DragIndicatorRoundedIcon from '@mui/icons-material/DragIndicatorRounded';
 import Portal from '../BlockPortal';
 import { IconButton } from '@mui/material';
 import BlockMenu from '../BlockMenu';
+import { useAppSelector } from '$app/stores/store';
 
 const sx = { height: 24, width: 24 };
 
 export default function BlockSideToolbar(props: { container: HTMLDivElement }) {
   const { nodeId, style, ref, menuOpen, handleToggleMenu } = useBlockSideToolbar(props);
+  const isDragging = useAppSelector(
+    (state) => state.documentRangeSelection.isDragging || state.documentRectSelection.isDragging
+  );
 
-  if (!nodeId) return null;
+  if (!nodeId || isDragging) return null;
   return (
     <>
       <Portal blockId={nodeId}>

+ 5 - 3
frontend/appflowy_tauri/src/appflowy_app/components/document/CalloutBlock/index.tsx

@@ -1,10 +1,11 @@
 import { BlockType, NestedBlock } from '$app/interfaces/document';
 import TextBlock from '$app/components/document/TextBlock';
 import NodeChildren from '$app/components/document/Node/NodeChildren';
-import { IconButton, Popover } from '@mui/material';
+import { IconButton } from '@mui/material';
 import emojiData from '@emoji-mart/data';
 import Picker from '@emoji-mart/react';
 import { useCalloutBlock } from '$app/components/document/CalloutBlock/CalloutBlock.hooks';
+import Popover from '@mui/material/Popover';
 
 export default function CalloutBlock({
   node,
@@ -17,7 +18,7 @@ export default function CalloutBlock({
 
   return (
     <div className={'my-1 flex rounded border border-solid border-main-accent bg-main-secondary p-4'}>
-      <div className={'w-[1.5em]'}>
+      <div className={'w-[1.5em]'} onMouseDown={(e) => e.stopPropagation()}>
         <div className={'flex h-[calc(1.5em_+_2px)] w-[24px] select-none items-center justify-start'}>
           <IconButton
             aria-describedby={id}
@@ -27,8 +28,9 @@ export default function CalloutBlock({
             {node.data.icon}
           </IconButton>
           <Popover
-            id={id}
+            className={'border-none bg-transparent shadow-none'}
             anchorEl={anchorEl}
+            disableAutoFocus={true}
             open={open}
             onClose={closeEmojiSelect}
             anchorOrigin={{

+ 9 - 1
frontend/appflowy_tauri/src/appflowy_app/components/document/CodeBlock/elements.tsx

@@ -8,6 +8,7 @@ interface CodeLeafProps extends RenderLeafProps {
     underlined?: boolean;
     strikethrough?: boolean;
     prism_token?: string;
+    selectionHighlighted?: boolean;
   };
 }
 
@@ -27,8 +28,15 @@ export const CodeLeaf = (props: CodeLeafProps) => {
     newChildren = <u>{newChildren}</u>;
   }
 
+  const className = [
+    'token',
+    leaf.prism_token && leaf.prism_token,
+    leaf.strikethrough && 'line-through',
+    leaf.selectionHighlighted && 'bg-main-secondary',
+  ].filter(Boolean);
+
   return (
-    <span {...attributes} className={`token ${leaf.prism_token} ${leaf.strikethrough ? `line-through` : ''}`}>
+    <span {...attributes} className={className.join(' ')}>
       {newChildren}
     </span>
   );

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

@@ -1,7 +1,6 @@
 import { BlockType, NestedBlock } from '$app/interfaces/document';
 import { useCodeBlock } from './CodeBlock.hooks';
 import { Editable, Slate } from 'slate-react';
-import BlockHorizontalToolbar from '$app/components/document/BlockHorizontalToolbar';
 import React from 'react';
 import { CodeLeaf, CodeBlockElement } from './elements';
 import SelectLanguage from './SelectLanguage';
@@ -23,10 +22,13 @@ export default function CodeBlock({
         <SelectLanguage id={id} language={language} />
       </div>
       <Slate editor={editor} onChange={onChange} value={value}>
-        <BlockHorizontalToolbar id={id} />
         <Editable
           {...rest}
-          decorate={(entry) => decorateCodeFunc(entry, language)}
+          decorate={(entry) => {
+            const codeRange = decorateCodeFunc(entry, language);
+            const range = rest.decorate(entry);
+            return [...range, ...codeRange];
+          }}
           renderLeaf={CodeLeaf}
           renderElement={CodeBlockElement}
           placeholder={placeholder || 'Please enter some text...'}

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

@@ -1,13 +1,14 @@
-import React, { useState } from 'react';
+import React from 'react';
 import BlockSideToolbar from '../BlockSideToolbar';
 import BlockSelection from '../BlockSelection';
+import TextActionMenu from '$app/components/document/TextActionMenu';
 
 export default function Overlay({ container }: { container: HTMLDivElement }) {
-  const [isDragging, setDragging] = useState(false);
   return (
     <>
-      {isDragging ? null : <BlockSideToolbar container={container} />}
-      <BlockSelection onDragging={setDragging} container={container} />
+      <BlockSideToolbar container={container} />
+      <TextActionMenu container={container} />
+      <BlockSelection container={container} />
     </>
   );
 }

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

@@ -19,18 +19,11 @@ function Root({ documentData }: { documentData: DocumentData }) {
   }
 
   return (
-    <div
-      id='appflowy-block-doc'
-      className='h-[100%] overflow-hidden'
-      onKeyDown={(e) => {
-        // prevent backspace from going back
-        if (e.key === 'Backspace') {
-          e.stopPropagation();
-        }
-      }}
-    >
-      <VirtualizedList node={node} childIds={childIds} renderNode={renderNode} />
-    </div>
+    <>
+      <div id='appflowy-block-doc' className='h-[100%] overflow-hidden'>
+        <VirtualizedList node={node} childIds={childIds} renderNode={renderNode} />
+      </div>
+    </>
   );
 }
 

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

@@ -0,0 +1,43 @@
+import { useEffect, useRef, useState } from 'react';
+import { calcToolbarPosition } from '$app/utils/document/toolbar';
+import { useAppSelector } from '$app/stores/store';
+
+export function useMenuStyle(container: HTMLDivElement) {
+  const ref = useRef<HTMLDivElement | null>(null);
+  const range = useAppSelector((state) => state.documentRangeSelection);
+
+  const [scrollTop, setScrollTop] = useState(container.scrollTop);
+  useEffect(() => {
+    const el = ref.current;
+    if (!el) return;
+
+    const id = range.focus?.id;
+    if (!id) return;
+
+    const position = calcToolbarPosition(el);
+
+    if (!position) {
+      el.style.opacity = '0';
+      el.style.pointerEvents = 'none';
+    } else {
+      el.style.opacity = '1';
+      el.style.pointerEvents = 'auto';
+      el.style.top = position.top;
+      el.style.left = position.left;
+    }
+  });
+
+  useEffect(() => {
+    const handleScroll = () => {
+      setScrollTop(container.scrollTop);
+    };
+    container.addEventListener('scroll', handleScroll);
+    return () => {
+      container.removeEventListener('scroll', handleScroll);
+    };
+  }, [container]);
+
+  return {
+    ref,
+  };
+}

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

@@ -0,0 +1,46 @@
+import { useMenuStyle } from './index.hooks';
+import { useAppSelector } from '$app/stores/store';
+import { isEqual } from '$app/utils/tool';
+import TextActionMenuList from '$app/components/document/TextActionMenu/menu';
+
+const TextActionComponent = ({ container }: { container: HTMLDivElement }) => {
+  const { ref } = useMenuStyle(container);
+
+  return (
+    <div
+      ref={ref}
+      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-200'
+      onMouseDown={(e) => {
+        // prevent toolbar from taking focus away from editor
+        e.preventDefault();
+        e.stopPropagation();
+      }}
+    >
+      <TextActionMenuList />
+    </div>
+  );
+};
+const TextActionMenu = ({ container }: { container: HTMLDivElement }) => {
+  const canShow = useAppSelector((state) => {
+    const range = state.documentRangeSelection;
+    if (range.isDragging) return false;
+    const anchorNode = range.anchor;
+    const focusNode = range.focus;
+    if (!anchorNode || !focusNode) return false;
+    const isSameLine = anchorNode.id === focusNode.id;
+    const isCollapsed = isEqual(anchorNode.selection.anchor, anchorNode.selection.focus);
+    return !(isSameLine && isCollapsed);
+  });
+  if (!canShow) return null;
+
+  return (
+    <div className='appflowy-block-toolbar-overlay pointer-events-none fixed inset-0 overflow-hidden'>
+      <TextActionComponent container={container} />
+    </div>
+  );
+};
+
+export default TextActionMenu;

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

@@ -0,0 +1,68 @@
+import IconButton from '@mui/material/IconButton';
+import FormatIcon from './FormatIcon';
+import React, { useCallback, useEffect, useMemo, useContext } from 'react';
+import { TextAction } from '$app/interfaces/document';
+import MenuTooltip from '$app/components/document/TextActionMenu/menu/MenuTooltip';
+import { getFormatActiveThunk, toggleFormatThunk } from '$app_reducers/document/async-actions/format';
+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';
+
+const FormatButton = ({ format, icon }: { format: TextAction; icon: string }) => {
+  const dispatch = useAppDispatch();
+  const controller = useContext(DocumentControllerContext);
+
+  const focusId = useAppSelector((state) => state.documentRangeSelection.focus?.id || '');
+  const { node: focusNode } = useSubscribeNode(focusId);
+
+  const [isActive, setIsActive] = React.useState(false);
+  const color = useMemo(() => (isActive ? '#00BCF0' : 'white'), [isActive]);
+
+  const formatTooltips: Record<string, string> = useMemo(
+    () => ({
+      [TextAction.Bold]: 'Bold',
+      [TextAction.Italic]: 'Italic',
+      [TextAction.Underline]: 'Underline',
+      [TextAction.Strikethrough]: 'Strike through',
+      [TextAction.Code]: 'Mark as Code',
+    }),
+    []
+  );
+
+  const isFormatActive = useCallback(async () => {
+    if (!focusNode) return false;
+    const { payload: isActive } = await dispatch(getFormatActiveThunk(format));
+    return !!isActive;
+  }, [dispatch, format, focusNode]);
+
+  const toggleFormat = useCallback(
+    async (format: TextAction) => {
+      if (!controller) return;
+      await dispatch(
+        toggleFormatThunk({
+          format,
+          controller,
+          isActive,
+        })
+      );
+    },
+    [controller, dispatch, isActive]
+  );
+
+  useEffect(() => {
+    void (async () => {
+      const isActive = await isFormatActive();
+      setIsActive(isActive);
+    })();
+  }, [isFormatActive]);
+
+  return (
+    <MenuTooltip title={formatTooltips[format]}>
+      <IconButton size='small' sx={{ color }} onClick={() => toggleFormat(format)}>
+        <FormatIcon icon={icon} />
+      </IconButton>
+    </MenuTooltip>
+  );
+};
+
+export default FormatButton;

+ 1 - 1
frontend/appflowy_tauri/src/appflowy_app/components/document/BlockHorizontalToolbar/FormatIcon.tsx → frontend/appflowy_tauri/src/appflowy_app/components/document/TextActionMenu/menu/FormatIcon.tsx

@@ -1,6 +1,6 @@
 import React from 'react';
 import { FormatBold, FormatUnderlined, FormatItalic, CodeOutlined, StrikethroughSOutlined } from '@mui/icons-material';
-import { iconSize } from '$app/constants/document/toolbar';
+export const iconSize = { width: 18, height: 18 };
 
 export default function FormatIcon({ icon }: { icon: string }) {
   switch (icon) {

+ 20 - 0
frontend/appflowy_tauri/src/appflowy_app/components/document/TextActionMenu/menu/MenuTooltip.tsx

@@ -0,0 +1,20 @@
+import React from 'react';
+import Tooltip from '@mui/material/Tooltip';
+
+function MenuTooltip({ title, children }: { children: JSX.Element; title?: string }) {
+  return (
+    <Tooltip
+      slotProps={{ tooltip: { style: { background: '#E0F8FF', borderRadius: 8 } } }}
+      title={
+        <div className='flex flex-col'>
+          <span className='text-base font-medium text-black'>{title}</span>
+        </div>
+      }
+      placement='top-start'
+    >
+      <div>{children}</div>
+    </Tooltip>
+  );
+}
+
+export default MenuTooltip;

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

@@ -0,0 +1,50 @@
+import React, { useCallback } from 'react';
+import TurnIntoPopover from '$app/components/document/_shared/TurnInto';
+import Button from '@mui/material/Button';
+import ArrowDropDown from '@mui/icons-material/ArrowDropDown';
+import MenuTooltip from './MenuTooltip';
+import { useSubscribeNode } from '$app/components/document/_shared/SubscribeNode.hooks';
+
+function TurnIntoSelect({ id }: { id: string }) {
+  const [anchorEl, setAnchorEl] = React.useState<HTMLButtonElement | null>(null);
+
+  const { node } = useSubscribeNode(id);
+  const handleClick = useCallback((event: React.MouseEvent<HTMLButtonElement>) => {
+    setAnchorEl(event.currentTarget);
+  }, []);
+
+  const handleClose = useCallback(() => {
+    setAnchorEl(null);
+  }, []);
+
+  const open = Boolean(anchorEl);
+
+  return (
+    <>
+      <MenuTooltip title='Turn into'>
+        <Button size={'small'} variant='text' onClick={handleClick}>
+          <div className='flex items-center text-main-accent'>
+            <span>{node.type}</span>
+            <ArrowDropDown />
+          </div>
+        </Button>
+      </MenuTooltip>
+      <TurnIntoPopover
+        id={id}
+        open={open}
+        onClose={handleClose}
+        anchorEl={anchorEl}
+        anchorOrigin={{
+          vertical: 'center',
+          horizontal: 'center',
+        }}
+        transformOrigin={{
+          vertical: 'center',
+          horizontal: 'center',
+        }}
+      />
+    </>
+  );
+}
+
+export default TurnIntoSelect;

+ 47 - 0
frontend/appflowy_tauri/src/appflowy_app/components/document/TextActionMenu/menu/index.hooks.ts

@@ -0,0 +1,47 @@
+import { useAppSelector } from '$app/stores/store';
+import { useMemo } from 'react';
+import {
+  blockConfig,
+  defaultTextActionProps,
+  multiLineTextActionGroups,
+  multiLineTextActionProps,
+  textActionGroups,
+} from '$app/constants/document/config';
+import { useSubscribeNode } from '$app/components/document/_shared/SubscribeNode.hooks';
+import { TextAction } from '$app/interfaces/document';
+
+export function useTextActionMenu() {
+  const range = useAppSelector((state) => state.documentRangeSelection);
+
+  const id = useMemo(() => {
+    return range.anchor?.id === range.focus?.id ? range.anchor?.id : undefined;
+  }, [range]);
+
+  const { node } = useSubscribeNode(id || '');
+
+  const items = useMemo(() => {
+    if (node) {
+      const config = blockConfig[node.type];
+      const { customItems, excludeItems } = {
+        ...defaultTextActionProps,
+        ...config.textActionMenuProps,
+      };
+      return customItems?.filter((item) => !excludeItems?.includes(item)) || [];
+    } else {
+      return multiLineTextActionProps.customItems || [];
+    }
+  }, [node]);
+
+  // the groups have default items, so we need to filter the items if this node has excluded items
+  const groupItems: TextAction[][] = useMemo(() => {
+    const groups = node ? textActionGroups : multiLineTextActionGroups;
+    return groups.map((group) => {
+      return group.filter((item) => items.includes(item));
+    });
+  }, [JSON.stringify(items), node]);
+
+  return {
+    groupItems,
+    id,
+  };
+}

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

@@ -0,0 +1,39 @@
+import { TextAction } from '$app/interfaces/document';
+import React, { useCallback } from 'react';
+import TurnIntoSelect from '$app/components/document/TextActionMenu/menu/TurnIntoSelect';
+import FormatButton from '$app/components/document/TextActionMenu/menu/FormatButton';
+import { useTextActionMenu } from '$app/components/document/TextActionMenu/menu/index.hooks';
+
+function TextActionMenuList() {
+  const { groupItems, id } = useTextActionMenu();
+  const renderNode = useCallback((action: TextAction, id?: string) => {
+    switch (action) {
+      case TextAction.Turn:
+        return id ? <TurnIntoSelect id={id} /> : null;
+      case TextAction.Bold:
+      case TextAction.Italic:
+      case TextAction.Underline:
+      case TextAction.Strikethrough:
+      case TextAction.Code:
+        return <FormatButton format={action} icon={action} />;
+      default:
+        return null;
+    }
+  }, []);
+
+  return (
+    <div className={'flex px-1'}>
+      {groupItems.map((group, i: number) => (
+        <div className={'flex border-r border-solid border-shade-2 px-1 last:border-r-0'} key={i}>
+          {group.map((item) => (
+            <div key={item} className={'flex items-center'}>
+              {renderNode(item, id)}
+            </div>
+          ))}
+        </div>
+      ))}
+    </div>
+  );
+}
+
+export default TextActionMenuList;

+ 7 - 17
frontend/appflowy_tauri/src/appflowy_app/components/document/TextBlock/Leaf.tsx

@@ -10,20 +10,12 @@ interface LeafProps extends RenderLeafProps {
     selectionHighlighted?: boolean;
   };
 }
-const Leaf = ({
-  attributes,
-  children,
-  leaf,
-}: LeafProps) => {
+const Leaf = ({ attributes, children, leaf }: LeafProps) => {
   let newChildren = children;
   if (leaf.bold) {
     newChildren = <strong>{children}</strong>;
   }
 
-  if (leaf.code) {
-    newChildren = <code className='rounded-sm	 bg-[#F2FCFF] p-1'>{newChildren}</code>;
-  }
-
   if (leaf.italic) {
     newChildren = <em>{newChildren}</em>;
   }
@@ -32,16 +24,14 @@ const Leaf = ({
     newChildren = <u>{newChildren}</u>;
   }
 
-  let className = "";
-  if (leaf.strikethrough) {
-    className += "line-through";
-  }
-  if (leaf.selectionHighlighted) {
-    className += " bg-main-secondary";
-  }
+  const className = [
+    leaf.strikethrough && 'line-through',
+    leaf.selectionHighlighted && 'bg-main-secondary',
+    leaf.code && 'bg-main-selector',
+  ].filter(Boolean);
 
   return (
-    <span {...attributes} className={className}>
+    <span {...attributes} className={className.join(' ')}>
       {newChildren}
     </span>
   );

+ 13 - 8
frontend/appflowy_tauri/src/appflowy_app/components/document/TextBlock/events/Events.hooks.ts

@@ -2,20 +2,23 @@ import { Editor } from 'slate';
 import { useTurnIntoBlock } from './TurnIntoEvents.hooks';
 import { useCallback, useContext, useMemo } from 'react';
 import { keyBoardEventKeyMap } from '$app/constants/document/text_block';
-import { triggerHotkey } from '$app/utils/document/blocks/text/hotkey';
 import { TextBlockKeyEventHandlerParams } from '$app/interfaces/document';
 import isHotkey from 'is-hotkey';
 import { indentNodeThunk, outdentNodeThunk, splitNodeThunk } from '$app_reducers/document/async-actions';
 import { DocumentControllerContext } from '$app/stores/effects/document/document_controller';
-import { useAppDispatch } from '$app/stores/store';
+import { useAppDispatch, useAppSelector } from '$app/stores/store';
 import { useDefaultTextInputEvents } from '$app/components/document/_shared/Text/TextEvents.hooks';
 import { ReactEditor } from 'slate-react';
 
 export function useTextBlockKeyEvent(id: string, editor: ReactEditor) {
   const controller = useContext(DocumentControllerContext);
   const dispatch = useAppDispatch();
-
   const defaultTextInputEvents = useDefaultTextInputEvents(id);
+  const isFocusCurrentNode = useAppSelector((state) => {
+    const { anchor, focus } = state.documentRangeSelection;
+    if (!anchor || !focus) return false;
+    return anchor.id === id && focus.id === id;
+  });
 
   const { turnIntoBlockEvents } = useTurnIntoBlock(id);
 
@@ -84,18 +87,20 @@ export function useTextBlockKeyEvent(id: string, editor: ReactEditor) {
 
   const onKeyDown = useCallback(
     (event: React.KeyboardEvent<HTMLDivElement>) => {
+      if (!isFocusCurrentNode) {
+        event.preventDefault();
+        return;
+      }
+
+      event.stopPropagation();
       // This is list of key events that can be handled by TextBlock
       const keyEvents = [...events, ...turnIntoBlockEvents];
 
       const matchKeys = keyEvents.filter((keyEvent) => keyEvent.canHandle(event, editor));
-      if (matchKeys.length === 0) {
-        triggerHotkey(event, editor);
-        return;
-      }
 
       matchKeys.forEach((matchKey) => matchKey.handler(event, editor));
     },
-    [editor, events, turnIntoBlockEvents]
+    [editor, events, turnIntoBlockEvents, isFocusCurrentNode]
   );
 
   return {

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

@@ -1,8 +1,7 @@
 import { Slate, Editable } from 'slate-react';
 import Leaf from './Leaf';
 import { useTextBlock } from './TextBlock.hooks';
-import BlockHorizontalToolbar from '../BlockHorizontalToolbar';
-import React, { useEffect } from 'react';
+import React from 'react';
 import { NestedBlock } from '$app/interfaces/document';
 import NodeChildren from '$app/components/document/Node/NodeChildren';
 
@@ -23,7 +22,6 @@ function TextBlock({
     <>
       <div className={`px-1 py-[2px] ${className}`}>
         <Slate editor={editor} onChange={onChange} value={value}>
-          {/*<BlockHorizontalToolbar id={node.id} />*/}
           <Editable
             {...rest}
             renderLeaf={(leafProps) => <Leaf {...leafProps} />}

+ 6 - 5
frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SubscribeNode.hooks.ts

@@ -2,7 +2,7 @@ import { useAppSelector } from '@/appflowy_app/stores/store';
 import { useMemo, useRef } from 'react';
 import { DocumentState, Node, RangeSelectionState } from '$app/interfaces/document';
 import { nodeInRange } from '$app/utils/document/blocks/common';
-import { getNodeEndSelection, selectionIsForward } from '$app/utils/document/blocks/text/delta';
+import { getNodeEndSelection } from '$app/utils/document/blocks/text/delta';
 
 /**
  * Subscribe node information
@@ -18,7 +18,7 @@ export function useSubscribeNode(id: string) {
   });
 
   const isSelected = useAppSelector<boolean>((state) => {
-    return state.documentRectSelection.includes(id) || false;
+    return state.documentRectSelection.selection.includes(id) || false;
   });
 
   // Memoize the node and its children
@@ -50,6 +50,7 @@ export function useSubscribeRangeSelection(id: string) {
     if (range.focus?.id === id) {
       return range.focus.selection;
     }
+
     return getAmendInRangeNodeSelection(id, range, state.document);
   });
 
@@ -60,17 +61,17 @@ export function useSubscribeRangeSelection(id: string) {
 }
 
 function getAmendInRangeNodeSelection(id: string, range: RangeSelectionState, document: DocumentState) {
-  if (!range.anchor || !range.focus || range.anchor.id === range.focus.id) {
+  if (!range.anchor || !range.focus || range.anchor.id === range.focus.id || range.isForward === undefined) {
     return null;
   }
-  const isForward = selectionIsForward(range.anchor.selection);
+
   const isNodeInRange = nodeInRange(
     id,
     {
       startId: range.anchor.id,
       endId: range.focus.id,
     },
-    isForward,
+    range.isForward,
     document
   );
 

+ 3 - 3
frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/Text/TextEvents.hooks.ts

@@ -11,7 +11,7 @@ import {
   canHandleUpKey,
 } from '$app/utils/document/blocks/text/hotkey';
 import { TextBlockKeyEventHandlerParams } from '$app/interfaces/document';
-import { ReactEditor } from "slate-react";
+import { ReactEditor } from 'slate-react';
 
 export function useDefaultTextInputEvents(id: string) {
   const dispatch = useAppDispatch();
@@ -81,11 +81,11 @@ export function useDefaultTextInputEvents(id: string) {
       triggerEventKey: keyBoardEventKeyMap.Backspace,
       canHandle: canHandleBackspaceKey,
       handler: (...args: TextBlockKeyEventHandlerParams) => {
-        const [e, _] = args;
+        const [e, editor] = args;
         e.preventDefault();
         void (async () => {
           if (!controller) return;
-          await dispatch(backspaceNodeThunk({ id, controller }));
+          await dispatch(backspaceNodeThunk({ id, controller, editor }));
         })();
       },
     },

+ 23 - 31
frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/Text/TextInput.hooks.ts

@@ -1,4 +1,4 @@
-import { createEditor, Descendant, Editor } from 'slate';
+import { createEditor, Descendant, Editor, Transforms } from 'slate';
 import { withReact } from 'slate-react';
 import { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
 
@@ -8,12 +8,12 @@ import { useAppDispatch } from '$app/stores/store';
 import { updateNodeDeltaThunk } from '$app_reducers/document/async-actions/blocks/text/update';
 import { deltaToSlateValue, slateValueToDelta } from '$app/utils/document/blocks/common';
 import { isSameDelta } from '$app/utils/document/blocks/text/delta';
-import { debounce } from '$app/utils/tool';
 import { useSubscribeNode } from '$app/components/document/_shared/SubscribeNode.hooks';
 import { useTextSelections } from '$app/components/document/_shared/Text/TextSelection.hooks';
 
 export function useTextInput(id: string) {
   const { node } = useSubscribeNode(id);
+
   const [editor] = useState(() => withReact(createEditor()));
   const isComposition = useRef(false);
   const { setLastActiveSelection, ...selectionProps } = useTextSelections(id, editor);
@@ -24,16 +24,15 @@ export function useTextInput(id: string) {
     }
     return node.data.delta;
   }, [node]);
+  const [value, setValue] = useState<Descendant[]>(deltaToSlateValue(delta));
 
   const { sync, receive } = useUpdateDelta(id, editor);
 
-  const [value, setValue] = useState<Descendant[]>(deltaToSlateValue(delta));
-
   // Update the editor's value when the node's delta changes.
   useEffect(() => {
     // If composition is in progress, do nothing.
     if (isComposition.current) return;
-    receive(delta);
+    receive(delta, setValue);
   }, [delta, receive]);
 
   // Update the node's delta when the editor's value changes.
@@ -88,33 +87,30 @@ function useUpdateDelta(id: string, editor: Editor) {
   const dispatch = useAppDispatch();
   const penddingRef = useRef(false);
 
-  // when user input, update the node's delta after 200ms
-  const debounceUpdate = useMemo(() => {
-    return debounce(() => {
-      if (!controller) return;
-      const delta = slateValueToDelta(editor.children);
-      void (async () => {
-        await dispatch(
-          updateNodeDeltaThunk({
-            id,
-            delta,
-            controller,
-          })
-        );
-        // reset pendding flag
-        penddingRef.current = false;
-      })();
-    }, 200);
+  const update = useCallback(() => {
+    if (!controller) return;
+    const delta = slateValueToDelta(editor.children);
+    void (async () => {
+      await dispatch(
+        updateNodeDeltaThunk({
+          id,
+          delta,
+          controller,
+        })
+      );
+      // reset pendding flag
+      penddingRef.current = false;
+    })();
   }, [controller, dispatch, editor, id]);
 
   const sync = useCallback(() => {
     // set pendding flag
     penddingRef.current = true;
-    debounceUpdate();
-  }, [debounceUpdate]);
+    update();
+  }, [update]);
 
   const receive = useCallback(
-    (delta: TextDelta[]) => {
+    (delta: TextDelta[], setValue: (children: Descendant[]) => void) => {
       // if pendding, do nothing
       if (penddingRef.current) return;
 
@@ -123,18 +119,14 @@ function useUpdateDelta(id: string, editor: Editor) {
       const isSame = isSameDelta(delta, localDelta);
       if (isSame) return;
 
+      Transforms.deselect(editor);
       const slateValue = deltaToSlateValue(delta);
       editor.children = slateValue;
+      setValue(slateValue);
     },
     [editor]
   );
 
-  useEffect(() => {
-    return () => {
-      debounceUpdate.cancel();
-    };
-  });
-
   return {
     sync,
     receive,

+ 38 - 53
frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/Text/TextSelection.hooks.ts

@@ -1,14 +1,14 @@
-import { MouseEventHandler, useCallback, useEffect } from 'react';
+import { MouseEvent, useCallback, useEffect, useRef } from 'react';
 import { BaseRange, Editor, Node, Path, Range, Transforms } from 'slate';
 import { EditableProps } from 'slate-react/dist/components/editable';
 import { useSubscribeRangeSelection } from '$app/components/document/_shared/SubscribeNode.hooks';
 import { useAppDispatch } from '$app/stores/store';
-import { rangeSelectionActions } from '$app_reducers/document/slice';
 import { TextSelection } from '$app/interfaces/document';
 import { ReactEditor } from 'slate-react';
 import { syncRangeSelectionThunk } from '$app_reducers/document/async-actions/range_selection';
-import { getCollapsedRange } from '$app/utils/document/blocks/common';
-import { getEditorEndPoint, selectionIsForward } from '$app/utils/document/blocks/text/delta';
+import { getNodeEndSelection } from '$app/utils/document/blocks/text/delta';
+import { slateValueToDelta } from '$app/utils/document/blocks/common';
+import { isEqual } from '$app/utils/tool';
 
 export function useTextSelections(id: string, editor: ReactEditor) {
   const { rangeRef, currentSelection } = useSubscribeRangeSelection(id);
@@ -16,15 +16,21 @@ export function useTextSelections(id: string, editor: ReactEditor) {
 
   useEffect(() => {
     if (!rangeRef.current) return;
-    const { isDragging, focus, anchor } = rangeRef.current;
-    if (isDragging || anchor?.id !== focus?.id || !currentSelection || !Range.isCollapsed(currentSelection as BaseRange))
+    if (!currentSelection) {
+      ReactEditor.deselect(editor);
+      ReactEditor.blur(editor);
       return;
+    }
 
+    const { isDragging, focus } = rangeRef.current;
+    if (isDragging || focus?.id !== id) return;
     if (!ReactEditor.isFocused(editor)) {
       ReactEditor.focus(editor);
     }
-    Transforms.select(editor, currentSelection);
-  }, [currentSelection, editor, rangeRef]);
+    if (!isEqual(editor.selection, currentSelection)) {
+      Transforms.select(editor, currentSelection);
+    }
+  }, [currentSelection, editor, id, rangeRef]);
 
   const decorate: EditableProps['decorate'] = useCallback(
     (entry: [Node, Path]) => {
@@ -48,48 +54,6 @@ export function useTextSelections(id: string, editor: ReactEditor) {
     [editor, currentSelection]
   );
 
-  const onMouseDown: MouseEventHandler<HTMLDivElement> = useCallback(
-    (e) => {
-      const range = getCollapsedRange(id, editor.selection as TextSelection);
-      dispatch(
-        rangeSelectionActions.setRange({
-          ...range,
-          isDragging: true,
-        })
-      );
-    },
-    [dispatch, editor, id]
-  );
-
-  const onMouseMove: MouseEventHandler<HTMLDivElement> = useCallback(
-    (e) => {
-      if (!rangeRef.current) return;
-      const { isDragging, anchor } = rangeRef.current;
-      if (!isDragging || !anchor || ReactEditor.isFocused(editor)) return;
-
-      const isForward = selectionIsForward(anchor.selection);
-      if (!isForward) {
-        Transforms.select(editor, getEditorEndPoint(editor));
-      }
-      ReactEditor.focus(editor);
-    },
-    [editor, rangeRef]
-  );
-
-  const onMouseUp: MouseEventHandler<HTMLDivElement> = useCallback(
-    (e) => {
-      if (!rangeRef.current) return;
-      const { isDragging } = rangeRef.current;
-      if (!isDragging) return;
-      dispatch(
-        rangeSelectionActions.setRange({
-          isDragging: false,
-        })
-      );
-    },
-    [dispatch, rangeRef]
-  );
-
   const setLastActiveSelection = useCallback(
     (lastActiveSelection: Range) => {
       const selection = lastActiveSelection as TextSelection;
@@ -102,12 +66,33 @@ export function useTextSelections(id: string, editor: ReactEditor) {
     ReactEditor.deselect(editor);
   }, [editor]);
 
+  const onMouseMove = useCallback(
+    (e: MouseEvent) => {
+      if (!rangeRef.current) return;
+      const { isDragging, isForward, anchor } = rangeRef.current;
+      if (!isDragging || !anchor) return;
+      if (ReactEditor.isFocused(editor)) {
+        return;
+      }
+
+      if (anchor.id === id) {
+        Transforms.select(editor, anchor.selection);
+      } else if (!isForward) {
+        const endSelection = getNodeEndSelection(slateValueToDelta(editor.children));
+        Transforms.select(editor, {
+          anchor: endSelection.anchor,
+          focus: editor.selection?.focus || endSelection.focus,
+        });
+      }
+      ReactEditor.focus(editor);
+    },
+    [editor, id, rangeRef]
+  );
+
   return {
     decorate,
-    onMouseDown,
-    onMouseMove,
-    onMouseUp,
     onBlur,
+    onMouseMove,
     setLastActiveSelection,
   };
 }

+ 49 - 0
frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/TurnInto/TurnInto.hooks.ts

@@ -0,0 +1,49 @@
+import { useAppDispatch } from '$app/stores/store';
+import { useCallback, useContext } from 'react';
+import { DocumentControllerContext } from '$app/stores/effects/document/document_controller';
+import { BlockData, BlockType, NestedBlock } from '$app/interfaces/document';
+import { blockConfig } from '$app/constants/document/config';
+import { turnToBlockThunk } from '$app_reducers/document/async-actions';
+
+export function useTurnInto({ node, onClose }: { node: NestedBlock; onClose?: () => void }) {
+  const dispatch = useAppDispatch();
+
+  const controller = useContext(DocumentControllerContext);
+
+  const turnIntoBlock = useCallback(
+    async (type: BlockType, isSelected: boolean, data?: BlockData<any>) => {
+      if (!controller || isSelected) {
+        onClose?.();
+        return;
+      }
+
+      const config = blockConfig[type];
+      await dispatch(
+        turnToBlockThunk({
+          id: node.id,
+          controller,
+          type,
+          data: {
+            ...config.defaultData,
+            delta: node?.data?.delta || [],
+            ...data,
+          },
+        })
+      );
+      onClose?.();
+    },
+    [onClose, controller, dispatch, node]
+  );
+
+  const turnIntoHeading = useCallback(
+    (level: number, isSelected: boolean) => {
+      turnIntoBlock(BlockType.HeadingBlock, isSelected, { level });
+    },
+    [turnIntoBlock]
+  );
+
+  return {
+    turnIntoBlock,
+    turnIntoHeading,
+  };
+}

+ 138 - 0
frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/TurnInto/index.tsx

@@ -0,0 +1,138 @@
+import React, { useMemo } from 'react';
+import { BlockType } from '$app/interfaces/document';
+
+import {
+  ArrowRight,
+  Check,
+  DataObject,
+  FormatListBulleted,
+  FormatListNumbered,
+  FormatQuote,
+  Lightbulb,
+  TextFields,
+  Title,
+  Functions,
+} from '@mui/icons-material';
+import Popover, { PopoverProps } from '@mui/material/Popover';
+import { ListItemIcon, ListItemText, MenuItem } from '@mui/material';
+import { useSubscribeNode } from '$app/components/document/_shared/SubscribeNode.hooks';
+import { useTurnInto } from '$app/components/document/_shared/TurnInto/TurnInto.hooks';
+
+const TurnIntoPopover = ({
+  id,
+  onClose,
+  ...props
+}: {
+  id: string;
+  onClose?: () => void;
+} & PopoverProps) => {
+  const { node } = useSubscribeNode(id);
+  const { turnIntoHeading, turnIntoBlock } = useTurnInto({ node, onClose });
+
+  const options: {
+    type: BlockType;
+    title: string;
+    icon: React.ReactNode;
+    selected?: boolean;
+    onClick?: (type: BlockType, isSelected: boolean) => void;
+  }[] = useMemo(
+    () => [
+      {
+        type: BlockType.TextBlock,
+        title: 'Text',
+        icon: <TextFields />,
+      },
+      {
+        type: BlockType.HeadingBlock,
+        title: 'Heading 1',
+        icon: <Title />,
+        selected: node?.data?.level === 1,
+        onClick: (type: BlockType, isSelected: boolean) => {
+          turnIntoHeading(1, isSelected);
+        },
+      },
+      {
+        type: BlockType.HeadingBlock,
+        title: 'Heading 2',
+        icon: <Title />,
+        selected: node?.data?.level === 2,
+        onClick: (type: BlockType, isSelected: boolean) => {
+          turnIntoHeading(2, isSelected);
+        },
+      },
+      {
+        type: BlockType.HeadingBlock,
+        title: 'Heading 3',
+        icon: <Title />,
+        selected: node?.data?.level === 3,
+        onClick: (type: BlockType, isSelected: boolean) => {
+          turnIntoHeading(3, isSelected);
+        },
+      },
+      {
+        type: BlockType.TodoListBlock,
+        title: 'To-do list',
+        icon: <Check />,
+      },
+      {
+        type: BlockType.BulletedListBlock,
+        title: 'Bulleted list',
+        icon: <FormatListBulleted />,
+      },
+      {
+        type: BlockType.NumberedListBlock,
+        title: 'Numbered list',
+        icon: <FormatListNumbered />,
+      },
+      {
+        type: BlockType.ToggleListBlock,
+        title: 'Toggle list',
+        icon: <ArrowRight />,
+      },
+      {
+        type: BlockType.CodeBlock,
+        title: 'Code',
+        icon: <DataObject />,
+      },
+      {
+        type: BlockType.QuoteBlock,
+        title: 'Quote',
+        icon: <FormatQuote />,
+      },
+      {
+        type: BlockType.CalloutBlock,
+        title: 'Callout',
+        icon: <Lightbulb />,
+      },
+      // {
+      //   type: BlockType.EquationBlock,
+      //   title: 'Block Equation',
+      //   icon: <Functions />,
+      // },
+    ],
+    [node?.data?.level, turnIntoHeading]
+  );
+
+  return (
+    <Popover disableAutoFocus={true} onClose={onClose} {...props}>
+      {options.map((option) => {
+        const isSelected = option.type === node.type && option.selected !== false;
+        return (
+          <MenuItem
+            className={'w-[100%]'}
+            key={option.title}
+            onClick={() =>
+              option.onClick ? option.onClick(option.type, isSelected) : turnIntoBlock(option.type, isSelected)
+            }
+          >
+            <ListItemIcon>{option.icon}</ListItemIcon>
+            <ListItemText>{option.title}</ListItemText>
+            <ListItemIcon>{isSelected ? <Check /> : null}</ListItemIcon>
+          </MenuItem>
+        );
+      })}
+    </Popover>
+  );
+};
+
+export default TurnIntoPopover;

+ 46 - 37
frontend/appflowy_tauri/src/appflowy_app/constants/document/config.ts

@@ -1,44 +1,9 @@
-import { BlockData, BlockType } from '$app/interfaces/document';
+import { BlockConfig, BlockType, SplitRelationship, TextAction, TextActionMenuProps } from '$app/interfaces/document';
 
-export enum SplitRelationship {
-  NextSibling,
-  FirstChild,
-}
 /**
  * If the block type is not in the config, it will be thrown an error in development env
  */
-export const blockConfig: Record<
-  string,
-  {
-    /**
-     * Whether the block can have children
-     */
-    canAddChild: boolean;
-    /**
-     * The regexps that will be used to match the markdown flag
-     */
-    markdownRegexps?: RegExp[];
-
-    /**
-     * The default data of the block
-     */
-    defaultData?: BlockData<any>;
-
-    /**
-     * The props that will be passed to the text split function
-     */
-    splitProps?: {
-      /**
-       * The relationship between the next line block and the current block
-       */
-      nextLineRelationShip: SplitRelationship;
-      /**
-       * The type of the next line block
-       */
-      nextLineBlockType: BlockType;
-    };
-  }
-> = {
+export const blockConfig: Record<string, BlockConfig> = {
   [BlockType.TextBlock]: {
     canAddChild: true,
     defaultData: {
@@ -169,5 +134,49 @@ export const blockConfig: Record<
      * ```
      */
     markdownRegexps: [/^(```)$/],
+
+    textActionMenuProps: {
+      excludeItems: [TextAction.Code],
+    },
   },
 };
+
+export const defaultTextActionProps: TextActionMenuProps = {
+  customItems: [
+    TextAction.Turn,
+    TextAction.Bold,
+    TextAction.Italic,
+    TextAction.Underline,
+    TextAction.Strikethrough,
+    TextAction.Code,
+    TextAction.Equation,
+  ],
+  excludeItems: [],
+};
+
+export const multiLineTextActionProps: TextActionMenuProps = {
+  customItems: [TextAction.Bold, TextAction.Italic, TextAction.Underline, TextAction.Strikethrough, TextAction.Code],
+};
+
+export const multiLineTextActionGroups = [
+  [
+    TextAction.Bold,
+    TextAction.Italic,
+    TextAction.Underline,
+    TextAction.Strikethrough,
+    TextAction.Code,
+    TextAction.Equation,
+  ],
+];
+
+export const textActionGroups = [
+  [TextAction.Turn],
+  [
+    TextAction.Bold,
+    TextAction.Italic,
+    TextAction.Underline,
+    TextAction.Strikethrough,
+    TextAction.Code,
+    TextAction.Equation,
+  ],
+];

+ 0 - 25
frontend/appflowy_tauri/src/appflowy_app/constants/document/toolbar.ts

@@ -1,25 +0,0 @@
-
-export const iconSize = { width: 18, height: 18 };
-
-export const command: Record<string, { title: string; key: string }> = {
-  bold: {
-    title: 'Bold',
-    key: '⌘ + B',
-  },
-  underlined: {
-    title: 'Underlined',
-    key: '⌘ + U',
-  },
-  italic: {
-    title: 'Italic',
-    key: '⌘ + I',
-  },
-  code: {
-    title: 'Mark as code',
-    key: '⌘ + E',
-  },
-  strikethrough: {
-    title: 'Strike through',
-    key: '⌘ + Shift + S or ⌘ + Shift + X',
-  },
-};

+ 73 - 8
frontend/appflowy_tauri/src/appflowy_app/interfaces/document.ts

@@ -1,6 +1,6 @@
 import { Editor } from 'slate';
 import { RegionGrid } from '$app/utils/region_grid';
-import { ReactEditor } from "slate-react";
+import { ReactEditor } from 'slate-react';
 
 export enum BlockType {
   PageBlock = 'page',
@@ -11,6 +11,7 @@ export enum BlockType {
   NumberedListBlock = 'numbered_list',
   ToggleListBlock = 'toggle_list',
   CodeBlock = 'code',
+  EquationBlock = 'math_equation',
   EmbedBlock = 'embed',
   QuoteBlock = 'quote',
   CalloutBlock = 'callout',
@@ -87,7 +88,7 @@ export interface NestedBlock<Type = any> {
 }
 export interface TextDelta {
   insert: string;
-  attributes?: Record<string, string | boolean>;
+  attributes?: Record<string, string | boolean | undefined>;
 }
 
 export enum BlockActionType {
@@ -131,16 +132,21 @@ export interface DocumentState {
   children: Record<string, string[]>;
 }
 
+export interface RectSelectionState {
+  selection: string[];
+  isDragging: boolean;
+}
 export interface RangeSelectionState {
-  isDragging?: boolean,
-  anchor?: PointState,
-  focus?: PointState,
+  anchor?: PointState;
+  focus?: PointState;
+  isForward?: boolean;
+  isDragging: boolean;
+  selection: string[];
 }
 
-
 export interface PointState {
-  id: string,
-  selection: TextSelection
+  id: string;
+  selection: TextSelection;
 }
 
 export enum ChangeType {
@@ -161,3 +167,62 @@ export interface BlockPBValue {
 }
 
 export type TextBlockKeyEventHandlerParams = [React.KeyboardEvent<HTMLDivElement>, ReactEditor & Editor];
+
+export enum SplitRelationship {
+  NextSibling,
+  FirstChild,
+}
+export enum TextAction {
+  Turn = 'turn',
+  Bold = 'bold',
+  Italic = 'italic',
+  Underline = 'underlined',
+  Strikethrough = 'strikethrough',
+  Code = 'code',
+  Equation = 'equation',
+}
+export interface TextActionMenuProps {
+  /**
+   * The custom items that will be covered in the default items
+   */
+  customItems?: TextAction[];
+  /**
+   * The items that will be excluded from the default items
+   */
+  excludeItems?: TextAction[];
+}
+
+export interface BlockConfig {
+  /**
+   * Whether the block can have children
+   */
+  canAddChild: boolean;
+  /**
+   * The regexps that will be used to match the markdown flag
+   */
+  markdownRegexps?: RegExp[];
+
+  /**
+   * The default data of the block
+   */
+  defaultData?: BlockData<any>;
+
+  /**
+   * The props that will be passed to the text split function
+   */
+  splitProps?: {
+    /**
+     * The relationship between the next line block and the current block
+     */
+    nextLineRelationShip: SplitRelationship;
+    /**
+     * The type of the next line block
+     */
+    nextLineBlockType: BlockType;
+  };
+
+  /**
+   * The props that will be passed to the text action menu
+   */
+  textActionMenuProps?: TextActionMenuProps;
+}

+ 4 - 2
frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/text/backspace.ts

@@ -4,6 +4,7 @@ import { createAsyncThunk } from '@reduxjs/toolkit';
 import { outdentNodeThunk } from './outdent';
 import { turnToTextBlockThunk } from '$app_reducers/document/async-actions/blocks/text/turn_to';
 import { mergeToPrevLineThunk } from '$app_reducers/document/async-actions/blocks/text/merge';
+import { ReactEditor } from 'slate-react';
 
 /**
  * 1. If current node is not text block, turn it to text block
@@ -14,8 +15,8 @@ import { mergeToPrevLineThunk } from '$app_reducers/document/async-actions/block
  */
 export const backspaceNodeThunk = createAsyncThunk(
   'document/backspaceNode',
-  async (payload: { id: string; controller: DocumentController }, thunkAPI) => {
-    const { id, controller } = payload;
+  async (payload: { id: string; controller: DocumentController; editor: ReactEditor }, thunkAPI) => {
+    const { id, controller, editor } = payload;
     const { dispatch, getState } = thunkAPI;
     const state = (getState() as { document: DocumentState }).document;
     const node = state.nodes[id];
@@ -33,6 +34,7 @@ export const backspaceNodeThunk = createAsyncThunk(
     // merge to previous line when parent is root
     if (parentIsRoot || nextNodeId) {
       // merge to previous line
+      ReactEditor.deselect(editor);
       await dispatch(mergeToPrevLineThunk({ id, controller, deleteCurrentNode: true }));
       return;
     }

+ 3 - 3
frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/text/split.ts

@@ -1,11 +1,11 @@
-import { DocumentState } from '$app/interfaces/document';
+import { DocumentState, SplitRelationship } from '$app/interfaces/document';
 import { DocumentController } from '$app/stores/effects/document/document_controller';
 import { createAsyncThunk } from '@reduxjs/toolkit';
 import { setCursorBeforeThunk } from '../../cursor';
 import { newBlock } from '$app/utils/document/blocks/common';
-import { blockConfig, SplitRelationship } from '$app/constants/document/config';
+import { blockConfig } from '$app/constants/document/config';
 import { getSplitDelta } from '@/appflowy_app/utils/document/blocks/text/delta';
-import { ReactEditor } from "slate-react";
+import { ReactEditor } from 'slate-react';
 
 export const splitNodeThunk = createAsyncThunk(
   'document/splitNode',

+ 89 - 0
frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/format.ts

@@ -0,0 +1,89 @@
+import { createAsyncThunk } from '@reduxjs/toolkit';
+import { RootState } from '$app/stores/store';
+import { TextAction, TextDelta, TextSelection } from '$app/interfaces/document';
+import { getAfterRangeDelta, getBeforeRangeDelta, getRangeDelta } from '$app/utils/document/blocks/text/delta';
+import { DocumentController } from '$app/stores/effects/document/document_controller';
+
+export const getFormatActiveThunk = createAsyncThunk<boolean, TextAction>(
+  'document/getFormatActive',
+  async (format, thunkAPI) => {
+    const { getState } = thunkAPI;
+    const state = getState() as RootState;
+    const { document } = state;
+    const { selection, anchor, focus } = state.documentRangeSelection;
+
+    const match = (delta: TextDelta[], format: TextAction) => {
+      return delta.every((op) => op.attributes?.[format] === true);
+    };
+    return selection.every((id) => {
+      const node = document.nodes[id];
+      let delta = node.data?.delta as TextDelta[];
+      if (!delta) return false;
+
+      if (id === anchor?.id) {
+        delta = getRangeDelta(delta, anchor.selection);
+      } else if (id === focus?.id) {
+        delta = getRangeDelta(delta, focus.selection);
+      }
+      return match(delta, format);
+    });
+  }
+);
+
+export const toggleFormatThunk = createAsyncThunk(
+  'document/toggleFormat',
+  async (payload: { format: TextAction; controller: DocumentController; isActive: boolean }, thunkAPI) => {
+    const { getState } = thunkAPI;
+    const { format, controller, isActive } = payload;
+    const state = getState() as RootState;
+    const { document } = state;
+    const { selection, anchor, focus } = state.documentRangeSelection;
+    const ids = Array.from(new Set(selection));
+
+    const toggle = (delta: TextDelta[], format: TextAction) => {
+      return delta.map((op) => {
+        const attributes = {
+          ...op.attributes,
+          [format]: isActive ? undefined : true,
+        };
+        return {
+          insert: op.insert,
+          attributes: attributes,
+        };
+      });
+    };
+
+    const splitDelta = (delta: TextDelta[], selection: TextSelection) => {
+      const before = getBeforeRangeDelta(delta, selection);
+      const after = getAfterRangeDelta(delta, selection);
+      let middle = getRangeDelta(delta, selection);
+
+      middle = toggle(middle, format);
+
+      return [...before, ...middle, ...after];
+    };
+
+    const actions = ids.map((id) => {
+      const node = document.nodes[id];
+      let delta = node.data?.delta as TextDelta[];
+      if (!delta) return controller.getUpdateAction(node);
+
+      if (id === anchor?.id) {
+        delta = splitDelta(delta, anchor.selection);
+      } else if (id === focus?.id) {
+        delta = splitDelta(delta, focus.selection);
+      } else {
+        delta = toggle(delta, format);
+      }
+
+      return controller.getUpdateAction({
+        ...node,
+        data: {
+          ...node.data,
+          delta,
+        },
+      });
+    });
+    await controller.applyActions(actions);
+  }
+);

+ 49 - 16
frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/range_selection.ts

@@ -1,8 +1,10 @@
 import { createAsyncThunk } from '@reduxjs/toolkit';
-import { DocumentState, RangeSelectionState, TextSelection } from '$app/interfaces/document';
+import { DocumentState, TextSelection } from '$app/interfaces/document';
 import { rangeSelectionActions } from '$app_reducers/document/slice';
-import { getNodeEndSelection, selectionIsForward } from '$app/utils/document/blocks/text/delta';
+import { getNodeBeginSelection, getNodeEndSelection } from '$app/utils/document/blocks/text/delta';
 import { isEqual } from '$app/utils/tool';
+import { RootState } from '$app/stores/store';
+import { getNodesInRange } from '$app/utils/document/blocks/common';
 
 const amendAnchorNodeThunk = createAsyncThunk(
   'document/amendAnchorNode',
@@ -15,22 +17,18 @@ const amendAnchorNodeThunk = createAsyncThunk(
     const { id } = payload;
     const { getState, dispatch } = thunkAPI;
     const nodes = (getState() as { document: DocumentState }).document.nodes;
-    const range = (getState() as { documentRangeSelection: RangeSelectionState }).documentRangeSelection;
-    const { anchor: anchorNode, isDragging, focus: focusNode } = range;
+
+    const state = getState() as RootState;
+    const { isDragging, isForward, ...range } = state.documentRangeSelection;
+    const { anchor: anchorNode, focus: focusNode } = range;
 
     if (!isDragging || !anchorNode || anchorNode.id !== id) return;
     const isCollapsed = focusNode?.id === id && anchorNode?.id === id;
     if (isCollapsed) return;
 
     const selection = anchorNode.selection;
-    const isForward = selectionIsForward(selection);
     const node = nodes[id];
-    const focus = isForward
-      ? getNodeEndSelection(node.data.delta).anchor
-      : {
-          path: [0, 0],
-          offset: 0,
-        };
+    const focus = isForward ? getNodeEndSelection(node.data.delta).anchor : getNodeBeginSelection().anchor;
     if (isEqual(focus, selection.focus)) return;
     const newSelection = {
       anchor: selection.anchor,
@@ -58,29 +56,64 @@ export const syncRangeSelectionThunk = createAsyncThunk(
     thunkAPI
   ) => {
     const { getState, dispatch } = thunkAPI;
-    const range = (getState() as { documentRangeSelection: RangeSelectionState }).documentRangeSelection;
+    const state = getState() as RootState;
+    const range = state.documentRangeSelection;
+    const isDragging = range.isDragging;
 
     const { id, selection } = payload;
+
     const updateRange = {
       focus: {
         id,
         selection,
       },
     };
-    const isAnchor = range.anchor?.id === id;
-    if (isAnchor) {
+
+    if (!isDragging && range.anchor?.id === id) {
+      Object.assign(updateRange, {
+        anchor: {
+          id,
+          selection: { ...selection },
+        },
+      });
+      dispatch(rangeSelectionActions.setRange(updateRange));
+      return;
+    }
+    if (!range.anchor || range.anchor.id === id) {
       Object.assign(updateRange, {
         anchor: {
           id,
-          selection,
+          selection: {
+            anchor: !range.anchor ? selection.anchor : range.anchor.selection.anchor,
+            focus: selection.focus,
+          },
         },
       });
     }
+
     dispatch(rangeSelectionActions.setRange(updateRange));
 
     const anchorId = range.anchor?.id;
-    if (!isAnchor && anchorId) {
+    // more than one node is selected
+    if (anchorId && anchorId !== id) {
       dispatch(amendAnchorNodeThunk({ id: anchorId }));
     }
   }
 );
+
+export const setRangeSelectionThunk = createAsyncThunk('document/setRangeSelection', async (payload, thunkAPI) => {
+  const { getState, dispatch } = thunkAPI;
+  const state = getState() as RootState;
+  const { anchor, focus, isForward } = state.documentRangeSelection;
+  const document = state.document;
+  if (!anchor || !focus || isForward === undefined) return;
+  const rangeIds = getNodesInRange(
+    {
+      startId: anchor.id,
+      endId: focus.id,
+    },
+    isForward,
+    document
+  );
+  dispatch(rangeSelectionActions.setSelection(rangeIds));
+});

+ 39 - 9
frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/slice.ts

@@ -1,16 +1,29 @@
-import { DocumentState, Node, RangeSelectionState } from '@/appflowy_app/interfaces/document';
+import {
+  DocumentState,
+  Node,
+  PointState,
+  RangeSelectionState,
+  RectSelectionState,
+} from '@/appflowy_app/interfaces/document';
 import { BlockEventPayloadPB } from '@/services/backend';
 import { createSlice, PayloadAction } from '@reduxjs/toolkit';
 import { parseValue, matchChange } from '$app/utils/document/subscribe';
+import { getNodesInRange } from '$app/utils/document/blocks/common';
 
 const initialState: DocumentState = {
   nodes: {},
   children: {},
 };
 
-const rectSelectionInitialState: string[] = [];
+const rectSelectionInitialState: RectSelectionState = {
+  selection: [],
+  isDragging: false,
+};
 
-const rangeSelectionInitialState: RangeSelectionState = {};
+const rangeSelectionInitialState: RangeSelectionState = {
+  isDragging: false,
+  selection: [],
+};
 
 export const documentSlice = createSlice({
   name: 'document',
@@ -35,7 +48,6 @@ export const documentSlice = createSlice({
       state.nodes = nodes;
       state.children = children;
     },
-
     /**
      This function listens for changes in the data layer triggered by the data API,
      and updates the UI state accordingly.
@@ -67,14 +79,18 @@ export const rectSelectionSlice = createSlice({
   reducers: {
     // update block selections
     updateSelections: (state, action: PayloadAction<string[]>) => {
-      return action.payload;
+      state.selection = action.payload;
     },
 
     // set block selected
     setSelectionById: (state, action: PayloadAction<string>) => {
       const id = action.payload;
-      if (state.includes(id)) return;
-      state.push(id);
+      if (state.selection.includes(id)) return;
+      state.selection = [...state.selection, id];
+    },
+
+    setDragging: (state, action: PayloadAction<boolean>) => {
+      state.isDragging = action.payload;
     },
   },
 });
@@ -83,13 +99,27 @@ export const rangeSelectionSlice = createSlice({
   name: 'documentRangeSelection',
   initialState: rangeSelectionInitialState,
   reducers: {
-    setRange: (state, action: PayloadAction<RangeSelectionState>) => {
+    setRange: (
+      state,
+      action: PayloadAction<{
+        anchor?: PointState;
+        focus?: PointState;
+      }>
+    ) => {
       return {
         ...state,
         ...action.payload,
       };
     },
-
+    setSelection: (state, action: PayloadAction<string[]>) => {
+      state.selection = action.payload;
+    },
+    setDragging: (state, action: PayloadAction<boolean>) => {
+      state.isDragging = action.payload;
+    },
+    setForward: (state, action: PayloadAction<boolean>) => {
+      state.isForward = action.payload;
+    },
     clearRange: (state, _: PayloadAction) => {
       return rangeSelectionInitialState;
     },

+ 1 - 1
frontend/appflowy_tauri/src/appflowy_app/utils/document/blocks/code/decorate.ts

@@ -28,7 +28,7 @@ import 'prismjs/components/prism-php';
 import 'prismjs/components/prism-sql';
 import 'prismjs/components/prism-visual-basic';
 
-import { BaseRange, NodeEntry, Text, Path } from 'slate';
+import { BaseRange, NodeEntry, Text, Path, Range, Editor } from 'slate';
 
 const push_string = (
   token: string | Prism.Token,

+ 49 - 8
frontend/appflowy_tauri/src/appflowy_app/utils/document/blocks/common.ts

@@ -151,29 +151,70 @@ export function getCollapsedRange(id: string, selection: TextSelection): RangeSe
     anchor: clone(point),
     focus: clone(point),
     isDragging: false,
+    selection: [],
   };
 }
 
-export function nodeInRange(
-  id: string,
+export function iterateNodes(
   range: {
     startId: string;
     endId: string;
   },
   isForward: boolean,
-  document: DocumentState
+  document: DocumentState,
+  callback: (nodeId?: string) => boolean
 ) {
   const { startId, endId } = range;
   let currentId = startId;
-  while (currentId && currentId !== id && currentId !== endId) {
+  while (currentId && currentId !== endId) {
     if (isForward) {
       currentId = getNextLineId(document, currentId) || '';
     } else {
       currentId = getPrevLineId(document, currentId) || '';
     }
+    if (callback(currentId)) {
+      break;
+    }
   }
-  if (currentId === id) {
-    return true;
-  }
-  return false;
+}
+export function getNodesInRange(
+  range: {
+    startId: string;
+    endId: string;
+  },
+  isForward: boolean,
+  document: DocumentState
+) {
+  const nodeIds: string[] = [];
+  nodeIds.push(range.startId);
+  iterateNodes(range, isForward, document, (nodeId) => {
+    if (nodeId) {
+      nodeIds.push(nodeId);
+      return false;
+    } else {
+      return true;
+    }
+  });
+  nodeIds.push(range.endId);
+  return nodeIds;
+}
+
+export function nodeInRange(
+  id: string,
+  range: {
+    startId: string;
+    endId: string;
+  },
+  isForward: boolean,
+  document: DocumentState
+) {
+  let match = false;
+  iterateNodes(range, isForward, document, (nodeId) => {
+    if (nodeId === id) {
+      match = true;
+      return true;
+    }
+    return false;
+  });
+  return match;
 }

+ 22 - 0
frontend/appflowy_tauri/src/appflowy_app/utils/document/blocks/selection.ts

@@ -0,0 +1,22 @@
+export function isPointInBlock(target: HTMLElement | null) {
+  let node = target;
+  while (node) {
+    if (node.getAttribute('data-block-id')) {
+      return true;
+    }
+    node = node.parentElement;
+  }
+  return false;
+}
+
+export function getBlockIdByPoint(target: HTMLElement | null) {
+  let node = target;
+  while (node) {
+    const id = node.getAttribute('data-block-id');
+    if (id) {
+      return id;
+    }
+    node = node.parentElement;
+  }
+  return null;
+}

+ 83 - 2
frontend/appflowy_tauri/src/appflowy_app/utils/document/blocks/text/delta.ts

@@ -1,4 +1,4 @@
-import { Editor, Element, Location, Text } from 'slate';
+import { Editor, Element, Location, Text, Range } from 'slate';
 import { SelectionPoint, TextDelta, TextSelection } from '$app/interfaces/document';
 import * as Y from 'yjs';
 import { getDeltaFromSlateNodes } from '$app/utils/document/blocks/common';
@@ -14,6 +14,86 @@ export function getDelta(editor: Editor, at: Location): TextDelta[] {
   });
 }
 
+export function getBeforeRangeDelta(delta: TextDelta[], range: TextSelection): TextDelta[] {
+  const anchor = Range.start(range);
+  const sliceNodes = delta.slice(0, anchor.path[1] + 1);
+  const sliceEnd = sliceNodes[sliceNodes.length - 1];
+  const sliceEndText = sliceEnd.insert.slice(0, anchor.offset);
+  const sliceEndAttributes = sliceEnd.attributes;
+  const sliceEndNode =
+    sliceEndText.length > 0
+      ? {
+          insert: sliceEndText,
+          attributes: sliceEndAttributes,
+        }
+      : null;
+  const sliceMiddleNodes = sliceNodes.slice(0, sliceNodes.length - 1);
+
+  // eslint-disable-next-line @typescript-eslint/ban-ts-comment
+  // @ts-ignore
+  return [...sliceMiddleNodes, sliceEndNode].filter((item) => item);
+}
+
+export function getAfterRangeDelta(delta: TextDelta[], range: TextSelection): TextDelta[] {
+  const focus = Range.end(range);
+  const sliceNodes = delta.slice(focus.path[1], delta.length);
+  const sliceStart = sliceNodes[0];
+  const sliceStartText = sliceStart.insert.slice(focus.offset);
+  const sliceStartAttributes = sliceStart.attributes;
+  const sliceStartNode =
+    sliceStartText.length > 0
+      ? {
+          insert: sliceStartText,
+          attributes: sliceStartAttributes,
+        }
+      : null;
+  const sliceMiddleNodes = sliceNodes.slice(1, sliceNodes.length);
+  // eslint-disable-next-line @typescript-eslint/ban-ts-comment
+  // @ts-ignore
+  return [sliceStartNode, ...sliceMiddleNodes].filter((item) => item);
+}
+
+export function getRangeDelta(delta: TextDelta[], range: TextSelection): TextDelta[] {
+  const anchor = Range.start(range);
+  const focus = Range.end(range);
+  const sliceNodes = delta.slice(anchor.path[1], focus.path[1] + 1);
+  if (anchor.path[1] === focus.path[1]) {
+    return sliceNodes.map((item) => {
+      const { insert, attributes } = item;
+      const text = insert.slice(anchor.offset, focus.offset);
+      return {
+        insert: text,
+        attributes,
+      };
+    });
+  }
+  const sliceStart = sliceNodes[0];
+  const sliceEnd = sliceNodes[sliceNodes.length - 1];
+  const sliceStartText = sliceStart.insert.slice(anchor.offset);
+  const sliceEndText = sliceEnd.insert.slice(0, focus.offset);
+  const sliceStartAttributes = sliceStart.attributes;
+  const sliceEndAttributes = sliceEnd.attributes;
+  const sliceStartNode =
+    sliceStartText.length > 0
+      ? {
+          insert: sliceStartText,
+          attributes: sliceStartAttributes,
+        }
+      : null;
+
+  const sliceEndNode =
+    sliceEndText.length > 0
+      ? {
+          insert: sliceEndText,
+          attributes: sliceEndAttributes,
+        }
+      : null;
+  const sliceMiddleNodes = sliceNodes.slice(1, sliceNodes.length - 1);
+
+  // eslint-disable-next-line @typescript-eslint/ban-ts-comment
+  // @ts-ignore
+  return [sliceStartNode, ...sliceMiddleNodes, sliceEndNode].filter((item) => item);
+}
 /**
  * get the selection between the beginning of the editor and the point
  * form 0 to point
@@ -290,7 +370,8 @@ export function getPointOfCurrentLineBeginning(editor: Editor) {
   return beginPoint;
 }
 
-export function selectionIsForward(selection: TextSelection) {
+export function selectionIsForward(selection: TextSelection | null) {
+  if (!selection) return false;
   const { anchor, focus } = selection;
   if (!anchor || !focus) return false;
   return anchor.path[1] < focus.path[1] || (anchor.path[1] === focus.path[1] && anchor.offset < focus.offset);

+ 0 - 25
frontend/appflowy_tauri/src/appflowy_app/utils/document/blocks/text/format.ts

@@ -1,25 +0,0 @@
-import {
-  Editor,
-  Transforms,
-  Text,
-  Node
-} from 'slate';
-
-export function toggleFormat(editor: Editor, format: string) {
-  const isActive = isFormatActive(editor, format)
-  Transforms.setNodes(
-    editor,
-    { [format]: isActive ? null : true },
-    { match: Text.isText, split: true }
-  )
-}
-
-export const isFormatActive = (editor: Editor, format: string) => {
-  const [match] = Editor.nodes(editor, {
-    // eslint-disable-next-line @typescript-eslint/ban-ts-comment
-    // @ts-ignore
-    match: (n: Node) => n[format] === true,
-    mode: 'all',
-  })
-  return !!match
-}

+ 0 - 11
frontend/appflowy_tauri/src/appflowy_app/utils/document/blocks/text/hotkey.ts

@@ -1,5 +1,4 @@
 import isHotkey from 'is-hotkey';
-import { toggleFormat } from './format';
 import { Editor, Range } from 'slate';
 import { getAfterRangeAt, getBeforeRangeAt, pointInBegin, pointInEnd } from './delta';
 import { keyBoardEventKeyMap } from '$app/constants/document/text_block';
@@ -13,16 +12,6 @@ const HOTKEYS: Record<string, string> = {
   'mod+shift+S': 'strikethrough',
 };
 
-export function triggerHotkey(event: React.KeyboardEvent<HTMLDivElement>, editor: Editor) {
-  for (const hotkey in HOTKEYS) {
-    if (isHotkey(hotkey, event)) {
-      event.preventDefault();
-      const format = HOTKEYS[hotkey];
-      toggleFormat(editor, format);
-    }
-  }
-}
-
 export function canHandleBackspaceKey(event: React.KeyboardEvent<HTMLDivElement>, editor: Editor) {
   const isBackspaceKey = isHotkey('backspace', event);
   const selection = editor.selection;

+ 0 - 27
frontend/appflowy_tauri/src/appflowy_app/utils/document/blocks/text/toolbar.ts

@@ -1,27 +0,0 @@
-import { Editor, Range } from 'slate';
-export function calcToolbarPosition(editor: Editor, toolbarDom: HTMLDivElement, blockRect: DOMRect) {
-  const { selection } = editor;
-
-  if (!selection || Range.isCollapsed(selection) || Editor.string(editor, selection) === '') {
-    return;
-  }
-
-  const domSelection = window.getSelection();
-  let domRange;
-  if (domSelection?.rangeCount === 0) {
-    return;
-  } else {
-    domRange = domSelection?.getRangeAt(0);
-  }
-
-  const rect = domRange?.getBoundingClientRect() || { top: 0, left: 0, width: 0, height: 0 };
-  
-  const top = `${-toolbarDom.offsetHeight - 5 + (rect.top - blockRect.y)}px`;
-  const left = `${rect.left - blockRect.x - toolbarDom.offsetWidth / 2 + rect.width / 2}px`;
-  
-  return {
-    top,
-    left,
-  }
-  
-}

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

@@ -0,0 +1,19 @@
+export function calcToolbarPosition(toolbarDom: HTMLDivElement) {
+  const domSelection = window.getSelection();
+  let domRange;
+  if (domSelection?.rangeCount === 0) {
+    return;
+  } else {
+    domRange = domSelection?.getRangeAt(0);
+  }
+
+  const rect = domRange?.getBoundingClientRect() || { top: 0, left: 0, width: 0, height: 0 };
+
+  let top = rect.top - toolbarDom.offsetHeight;
+  let left = rect.left - toolbarDom.offsetWidth / 2 + rect.width / 2;
+
+  return {
+    top: top + 'px',
+    left: left + 'px',
+  };
+}

+ 8 - 2
frontend/appflowy_tauri/src/appflowy_app/views/DocumentPage.tsx

@@ -3,9 +3,15 @@ import { createTheme, ThemeProvider } from '@mui/material';
 import Root from '../components/document/Root';
 import { DocumentControllerContext } from '../stores/effects/document/document_controller';
 
-const theme = createTheme({
+const muiTheme = createTheme({
   typography: {
     fontFamily: ['Poppins'].join(','),
+    fontSize: 14,
+  },
+  palette: {
+    primary: {
+      main: '#00BCF0',
+    },
   },
 });
 
@@ -14,7 +20,7 @@ export const DocumentPage = () => {
 
   if (!documentId || !documentData || !controller) return null;
   return (
-    <ThemeProvider theme={theme}>
+    <ThemeProvider theme={muiTheme}>
       <DocumentControllerContext.Provider value={controller}>
         <Root documentData={documentData} />
       </DocumentControllerContext.Provider>

+ 2 - 0
frontend/appflowy_tauri/src/services/backend/index.ts

@@ -4,3 +4,5 @@ export * from "./models/flowy-folder2";
 export * from "./models/flowy-document2";
 export * from "./models/flowy-net";
 export * from "./models/flowy-error";
+export * from "./models/flowy-config";
+

+ 0 - 1
frontend/appflowy_tauri/src/styles/template.css

@@ -24,7 +24,6 @@ body {
   @apply bg-[transparent]
 }
 
-
 .btn {
   @apply rounded-xl border border-gray-500 px-4 py-3;
 }

Some files were not shown because too many files changed in this diff