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 { useCallback, useEffect, useMemo, useRef, useState } from 'react';
 import { useAppDispatch } from '$app/stores/store';
 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 { 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 [isDragging, setDragging] = useState(false);
   const startPointRef = useRef<number[]>([]);
   const startPointRef = useRef<number[]>([]);
@@ -20,8 +14,8 @@ export function useBlockSelection({
   const { getIntersectedBlockIds } = useNodesRect(container);
   const { getIntersectedBlockIds } = useNodesRect(container);
 
 
   useEffect(() => {
   useEffect(() => {
-    onDragging?.(isDragging);
-  }, [isDragging, onDragging]);
+    dispatch(rectSelectionActions.setDragging(isDragging));
+  }, [dispatch, isDragging]);
 
 
   const [rect, setRect] = useState<{
   const [rect, setRect] = useState<{
     startX: number;
     startX: number;
@@ -45,17 +39,6 @@ export function useBlockSelection({
     };
     };
   }, [container.scrollLeft, container.scrollTop, rect]);
   }, [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(
   const handleDragStart = useCallback(
     (e: MouseEvent) => {
     (e: MouseEvent) => {
       if (isPointInBlock(e.target as HTMLElement)) {
       if (isPointInBlock(e.target as HTMLElement)) {
@@ -74,7 +57,7 @@ export function useBlockSelection({
         endY: startY,
         endY: startY,
       });
       });
     },
     },
-    [container.scrollLeft, container.scrollTop, isPointInBlock]
+    [container.scrollLeft, container.scrollTop]
   );
   );
 
 
   const updateSelctionsByPoint = useCallback(
   const updateSelctionsByPoint = useCallback(
@@ -92,9 +75,9 @@ export function useBlockSelection({
       };
       };
       const blockIds = getIntersectedBlockIds(newRect);
       const blockIds = getIntersectedBlockIds(newRect);
       setRect(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(
   const handleDraging = useCallback(
@@ -119,7 +102,7 @@ export function useBlockSelection({
   const handleDragEnd = useCallback(
   const handleDragEnd = useCallback(
     (e: MouseEvent) => {
     (e: MouseEvent) => {
       if (isPointInBlock(e.target as HTMLElement) && !isDragging) {
       if (isPointInBlock(e.target as HTMLElement) && !isDragging) {
-        disaptch(rectSelectionActions.updateSelections([]));
+        dispatch(rectSelectionActions.updateSelections([]));
         return;
         return;
       }
       }
       if (!isDragging) return;
       if (!isDragging) return;
@@ -128,11 +111,10 @@ export function useBlockSelection({
       setDragging(false);
       setDragging(false);
       setRect(null);
       setRect(null);
     },
     },
-    [disaptch, isDragging, isPointInBlock, updateSelctionsByPoint]
+    [dispatch, isDragging, updateSelctionsByPoint]
   );
   );
 
 
   useEffect(() => {
   useEffect(() => {
-    if (!ref.current) return;
     document.addEventListener('mousedown', handleDragStart);
     document.addEventListener('mousedown', handleDragStart);
     document.addEventListener('mousemove', handleDraging);
     document.addEventListener('mousemove', handleDraging);
     document.addEventListener('mouseup', handleDragEnd);
     document.addEventListener('mouseup', handleDragEnd);
@@ -147,6 +129,5 @@ export function useBlockSelection({
   return {
   return {
     isDragging,
     isDragging,
     style,
     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 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 (
   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>
     </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 Portal from '../BlockPortal';
 import { IconButton } from '@mui/material';
 import { IconButton } from '@mui/material';
 import BlockMenu from '../BlockMenu';
 import BlockMenu from '../BlockMenu';
+import { useAppSelector } from '$app/stores/store';
 
 
 const sx = { height: 24, width: 24 };
 const sx = { height: 24, width: 24 };
 
 
 export default function BlockSideToolbar(props: { container: HTMLDivElement }) {
 export default function BlockSideToolbar(props: { container: HTMLDivElement }) {
   const { nodeId, style, ref, menuOpen, handleToggleMenu } = useBlockSideToolbar(props);
   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 (
   return (
     <>
     <>
       <Portal blockId={nodeId}>
       <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 { BlockType, NestedBlock } from '$app/interfaces/document';
 import TextBlock from '$app/components/document/TextBlock';
 import TextBlock from '$app/components/document/TextBlock';
 import NodeChildren from '$app/components/document/Node/NodeChildren';
 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 emojiData from '@emoji-mart/data';
 import Picker from '@emoji-mart/react';
 import Picker from '@emoji-mart/react';
 import { useCalloutBlock } from '$app/components/document/CalloutBlock/CalloutBlock.hooks';
 import { useCalloutBlock } from '$app/components/document/CalloutBlock/CalloutBlock.hooks';
+import Popover from '@mui/material/Popover';
 
 
 export default function CalloutBlock({
 export default function CalloutBlock({
   node,
   node,
@@ -17,7 +18,7 @@ export default function CalloutBlock({
 
 
   return (
   return (
     <div className={'my-1 flex rounded border border-solid border-main-accent bg-main-secondary p-4'}>
     <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'}>
         <div className={'flex h-[calc(1.5em_+_2px)] w-[24px] select-none items-center justify-start'}>
           <IconButton
           <IconButton
             aria-describedby={id}
             aria-describedby={id}
@@ -27,8 +28,9 @@ export default function CalloutBlock({
             {node.data.icon}
             {node.data.icon}
           </IconButton>
           </IconButton>
           <Popover
           <Popover
-            id={id}
+            className={'border-none bg-transparent shadow-none'}
             anchorEl={anchorEl}
             anchorEl={anchorEl}
+            disableAutoFocus={true}
             open={open}
             open={open}
             onClose={closeEmojiSelect}
             onClose={closeEmojiSelect}
             anchorOrigin={{
             anchorOrigin={{

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

@@ -8,6 +8,7 @@ interface CodeLeafProps extends RenderLeafProps {
     underlined?: boolean;
     underlined?: boolean;
     strikethrough?: boolean;
     strikethrough?: boolean;
     prism_token?: string;
     prism_token?: string;
+    selectionHighlighted?: boolean;
   };
   };
 }
 }
 
 
@@ -27,8 +28,15 @@ export const CodeLeaf = (props: CodeLeafProps) => {
     newChildren = <u>{newChildren}</u>;
     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 (
   return (
-    <span {...attributes} className={`token ${leaf.prism_token} ${leaf.strikethrough ? `line-through` : ''}`}>
+    <span {...attributes} className={className.join(' ')}>
       {newChildren}
       {newChildren}
     </span>
     </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 { BlockType, NestedBlock } from '$app/interfaces/document';
 import { useCodeBlock } from './CodeBlock.hooks';
 import { useCodeBlock } from './CodeBlock.hooks';
 import { Editable, Slate } from 'slate-react';
 import { Editable, Slate } from 'slate-react';
-import BlockHorizontalToolbar from '$app/components/document/BlockHorizontalToolbar';
 import React from 'react';
 import React from 'react';
 import { CodeLeaf, CodeBlockElement } from './elements';
 import { CodeLeaf, CodeBlockElement } from './elements';
 import SelectLanguage from './SelectLanguage';
 import SelectLanguage from './SelectLanguage';
@@ -23,10 +22,13 @@ export default function CodeBlock({
         <SelectLanguage id={id} language={language} />
         <SelectLanguage id={id} language={language} />
       </div>
       </div>
       <Slate editor={editor} onChange={onChange} value={value}>
       <Slate editor={editor} onChange={onChange} value={value}>
-        <BlockHorizontalToolbar id={id} />
         <Editable
         <Editable
           {...rest}
           {...rest}
-          decorate={(entry) => decorateCodeFunc(entry, language)}
+          decorate={(entry) => {
+            const codeRange = decorateCodeFunc(entry, language);
+            const range = rest.decorate(entry);
+            return [...range, ...codeRange];
+          }}
           renderLeaf={CodeLeaf}
           renderLeaf={CodeLeaf}
           renderElement={CodeBlockElement}
           renderElement={CodeBlockElement}
           placeholder={placeholder || 'Please enter some text...'}
           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 BlockSideToolbar from '../BlockSideToolbar';
 import BlockSelection from '../BlockSelection';
 import BlockSelection from '../BlockSelection';
+import TextActionMenu from '$app/components/document/TextActionMenu';
 
 
 export default function Overlay({ container }: { container: HTMLDivElement }) {
 export default function Overlay({ container }: { container: HTMLDivElement }) {
-  const [isDragging, setDragging] = useState(false);
   return (
   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 (
   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 React from 'react';
 import { FormatBold, FormatUnderlined, FormatItalic, CodeOutlined, StrikethroughSOutlined } from '@mui/icons-material';
 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 }) {
 export default function FormatIcon({ icon }: { icon: string }) {
   switch (icon) {
   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;
     selectionHighlighted?: boolean;
   };
   };
 }
 }
-const Leaf = ({
-  attributes,
-  children,
-  leaf,
-}: LeafProps) => {
+const Leaf = ({ attributes, children, leaf }: LeafProps) => {
   let newChildren = children;
   let newChildren = children;
   if (leaf.bold) {
   if (leaf.bold) {
     newChildren = <strong>{children}</strong>;
     newChildren = <strong>{children}</strong>;
   }
   }
 
 
-  if (leaf.code) {
-    newChildren = <code className='rounded-sm	 bg-[#F2FCFF] p-1'>{newChildren}</code>;
-  }
-
   if (leaf.italic) {
   if (leaf.italic) {
     newChildren = <em>{newChildren}</em>;
     newChildren = <em>{newChildren}</em>;
   }
   }
@@ -32,16 +24,14 @@ const Leaf = ({
     newChildren = <u>{newChildren}</u>;
     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 (
   return (
-    <span {...attributes} className={className}>
+    <span {...attributes} className={className.join(' ')}>
       {newChildren}
       {newChildren}
     </span>
     </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 { useTurnIntoBlock } from './TurnIntoEvents.hooks';
 import { useCallback, useContext, useMemo } from 'react';
 import { useCallback, useContext, useMemo } from 'react';
 import { keyBoardEventKeyMap } from '$app/constants/document/text_block';
 import { keyBoardEventKeyMap } from '$app/constants/document/text_block';
-import { triggerHotkey } from '$app/utils/document/blocks/text/hotkey';
 import { TextBlockKeyEventHandlerParams } from '$app/interfaces/document';
 import { TextBlockKeyEventHandlerParams } from '$app/interfaces/document';
 import isHotkey from 'is-hotkey';
 import isHotkey from 'is-hotkey';
 import { indentNodeThunk, outdentNodeThunk, splitNodeThunk } from '$app_reducers/document/async-actions';
 import { indentNodeThunk, outdentNodeThunk, splitNodeThunk } from '$app_reducers/document/async-actions';
 import { DocumentControllerContext } from '$app/stores/effects/document/document_controller';
 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 { useDefaultTextInputEvents } from '$app/components/document/_shared/Text/TextEvents.hooks';
 import { ReactEditor } from 'slate-react';
 import { ReactEditor } from 'slate-react';
 
 
 export function useTextBlockKeyEvent(id: string, editor: ReactEditor) {
 export function useTextBlockKeyEvent(id: string, editor: ReactEditor) {
   const controller = useContext(DocumentControllerContext);
   const controller = useContext(DocumentControllerContext);
   const dispatch = useAppDispatch();
   const dispatch = useAppDispatch();
-
   const defaultTextInputEvents = useDefaultTextInputEvents(id);
   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);
   const { turnIntoBlockEvents } = useTurnIntoBlock(id);
 
 
@@ -84,18 +87,20 @@ export function useTextBlockKeyEvent(id: string, editor: ReactEditor) {
 
 
   const onKeyDown = useCallback(
   const onKeyDown = useCallback(
     (event: React.KeyboardEvent<HTMLDivElement>) => {
     (event: React.KeyboardEvent<HTMLDivElement>) => {
+      if (!isFocusCurrentNode) {
+        event.preventDefault();
+        return;
+      }
+
+      event.stopPropagation();
       // This is list of key events that can be handled by TextBlock
       // This is list of key events that can be handled by TextBlock
       const keyEvents = [...events, ...turnIntoBlockEvents];
       const keyEvents = [...events, ...turnIntoBlockEvents];
 
 
       const matchKeys = keyEvents.filter((keyEvent) => keyEvent.canHandle(event, editor));
       const matchKeys = keyEvents.filter((keyEvent) => keyEvent.canHandle(event, editor));
-      if (matchKeys.length === 0) {
-        triggerHotkey(event, editor);
-        return;
-      }
 
 
       matchKeys.forEach((matchKey) => matchKey.handler(event, editor));
       matchKeys.forEach((matchKey) => matchKey.handler(event, editor));
     },
     },
-    [editor, events, turnIntoBlockEvents]
+    [editor, events, turnIntoBlockEvents, isFocusCurrentNode]
   );
   );
 
 
   return {
   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 { Slate, Editable } from 'slate-react';
 import Leaf from './Leaf';
 import Leaf from './Leaf';
 import { useTextBlock } from './TextBlock.hooks';
 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 { NestedBlock } from '$app/interfaces/document';
 import NodeChildren from '$app/components/document/Node/NodeChildren';
 import NodeChildren from '$app/components/document/Node/NodeChildren';
 
 
@@ -23,7 +22,6 @@ function TextBlock({
     <>
     <>
       <div className={`px-1 py-[2px] ${className}`}>
       <div className={`px-1 py-[2px] ${className}`}>
         <Slate editor={editor} onChange={onChange} value={value}>
         <Slate editor={editor} onChange={onChange} value={value}>
-          {/*<BlockHorizontalToolbar id={node.id} />*/}
           <Editable
           <Editable
             {...rest}
             {...rest}
             renderLeaf={(leafProps) => <Leaf {...leafProps} />}
             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 { useMemo, useRef } from 'react';
 import { DocumentState, Node, RangeSelectionState } from '$app/interfaces/document';
 import { DocumentState, Node, RangeSelectionState } from '$app/interfaces/document';
 import { nodeInRange } from '$app/utils/document/blocks/common';
 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
  * Subscribe node information
@@ -18,7 +18,7 @@ export function useSubscribeNode(id: string) {
   });
   });
 
 
   const isSelected = useAppSelector<boolean>((state) => {
   const isSelected = useAppSelector<boolean>((state) => {
-    return state.documentRectSelection.includes(id) || false;
+    return state.documentRectSelection.selection.includes(id) || false;
   });
   });
 
 
   // Memoize the node and its children
   // Memoize the node and its children
@@ -50,6 +50,7 @@ export function useSubscribeRangeSelection(id: string) {
     if (range.focus?.id === id) {
     if (range.focus?.id === id) {
       return range.focus.selection;
       return range.focus.selection;
     }
     }
+
     return getAmendInRangeNodeSelection(id, range, state.document);
     return getAmendInRangeNodeSelection(id, range, state.document);
   });
   });
 
 
@@ -60,17 +61,17 @@ export function useSubscribeRangeSelection(id: string) {
 }
 }
 
 
 function getAmendInRangeNodeSelection(id: string, range: RangeSelectionState, document: DocumentState) {
 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;
     return null;
   }
   }
-  const isForward = selectionIsForward(range.anchor.selection);
+
   const isNodeInRange = nodeInRange(
   const isNodeInRange = nodeInRange(
     id,
     id,
     {
     {
       startId: range.anchor.id,
       startId: range.anchor.id,
       endId: range.focus.id,
       endId: range.focus.id,
     },
     },
-    isForward,
+    range.isForward,
     document
     document
   );
   );
 
 

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

@@ -11,7 +11,7 @@ import {
   canHandleUpKey,
   canHandleUpKey,
 } from '$app/utils/document/blocks/text/hotkey';
 } from '$app/utils/document/blocks/text/hotkey';
 import { TextBlockKeyEventHandlerParams } from '$app/interfaces/document';
 import { TextBlockKeyEventHandlerParams } from '$app/interfaces/document';
-import { ReactEditor } from "slate-react";
+import { ReactEditor } from 'slate-react';
 
 
 export function useDefaultTextInputEvents(id: string) {
 export function useDefaultTextInputEvents(id: string) {
   const dispatch = useAppDispatch();
   const dispatch = useAppDispatch();
@@ -81,11 +81,11 @@ export function useDefaultTextInputEvents(id: string) {
       triggerEventKey: keyBoardEventKeyMap.Backspace,
       triggerEventKey: keyBoardEventKeyMap.Backspace,
       canHandle: canHandleBackspaceKey,
       canHandle: canHandleBackspaceKey,
       handler: (...args: TextBlockKeyEventHandlerParams) => {
       handler: (...args: TextBlockKeyEventHandlerParams) => {
-        const [e, _] = args;
+        const [e, editor] = args;
         e.preventDefault();
         e.preventDefault();
         void (async () => {
         void (async () => {
           if (!controller) return;
           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 { withReact } from 'slate-react';
 import { useCallback, useContext, useEffect, useMemo, useRef, useState } from '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 { updateNodeDeltaThunk } from '$app_reducers/document/async-actions/blocks/text/update';
 import { deltaToSlateValue, slateValueToDelta } from '$app/utils/document/blocks/common';
 import { deltaToSlateValue, slateValueToDelta } from '$app/utils/document/blocks/common';
 import { isSameDelta } from '$app/utils/document/blocks/text/delta';
 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 { useSubscribeNode } from '$app/components/document/_shared/SubscribeNode.hooks';
 import { useTextSelections } from '$app/components/document/_shared/Text/TextSelection.hooks';
 import { useTextSelections } from '$app/components/document/_shared/Text/TextSelection.hooks';
 
 
 export function useTextInput(id: string) {
 export function useTextInput(id: string) {
   const { node } = useSubscribeNode(id);
   const { node } = useSubscribeNode(id);
+
   const [editor] = useState(() => withReact(createEditor()));
   const [editor] = useState(() => withReact(createEditor()));
   const isComposition = useRef(false);
   const isComposition = useRef(false);
   const { setLastActiveSelection, ...selectionProps } = useTextSelections(id, editor);
   const { setLastActiveSelection, ...selectionProps } = useTextSelections(id, editor);
@@ -24,16 +24,15 @@ export function useTextInput(id: string) {
     }
     }
     return node.data.delta;
     return node.data.delta;
   }, [node]);
   }, [node]);
+  const [value, setValue] = useState<Descendant[]>(deltaToSlateValue(delta));
 
 
   const { sync, receive } = useUpdateDelta(id, editor);
   const { sync, receive } = useUpdateDelta(id, editor);
 
 
-  const [value, setValue] = useState<Descendant[]>(deltaToSlateValue(delta));
-
   // Update the editor's value when the node's delta changes.
   // Update the editor's value when the node's delta changes.
   useEffect(() => {
   useEffect(() => {
     // If composition is in progress, do nothing.
     // If composition is in progress, do nothing.
     if (isComposition.current) return;
     if (isComposition.current) return;
-    receive(delta);
+    receive(delta, setValue);
   }, [delta, receive]);
   }, [delta, receive]);
 
 
   // Update the node's delta when the editor's value changes.
   // 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 dispatch = useAppDispatch();
   const penddingRef = useRef(false);
   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]);
   }, [controller, dispatch, editor, id]);
 
 
   const sync = useCallback(() => {
   const sync = useCallback(() => {
     // set pendding flag
     // set pendding flag
     penddingRef.current = true;
     penddingRef.current = true;
-    debounceUpdate();
-  }, [debounceUpdate]);
+    update();
+  }, [update]);
 
 
   const receive = useCallback(
   const receive = useCallback(
-    (delta: TextDelta[]) => {
+    (delta: TextDelta[], setValue: (children: Descendant[]) => void) => {
       // if pendding, do nothing
       // if pendding, do nothing
       if (penddingRef.current) return;
       if (penddingRef.current) return;
 
 
@@ -123,18 +119,14 @@ function useUpdateDelta(id: string, editor: Editor) {
       const isSame = isSameDelta(delta, localDelta);
       const isSame = isSameDelta(delta, localDelta);
       if (isSame) return;
       if (isSame) return;
 
 
+      Transforms.deselect(editor);
       const slateValue = deltaToSlateValue(delta);
       const slateValue = deltaToSlateValue(delta);
       editor.children = slateValue;
       editor.children = slateValue;
+      setValue(slateValue);
     },
     },
     [editor]
     [editor]
   );
   );
 
 
-  useEffect(() => {
-    return () => {
-      debounceUpdate.cancel();
-    };
-  });
-
   return {
   return {
     sync,
     sync,
     receive,
     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 { BaseRange, Editor, Node, Path, Range, Transforms } from 'slate';
 import { EditableProps } from 'slate-react/dist/components/editable';
 import { EditableProps } from 'slate-react/dist/components/editable';
 import { useSubscribeRangeSelection } from '$app/components/document/_shared/SubscribeNode.hooks';
 import { useSubscribeRangeSelection } from '$app/components/document/_shared/SubscribeNode.hooks';
 import { useAppDispatch } from '$app/stores/store';
 import { useAppDispatch } from '$app/stores/store';
-import { rangeSelectionActions } from '$app_reducers/document/slice';
 import { TextSelection } from '$app/interfaces/document';
 import { TextSelection } from '$app/interfaces/document';
 import { ReactEditor } from 'slate-react';
 import { ReactEditor } from 'slate-react';
 import { syncRangeSelectionThunk } from '$app_reducers/document/async-actions/range_selection';
 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) {
 export function useTextSelections(id: string, editor: ReactEditor) {
   const { rangeRef, currentSelection } = useSubscribeRangeSelection(id);
   const { rangeRef, currentSelection } = useSubscribeRangeSelection(id);
@@ -16,15 +16,21 @@ export function useTextSelections(id: string, editor: ReactEditor) {
 
 
   useEffect(() => {
   useEffect(() => {
     if (!rangeRef.current) return;
     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;
       return;
+    }
 
 
+    const { isDragging, focus } = rangeRef.current;
+    if (isDragging || focus?.id !== id) return;
     if (!ReactEditor.isFocused(editor)) {
     if (!ReactEditor.isFocused(editor)) {
       ReactEditor.focus(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(
   const decorate: EditableProps['decorate'] = useCallback(
     (entry: [Node, Path]) => {
     (entry: [Node, Path]) => {
@@ -48,48 +54,6 @@ export function useTextSelections(id: string, editor: ReactEditor) {
     [editor, currentSelection]
     [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(
   const setLastActiveSelection = useCallback(
     (lastActiveSelection: Range) => {
     (lastActiveSelection: Range) => {
       const selection = lastActiveSelection as TextSelection;
       const selection = lastActiveSelection as TextSelection;
@@ -102,12 +66,33 @@ export function useTextSelections(id: string, editor: ReactEditor) {
     ReactEditor.deselect(editor);
     ReactEditor.deselect(editor);
   }, [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 {
   return {
     decorate,
     decorate,
-    onMouseDown,
-    onMouseMove,
-    onMouseUp,
     onBlur,
     onBlur,
+    onMouseMove,
     setLastActiveSelection,
     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
  * 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]: {
   [BlockType.TextBlock]: {
     canAddChild: true,
     canAddChild: true,
     defaultData: {
     defaultData: {
@@ -169,5 +134,49 @@ export const blockConfig: Record<
      * ```
      * ```
      */
      */
     markdownRegexps: [/^(```)$/],
     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 { Editor } from 'slate';
 import { RegionGrid } from '$app/utils/region_grid';
 import { RegionGrid } from '$app/utils/region_grid';
-import { ReactEditor } from "slate-react";
+import { ReactEditor } from 'slate-react';
 
 
 export enum BlockType {
 export enum BlockType {
   PageBlock = 'page',
   PageBlock = 'page',
@@ -11,6 +11,7 @@ export enum BlockType {
   NumberedListBlock = 'numbered_list',
   NumberedListBlock = 'numbered_list',
   ToggleListBlock = 'toggle_list',
   ToggleListBlock = 'toggle_list',
   CodeBlock = 'code',
   CodeBlock = 'code',
+  EquationBlock = 'math_equation',
   EmbedBlock = 'embed',
   EmbedBlock = 'embed',
   QuoteBlock = 'quote',
   QuoteBlock = 'quote',
   CalloutBlock = 'callout',
   CalloutBlock = 'callout',
@@ -87,7 +88,7 @@ export interface NestedBlock<Type = any> {
 }
 }
 export interface TextDelta {
 export interface TextDelta {
   insert: string;
   insert: string;
-  attributes?: Record<string, string | boolean>;
+  attributes?: Record<string, string | boolean | undefined>;
 }
 }
 
 
 export enum BlockActionType {
 export enum BlockActionType {
@@ -131,16 +132,21 @@ export interface DocumentState {
   children: Record<string, string[]>;
   children: Record<string, string[]>;
 }
 }
 
 
+export interface RectSelectionState {
+  selection: string[];
+  isDragging: boolean;
+}
 export interface RangeSelectionState {
 export interface RangeSelectionState {
-  isDragging?: boolean,
-  anchor?: PointState,
-  focus?: PointState,
+  anchor?: PointState;
+  focus?: PointState;
+  isForward?: boolean;
+  isDragging: boolean;
+  selection: string[];
 }
 }
 
 
-
 export interface PointState {
 export interface PointState {
-  id: string,
-  selection: TextSelection
+  id: string;
+  selection: TextSelection;
 }
 }
 
 
 export enum ChangeType {
 export enum ChangeType {
@@ -161,3 +167,62 @@ export interface BlockPBValue {
 }
 }
 
 
 export type TextBlockKeyEventHandlerParams = [React.KeyboardEvent<HTMLDivElement>, ReactEditor & Editor];
 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 { outdentNodeThunk } from './outdent';
 import { turnToTextBlockThunk } from '$app_reducers/document/async-actions/blocks/text/turn_to';
 import { turnToTextBlockThunk } from '$app_reducers/document/async-actions/blocks/text/turn_to';
 import { mergeToPrevLineThunk } from '$app_reducers/document/async-actions/blocks/text/merge';
 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
  * 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(
 export const backspaceNodeThunk = createAsyncThunk(
   'document/backspaceNode',
   '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 { dispatch, getState } = thunkAPI;
     const state = (getState() as { document: DocumentState }).document;
     const state = (getState() as { document: DocumentState }).document;
     const node = state.nodes[id];
     const node = state.nodes[id];
@@ -33,6 +34,7 @@ export const backspaceNodeThunk = createAsyncThunk(
     // merge to previous line when parent is root
     // merge to previous line when parent is root
     if (parentIsRoot || nextNodeId) {
     if (parentIsRoot || nextNodeId) {
       // merge to previous line
       // merge to previous line
+      ReactEditor.deselect(editor);
       await dispatch(mergeToPrevLineThunk({ id, controller, deleteCurrentNode: true }));
       await dispatch(mergeToPrevLineThunk({ id, controller, deleteCurrentNode: true }));
       return;
       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 { DocumentController } from '$app/stores/effects/document/document_controller';
 import { createAsyncThunk } from '@reduxjs/toolkit';
 import { createAsyncThunk } from '@reduxjs/toolkit';
 import { setCursorBeforeThunk } from '../../cursor';
 import { setCursorBeforeThunk } from '../../cursor';
 import { newBlock } from '$app/utils/document/blocks/common';
 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 { getSplitDelta } from '@/appflowy_app/utils/document/blocks/text/delta';
-import { ReactEditor } from "slate-react";
+import { ReactEditor } from 'slate-react';
 
 
 export const splitNodeThunk = createAsyncThunk(
 export const splitNodeThunk = createAsyncThunk(
   'document/splitNode',
   '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 { 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 { 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 { isEqual } from '$app/utils/tool';
+import { RootState } from '$app/stores/store';
+import { getNodesInRange } from '$app/utils/document/blocks/common';
 
 
 const amendAnchorNodeThunk = createAsyncThunk(
 const amendAnchorNodeThunk = createAsyncThunk(
   'document/amendAnchorNode',
   'document/amendAnchorNode',
@@ -15,22 +17,18 @@ const amendAnchorNodeThunk = createAsyncThunk(
     const { id } = payload;
     const { id } = payload;
     const { getState, dispatch } = thunkAPI;
     const { getState, dispatch } = thunkAPI;
     const nodes = (getState() as { document: DocumentState }).document.nodes;
     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;
     if (!isDragging || !anchorNode || anchorNode.id !== id) return;
     const isCollapsed = focusNode?.id === id && anchorNode?.id === id;
     const isCollapsed = focusNode?.id === id && anchorNode?.id === id;
     if (isCollapsed) return;
     if (isCollapsed) return;
 
 
     const selection = anchorNode.selection;
     const selection = anchorNode.selection;
-    const isForward = selectionIsForward(selection);
     const node = nodes[id];
     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;
     if (isEqual(focus, selection.focus)) return;
     const newSelection = {
     const newSelection = {
       anchor: selection.anchor,
       anchor: selection.anchor,
@@ -58,29 +56,64 @@ export const syncRangeSelectionThunk = createAsyncThunk(
     thunkAPI
     thunkAPI
   ) => {
   ) => {
     const { getState, dispatch } = 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 { id, selection } = payload;
+
     const updateRange = {
     const updateRange = {
       focus: {
       focus: {
         id,
         id,
         selection,
         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, {
       Object.assign(updateRange, {
         anchor: {
         anchor: {
           id,
           id,
-          selection,
+          selection: {
+            anchor: !range.anchor ? selection.anchor : range.anchor.selection.anchor,
+            focus: selection.focus,
+          },
         },
         },
       });
       });
     }
     }
+
     dispatch(rangeSelectionActions.setRange(updateRange));
     dispatch(rangeSelectionActions.setRange(updateRange));
 
 
     const anchorId = range.anchor?.id;
     const anchorId = range.anchor?.id;
-    if (!isAnchor && anchorId) {
+    // more than one node is selected
+    if (anchorId && anchorId !== id) {
       dispatch(amendAnchorNodeThunk({ id: anchorId }));
       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 { BlockEventPayloadPB } from '@/services/backend';
 import { createSlice, PayloadAction } from '@reduxjs/toolkit';
 import { createSlice, PayloadAction } from '@reduxjs/toolkit';
 import { parseValue, matchChange } from '$app/utils/document/subscribe';
 import { parseValue, matchChange } from '$app/utils/document/subscribe';
+import { getNodesInRange } from '$app/utils/document/blocks/common';
 
 
 const initialState: DocumentState = {
 const initialState: DocumentState = {
   nodes: {},
   nodes: {},
   children: {},
   children: {},
 };
 };
 
 
-const rectSelectionInitialState: string[] = [];
+const rectSelectionInitialState: RectSelectionState = {
+  selection: [],
+  isDragging: false,
+};
 
 
-const rangeSelectionInitialState: RangeSelectionState = {};
+const rangeSelectionInitialState: RangeSelectionState = {
+  isDragging: false,
+  selection: [],
+};
 
 
 export const documentSlice = createSlice({
 export const documentSlice = createSlice({
   name: 'document',
   name: 'document',
@@ -35,7 +48,6 @@ export const documentSlice = createSlice({
       state.nodes = nodes;
       state.nodes = nodes;
       state.children = children;
       state.children = children;
     },
     },
-
     /**
     /**
      This function listens for changes in the data layer triggered by the data API,
      This function listens for changes in the data layer triggered by the data API,
      and updates the UI state accordingly.
      and updates the UI state accordingly.
@@ -67,14 +79,18 @@ export const rectSelectionSlice = createSlice({
   reducers: {
   reducers: {
     // update block selections
     // update block selections
     updateSelections: (state, action: PayloadAction<string[]>) => {
     updateSelections: (state, action: PayloadAction<string[]>) => {
-      return action.payload;
+      state.selection = action.payload;
     },
     },
 
 
     // set block selected
     // set block selected
     setSelectionById: (state, action: PayloadAction<string>) => {
     setSelectionById: (state, action: PayloadAction<string>) => {
       const id = action.payload;
       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',
   name: 'documentRangeSelection',
   initialState: rangeSelectionInitialState,
   initialState: rangeSelectionInitialState,
   reducers: {
   reducers: {
-    setRange: (state, action: PayloadAction<RangeSelectionState>) => {
+    setRange: (
+      state,
+      action: PayloadAction<{
+        anchor?: PointState;
+        focus?: PointState;
+      }>
+    ) => {
       return {
       return {
         ...state,
         ...state,
         ...action.payload,
         ...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) => {
     clearRange: (state, _: PayloadAction) => {
       return rangeSelectionInitialState;
       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-sql';
 import 'prismjs/components/prism-visual-basic';
 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 = (
 const push_string = (
   token: string | Prism.Token,
   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),
     anchor: clone(point),
     focus: clone(point),
     focus: clone(point),
     isDragging: false,
     isDragging: false,
+    selection: [],
   };
   };
 }
 }
 
 
-export function nodeInRange(
-  id: string,
+export function iterateNodes(
   range: {
   range: {
     startId: string;
     startId: string;
     endId: string;
     endId: string;
   },
   },
   isForward: boolean,
   isForward: boolean,
-  document: DocumentState
+  document: DocumentState,
+  callback: (nodeId?: string) => boolean
 ) {
 ) {
   const { startId, endId } = range;
   const { startId, endId } = range;
   let currentId = startId;
   let currentId = startId;
-  while (currentId && currentId !== id && currentId !== endId) {
+  while (currentId && currentId !== endId) {
     if (isForward) {
     if (isForward) {
       currentId = getNextLineId(document, currentId) || '';
       currentId = getNextLineId(document, currentId) || '';
     } else {
     } else {
       currentId = getPrevLineId(document, currentId) || '';
       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 { SelectionPoint, TextDelta, TextSelection } from '$app/interfaces/document';
 import * as Y from 'yjs';
 import * as Y from 'yjs';
 import { getDeltaFromSlateNodes } from '$app/utils/document/blocks/common';
 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
  * get the selection between the beginning of the editor and the point
  * form 0 to point
  * form 0 to point
@@ -290,7 +370,8 @@ export function getPointOfCurrentLineBeginning(editor: Editor) {
   return beginPoint;
   return beginPoint;
 }
 }
 
 
-export function selectionIsForward(selection: TextSelection) {
+export function selectionIsForward(selection: TextSelection | null) {
+  if (!selection) return false;
   const { anchor, focus } = selection;
   const { anchor, focus } = selection;
   if (!anchor || !focus) return false;
   if (!anchor || !focus) return false;
   return anchor.path[1] < focus.path[1] || (anchor.path[1] === focus.path[1] && anchor.offset < focus.offset);
   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 isHotkey from 'is-hotkey';
-import { toggleFormat } from './format';
 import { Editor, Range } from 'slate';
 import { Editor, Range } from 'slate';
 import { getAfterRangeAt, getBeforeRangeAt, pointInBegin, pointInEnd } from './delta';
 import { getAfterRangeAt, getBeforeRangeAt, pointInBegin, pointInEnd } from './delta';
 import { keyBoardEventKeyMap } from '$app/constants/document/text_block';
 import { keyBoardEventKeyMap } from '$app/constants/document/text_block';
@@ -13,16 +12,6 @@ const HOTKEYS: Record<string, string> = {
   'mod+shift+S': 'strikethrough',
   '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) {
 export function canHandleBackspaceKey(event: React.KeyboardEvent<HTMLDivElement>, editor: Editor) {
   const isBackspaceKey = isHotkey('backspace', event);
   const isBackspaceKey = isHotkey('backspace', event);
   const selection = editor.selection;
   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 Root from '../components/document/Root';
 import { DocumentControllerContext } from '../stores/effects/document/document_controller';
 import { DocumentControllerContext } from '../stores/effects/document/document_controller';
 
 
-const theme = createTheme({
+const muiTheme = createTheme({
   typography: {
   typography: {
     fontFamily: ['Poppins'].join(','),
     fontFamily: ['Poppins'].join(','),
+    fontSize: 14,
+  },
+  palette: {
+    primary: {
+      main: '#00BCF0',
+    },
   },
   },
 });
 });
 
 
@@ -14,7 +20,7 @@ export const DocumentPage = () => {
 
 
   if (!documentId || !documentData || !controller) return null;
   if (!documentId || !documentData || !controller) return null;
   return (
   return (
-    <ThemeProvider theme={theme}>
+    <ThemeProvider theme={muiTheme}>
       <DocumentControllerContext.Provider value={controller}>
       <DocumentControllerContext.Provider value={controller}>
         <Root documentData={documentData} />
         <Root documentData={documentData} />
       </DocumentControllerContext.Provider>
       </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-document2";
 export * from "./models/flowy-net";
 export * from "./models/flowy-net";
 export * from "./models/flowy-error";
 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]
   @apply bg-[transparent]
 }
 }
 
 
-
 .btn {
 .btn {
   @apply rounded-xl border border-gray-500 px-4 py-3;
   @apply rounded-xl border border-gray-500 px-4 py-3;
 }
 }

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