Browse Source

Support block toolbar (#2566)

* feat: support block toolbar in left side

* fix: export delete and duplicate

* feat: slash menu
Kilu.He 2 năm trước cách đây
mục cha
commit
b41b212b0d
26 tập tin đã thay đổi với 745 bổ sung181 xóa
  1. 0 30
      frontend/appflowy_tauri/src/appflowy_app/components/document/BlockMenu/BlockMenu.hooks.ts
  2. 0 31
      frontend/appflowy_tauri/src/appflowy_app/components/document/BlockMenu/MenuItem.hooks.ts
  3. 0 32
      frontend/appflowy_tauri/src/appflowy_app/components/document/BlockMenu/MenuItem.tsx
  4. 0 42
      frontend/appflowy_tauri/src/appflowy_app/components/document/BlockMenu/index.tsx
  5. 35 0
      frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSideToolbar/BlockMenu.hooks.ts
  6. 60 0
      frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSideToolbar/BlockMenu.tsx
  7. 62 0
      frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSideToolbar/BlockMenuTurnInto.tsx
  8. 45 18
      frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSideToolbar/BlockSideToolbar.hooks.tsx
  9. 36 0
      frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSideToolbar/MenuItem.tsx
  10. 24 0
      frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSideToolbar/ToolbarButton.tsx
  11. 52 17
      frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSideToolbar/index.tsx
  12. 142 0
      frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSlash/BlockSlashMenu.tsx
  13. 72 0
      frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSlash/index.hooks.ts
  14. 30 0
      frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSlash/index.tsx
  15. 2 0
      frontend/appflowy_tauri/src/appflowy_app/components/document/Overlay/index.tsx
  16. 27 2
      frontend/appflowy_tauri/src/appflowy_app/components/document/TextBlock/events/Events.hooks.ts
  17. 1 6
      frontend/appflowy_tauri/src/appflowy_app/components/document/VirtualizedList/index.tsx
  18. 1 0
      frontend/appflowy_tauri/src/appflowy_app/constants/document/text_block.ts
  19. 4 0
      frontend/appflowy_tauri/src/appflowy_app/interfaces/document.ts
  20. 0 0
      frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/delete.ts
  21. 22 0
      frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/duplicate.ts
  22. 3 0
      frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/index.ts
  23. 0 0
      frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/insert.ts
  24. 0 2
      frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/text/index.ts
  25. 96 0
      frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/menu.ts
  26. 31 1
      frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/slice.ts

+ 0 - 30
frontend/appflowy_tauri/src/appflowy_app/components/document/BlockMenu/BlockMenu.hooks.ts

@@ -1,30 +0,0 @@
-import { rectSelectionActions } from "@/appflowy_app/stores/reducers/document/slice";
-import { useAppDispatch } from '@/appflowy_app/stores/store';
-import { useRef, useState, useEffect } from 'react';
-
-export function useBlockMenu(nodeId: string, open: boolean) {
-  const ref = useRef<HTMLDivElement | null>(null);
-  const dispatch = useAppDispatch();
-  const [style, setStyle] = useState({ top: '0px', left: '0px' });
-
-  useEffect(() => {
-    if (!open) {
-      return;
-    }
-    // set selection when open
-    dispatch(rectSelectionActions.setSelectionById(nodeId));
-    // get node rect
-    const rect = document.querySelector(`[data-block-id="${nodeId}"]`)?.getBoundingClientRect();
-    if (!rect) return;
-    // set menu position
-    setStyle({
-      top: rect.top + 'px',
-      left: rect.left + 'px',
-    });
-  }, [open, nodeId, dispatch]);
-
-  return {
-    ref,
-    style,
-  };
-}

+ 0 - 31
frontend/appflowy_tauri/src/appflowy_app/components/document/BlockMenu/MenuItem.hooks.ts

@@ -1,31 +0,0 @@
-import { DocumentControllerContext } from '$app/stores/effects/document/document_controller';
-import { useAppDispatch } from '@/appflowy_app/stores/store';
-import { useCallback, useContext } from 'react';
-import { insertAfterNodeThunk, deleteNodeThunk } from '$app/stores/reducers/document/async-actions';
-
-export enum ActionType {
-  InsertAfter = 'insertAfter',
-  Remove = 'remove',
-}
-export function useActions(id: string, type: ActionType) {
-  const dispatch = useAppDispatch();
-  const controller = useContext(DocumentControllerContext);
-
-  const insertAfter = useCallback(async () => {
-    if (!controller) return;
-    await dispatch(insertAfterNodeThunk({ id, controller }));
-  }, [id, controller, dispatch]);
-
-  const remove = useCallback(async () => {
-    if (!controller) return;
-    await dispatch(deleteNodeThunk({ id, controller }));
-  }, [id, dispatch]);
-
-  if (type === ActionType.InsertAfter) {
-    return insertAfter;
-  }
-  if (type === ActionType.Remove) {
-    return remove;
-  }
-  return;
-}

+ 0 - 32
frontend/appflowy_tauri/src/appflowy_app/components/document/BlockMenu/MenuItem.tsx

@@ -1,32 +0,0 @@
-import React from 'react';
-import DeleteIcon from '@mui/icons-material/Delete';
-import AddIcon from '@mui/icons-material/Add';
-import Button from '@mui/material/Button';
-import { ActionType, useActions } from './MenuItem.hooks';
-
-const icon: Record<ActionType, React.ReactNode> = {
-  [ActionType.InsertAfter]: <AddIcon />,
-  [ActionType.Remove]: <DeleteIcon />,
-};
-
-function MenuItem({ id, type, onClick }: { id: string; type: ActionType; onClick?: () => void }) {
-  const action = useActions(id, type);
-  return (
-    <Button
-      key={type}
-      className='w-[100%]'
-      variant={'text'}
-      color={'inherit'}
-      startIcon={icon[type]}
-      onClick={() => {
-        void action?.();
-        onClick?.();
-      }}
-      style={{ justifyContent: 'flex-start' }}
-    >
-      {type}
-    </Button>
-  );
-}
-
-export default MenuItem;

+ 0 - 42
frontend/appflowy_tauri/src/appflowy_app/components/document/BlockMenu/index.tsx

@@ -1,42 +0,0 @@
-import React from 'react';
-import { useBlockMenu } from './BlockMenu.hooks';
-import MenuItem from './MenuItem';
-import { ActionType } from '$app/components/document/BlockMenu/MenuItem.hooks';
-
-function BlockMenu({ open, onClose, nodeId }: { open: boolean; onClose: () => void; nodeId: string }) {
-  const { ref, style } = useBlockMenu(nodeId, open);
-
-  return open ? (
-    <div
-      ref={ref}
-      className='appflowy-block-menu-overlay z-1 fixed inset-0 overflow-hidden'
-      onScrollCapture={(e) => {
-        // prevent scrolling of the document when menu is open
-        e.stopPropagation();
-      }}
-      onMouseDown={(e) => {
-        // prevent menu from taking focus away from editor
-        e.preventDefault();
-        e.stopPropagation();
-      }}
-      onClick={(e) => {
-        e.stopPropagation();
-        onClose();
-      }}
-    >
-      <div
-        className='z-99 absolute flex w-[200px] translate-x-[-100%] translate-y-[32px] transform flex-col items-start justify-items-start rounded bg-white p-4 shadow'
-        style={style}
-        onClick={(e) => {
-          // prevent menu close when clicking on menu
-          e.stopPropagation();
-        }}
-      >
-        <MenuItem id={nodeId} type={ActionType.InsertAfter} />
-        <MenuItem id={nodeId} type={ActionType.Remove} onClick={onClose} />
-      </div>
-    </div>
-  ) : null;
-}
-
-export default React.memo(BlockMenu);

+ 35 - 0
frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSideToolbar/BlockMenu.hooks.ts

@@ -0,0 +1,35 @@
+import { useAppDispatch } from '$app/stores/store';
+import { useCallback, useContext } from 'react';
+import { DocumentControllerContext } from '$app/stores/effects/document/document_controller';
+import { duplicateBelowNodeThunk } from '$app_reducers/document/async-actions/blocks/duplicate';
+import { deleteNodeThunk } from '$app_reducers/document/async-actions';
+
+export function useBlockMenu(id: string) {
+  const dispatch = useAppDispatch();
+  const controller = useContext(DocumentControllerContext);
+
+  const handleDuplicate = useCallback(async () => {
+    if (!controller) return;
+    await dispatch(
+      duplicateBelowNodeThunk({
+        id,
+        controller,
+      })
+    );
+  }, [controller, dispatch, id]);
+
+  const handleDelete = useCallback(async () => {
+    if (!controller) return;
+    await dispatch(
+      deleteNodeThunk({
+        id,
+        controller,
+      })
+    );
+  }, [controller, dispatch, id]);
+
+  return {
+    handleDuplicate,
+    handleDelete,
+  };
+}

+ 60 - 0
frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSideToolbar/BlockMenu.tsx

@@ -0,0 +1,60 @@
+import React, { useCallback } from 'react';
+import { List } from '@mui/material';
+import { ContentCopy, Delete } from '@mui/icons-material';
+import MenuItem from './MenuItem';
+import { useBlockMenu } from '$app/components/document/BlockSideToolbar/BlockMenu.hooks';
+import BlockMenuTurnInto from '$app/components/document/BlockSideToolbar/BlockMenuTurnInto';
+
+function BlockMenu({ id, onClose }: { id: string; onClose: () => void }) {
+  const { handleDelete, handleDuplicate } = useBlockMenu(id);
+
+  const [turnIntoPup, setTurnIntoPup] = React.useState<boolean>(false);
+  const handleClick = useCallback(
+    async ({ operate }: { operate: () => Promise<void> }) => {
+      await operate();
+      onClose();
+    },
+    [onClose]
+  );
+
+  return (
+    <List
+      onMouseDown={(e) => {
+        e.preventDefault();
+        e.stopPropagation();
+      }}
+    >
+      <MenuItem
+        title='Delete'
+        icon={<Delete />}
+        onClick={() =>
+          handleClick({
+            operate: handleDelete,
+          })
+        }
+        onHover={(isHovered) => {
+          if (isHovered) {
+            setTurnIntoPup(false);
+          }
+        }}
+      />
+      <MenuItem
+        title='Duplicate'
+        icon={<ContentCopy />}
+        onClick={() =>
+          handleClick({
+            operate: handleDuplicate,
+          })
+        }
+        onHover={(isHovered) => {
+          if (isHovered) {
+            setTurnIntoPup(false);
+          }
+        }}
+      />
+      <BlockMenuTurnInto onHovered={() => setTurnIntoPup(true)} isHovered={turnIntoPup} onClose={onClose} id={id} />
+    </List>
+  );
+}
+
+export default BlockMenu;

+ 62 - 0
frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSideToolbar/BlockMenuTurnInto.tsx

@@ -0,0 +1,62 @@
+import React, { useState } from 'react';
+import { ArrowRight, Transform } from '@mui/icons-material';
+import MenuItem from '$app/components/document/BlockSideToolbar/MenuItem';
+import TurnIntoPopover from '$app/components/document/_shared/TurnInto';
+function BlockMenuTurnInto({
+  id,
+  onClose,
+  onHovered,
+  isHovered,
+}: {
+  id: string;
+  onClose: () => void;
+  onHovered: () => void;
+  isHovered: boolean;
+}) {
+  const [anchorEl, setAnchorEl] = useState<null | HTMLDivElement>(null);
+
+  const open = isHovered && Boolean(anchorEl);
+
+  return (
+    <>
+      <MenuItem
+        title='Turn into'
+        icon={<Transform />}
+        extra={<ArrowRight />}
+        onHover={(hovered, event) => {
+          if (hovered) {
+            onHovered();
+            setAnchorEl(event.currentTarget);
+            return;
+          }
+        }}
+      />
+      <TurnIntoPopover
+        id={id}
+        open={open}
+        disableRestoreFocus
+        disableAutoFocus
+        sx={{
+          pointerEvents: 'none',
+        }}
+        PaperProps={{
+          style: {
+            pointerEvents: 'auto',
+          },
+        }}
+        onClose={onClose}
+        anchorEl={anchorEl}
+        anchorOrigin={{
+          vertical: 'center',
+          horizontal: 'right',
+        }}
+        transformOrigin={{
+          vertical: 'center',
+          horizontal: 'left',
+        }}
+      />
+    </>
+  );
+}
+
+export default BlockMenuTurnInto;

+ 45 - 18
frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSideToolbar/BlockSideToolbar.hooks.tsx

@@ -1,7 +1,8 @@
-import { BlockType, HeadingBlockData, NestedBlock } from "@/appflowy_app/interfaces/document";
-import { useAppDispatch } from "@/appflowy_app/stores/store";
-import { useCallback, useEffect, useRef, useState } from 'react';
-import { getBlockByIdThunk } from "$app_reducers/document/async-actions";
+import { BlockType, HeadingBlockData, NestedBlock } from '@/appflowy_app/interfaces/document';
+import { useAppDispatch } from '@/appflowy_app/stores/store';
+import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import { getBlockByIdThunk } from '$app_reducers/document/async-actions';
+import { PopoverOrigin } from '@mui/material/Popover/Popover';
 
 
 const headingBlockTopOffset: Record<number, number> = {
 const headingBlockTopOffset: Record<number, number> = {
   1: 7,
   1: 7,
@@ -10,7 +11,6 @@ const headingBlockTopOffset: Record<number, number> = {
 };
 };
 export function useBlockSideToolbar({ container }: { container: HTMLDivElement }) {
 export function useBlockSideToolbar({ container }: { container: HTMLDivElement }) {
   const [nodeId, setHoverNodeId] = useState<string | null>(null);
   const [nodeId, setHoverNodeId] = useState<string | null>(null);
-  const [menuOpen, setMenuOpen] = useState(false);
   const ref = useRef<HTMLDivElement | null>(null);
   const ref = useRef<HTMLDivElement | null>(null);
   const dispatch = useAppDispatch();
   const dispatch = useAppDispatch();
   const [style, setStyle] = useState<React.CSSProperties>({});
   const [style, setStyle] = useState<React.CSSProperties>({});
@@ -18,8 +18,8 @@ export function useBlockSideToolbar({ container }: { container: HTMLDivElement }
   useEffect(() => {
   useEffect(() => {
     const el = ref.current;
     const el = ref.current;
     if (!el || !nodeId) return;
     if (!el || !nodeId) return;
-    void(async () => {
-      const{ payload: node } = await dispatch(getBlockByIdThunk(nodeId)) as {
+    void (async () => {
+      const { payload: node } = (await dispatch(getBlockByIdThunk(nodeId))) as {
         payload: NestedBlock;
         payload: NestedBlock;
       };
       };
       if (!node) {
       if (!node) {
@@ -43,16 +43,8 @@ export function useBlockSideToolbar({ container }: { container: HTMLDivElement }
         });
         });
       }
       }
     })();
     })();
-
   }, [dispatch, nodeId]);
   }, [dispatch, nodeId]);
 
 
-  const handleToggleMenu = useCallback((isOpen: boolean) => {
-    setMenuOpen(isOpen);
-    if (!isOpen) {
-      setHoverNodeId('');
-    }
-  }, []);
-
   const handleMouseMove = useCallback((e: MouseEvent) => {
   const handleMouseMove = useCallback((e: MouseEvent) => {
     const { clientX, clientY } = e;
     const { clientX, clientY } = e;
     const id = getNodeIdByPoint(clientX, clientY);
     const id = getNodeIdByPoint(clientX, clientY);
@@ -69,9 +61,7 @@ export function useBlockSideToolbar({ container }: { container: HTMLDivElement }
   return {
   return {
     nodeId,
     nodeId,
     ref,
     ref,
-    handleToggleMenu,
-    menuOpen,
-    style
+    style,
   };
   };
 }
 }
 
 
@@ -102,3 +92,40 @@ function getNodeIdByPoint(x: number, y: number) {
       ).el.getAttribute('data-block-id')
       ).el.getAttribute('data-block-id')
     : null;
     : null;
 }
 }
+
+const origin: {
+  anchorOrigin: PopoverOrigin;
+  transformOrigin: PopoverOrigin;
+} = {
+  anchorOrigin: {
+    vertical: 'bottom',
+    horizontal: 'right',
+  },
+  transformOrigin: {
+    vertical: 'bottom',
+    horizontal: 'left',
+  },
+};
+export function usePopover() {
+  const [anchorEl, setAnchorEl] = React.useState<HTMLButtonElement | null>(null);
+
+  const onClose = useCallback(() => {
+    setAnchorEl(null);
+  }, []);
+
+  const handleOpen = useCallback((e: React.MouseEvent<HTMLButtonElement>) => {
+    e.preventDefault();
+    setAnchorEl(e.currentTarget);
+  }, []);
+
+  const open = Boolean(anchorEl);
+
+  return {
+    anchorEl,
+    onClose,
+    open,
+    handleOpen,
+    disableAutoFocus: true,
+    ...origin,
+  };
+}

+ 36 - 0
frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSideToolbar/MenuItem.tsx

@@ -0,0 +1,36 @@
+import React from 'react';
+import { ListItem, ListItemButton, ListItemIcon, ListItemText } from '@mui/material';
+
+function MenuItem({
+  icon,
+  title,
+  onClick,
+  extra,
+  onHover,
+}: {
+  title: string;
+  icon: React.ReactNode;
+  onClick?: () => void;
+  extra?: React.ReactNode;
+  onHover?: (isHovered: boolean, event: React.MouseEvent<HTMLDivElement>) => void;
+}) {
+  return (
+    <ListItem disablePadding>
+      <ListItemButton
+        onMouseEnter={(e) => onHover?.(true, e)}
+        onMouseLeave={(e) => onHover?.(false, e)}
+        onClick={(e) => {
+          e.preventDefault();
+          e.stopPropagation();
+          onClick?.();
+        }}
+      >
+        <ListItemIcon>{icon}</ListItemIcon>
+        <ListItemText primary={title} />
+        {extra}
+      </ListItemButton>
+    </ListItem>
+  );
+}
+
+export default MenuItem;

+ 24 - 0
frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSideToolbar/ToolbarButton.tsx

@@ -0,0 +1,24 @@
+import React from 'react';
+const sx = { height: 24, width: 24 };
+import { IconButton } from '@mui/material';
+import Tooltip from '@mui/material/Tooltip';
+
+const ToolbarButton = ({
+  onClick,
+  children,
+  tooltip,
+}: {
+  tooltip: string;
+  children: React.ReactNode;
+  onClick: React.MouseEventHandler<HTMLButtonElement>;
+}) => {
+  return (
+    <Tooltip title={tooltip} placement={'top-start'}>
+      <IconButton onClick={onClick} sx={sx}>
+        {children}
+      </IconButton>
+    </Tooltip>
+  );
+};
+
+export default ToolbarButton;

+ 52 - 17
frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSideToolbar/index.tsx

@@ -1,21 +1,30 @@
-import React from 'react';
-import { useBlockSideToolbar } from './BlockSideToolbar.hooks';
-import ExpandCircleDownSharpIcon from '@mui/icons-material/ExpandCircleDownSharp';
-import DragIndicatorRoundedIcon from '@mui/icons-material/DragIndicatorRounded';
+import React, { useCallback, useContext, useState } from 'react';
+import { useBlockSideToolbar, usePopover } from './BlockSideToolbar.hooks';
 import Portal from '../BlockPortal';
 import Portal from '../BlockPortal';
-import { IconButton } from '@mui/material';
-import BlockMenu from '../BlockMenu';
-import { useAppSelector } from '$app/stores/store';
-
-const sx = { height: 24, width: 24 };
+import { useAppDispatch, useAppSelector } from '$app/stores/store';
+import Popover from '@mui/material/Popover';
+import DragIndicatorRoundedIcon from '@mui/icons-material/DragIndicatorRounded';
+import AddSharpIcon from '@mui/icons-material/AddSharp';
+import BlockMenu from './BlockMenu';
+import ToolbarButton from './ToolbarButton';
+import { rectSelectionActions } from '$app_reducers/document/slice';
+import { addBlockBelowClickThunk } from '$app_reducers/document/async-actions/menu';
+import { DocumentControllerContext } from '$app/stores/effects/document/document_controller';
 
 
-export default function BlockSideToolbar(props: { container: HTMLDivElement }) {
-  const { nodeId, style, ref, menuOpen, handleToggleMenu } = useBlockSideToolbar(props);
+export default function BlockSideToolbar({ container }: { container: HTMLDivElement }) {
+  const dispatch = useAppDispatch();
+  const controller = useContext(DocumentControllerContext);
+  const { nodeId, style, ref } = useBlockSideToolbar({ container });
   const isDragging = useAppSelector(
   const isDragging = useAppSelector(
     (state) => state.documentRangeSelection.isDragging || state.documentRectSelection.isDragging
     (state) => state.documentRangeSelection.isDragging || state.documentRectSelection.isDragging
   );
   );
+  const { handleOpen, ...popoverProps } = usePopover();
+
+  // prevent popover from showing when anchorEl is not in DOM
+  const showPopover = popoverProps.anchorEl ? document.contains(popoverProps.anchorEl) : true;
 
 
   if (!nodeId || isDragging) return null;
   if (!nodeId || isDragging) return null;
+
   return (
   return (
     <>
     <>
       <Portal blockId={nodeId}>
       <Portal blockId={nodeId}>
@@ -32,15 +41,41 @@ export default function BlockSideToolbar(props: { container: HTMLDivElement }) {
             e.stopPropagation();
             e.stopPropagation();
           }}
           }}
         >
         >
-          <IconButton onClick={() => handleToggleMenu(true)} sx={sx}>
-            <ExpandCircleDownSharpIcon />
-          </IconButton>
-          <IconButton sx={sx}>
+          {/** Add Block below */}
+          <ToolbarButton
+            tooltip={'Add a new block below'}
+            onClick={(e: React.MouseEvent<HTMLButtonElement>) => {
+              if (!nodeId || !controller) return;
+              dispatch(
+                addBlockBelowClickThunk({
+                  id: nodeId,
+                  controller,
+                })
+              );
+            }}
+          >
+            <AddSharpIcon />
+          </ToolbarButton>
+
+          {/** Open menu or drag */}
+          <ToolbarButton
+            tooltip={'Click to open Menu'}
+            onClick={(e: React.MouseEvent<HTMLButtonElement>) => {
+              if (!nodeId) return;
+              dispatch(rectSelectionActions.setSelectionById(nodeId));
+              handleOpen(e);
+            }}
+          >
             <DragIndicatorRoundedIcon />
             <DragIndicatorRoundedIcon />
-          </IconButton>
+          </ToolbarButton>
         </div>
         </div>
       </Portal>
       </Portal>
-      <BlockMenu open={menuOpen} onClose={() => handleToggleMenu(false)} nodeId={nodeId} />
+
+      {showPopover && (
+        <Popover {...popoverProps}>
+          <BlockMenu id={nodeId} onClose={popoverProps.onClose} />
+        </Popover>
+      )}
     </>
     </>
   );
   );
 }
 }

+ 142 - 0
frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSlash/BlockSlashMenu.tsx

@@ -0,0 +1,142 @@
+import React, { useCallback, useContext, useMemo } from 'react';
+import MenuItem from '$app/components/document/BlockSideToolbar/MenuItem';
+import {
+  ArrowRight,
+  Check,
+  DataObject,
+  FormatListBulleted,
+  FormatListNumbered,
+  FormatQuote,
+  Lightbulb,
+  TextFields,
+  Title,
+} from '@mui/icons-material';
+import { List } from '@mui/material';
+import { BlockData, BlockType } from '$app/interfaces/document';
+import { useAppDispatch } from '$app/stores/store';
+import { DocumentControllerContext } from '$app/stores/effects/document/document_controller';
+import { triggerSlashCommandActionThunk } from '$app_reducers/document/async-actions/menu';
+
+function BlockSlashMenu({ id, onClose, searchText }: { id: string; onClose?: () => void; searchText?: string }) {
+  const dispatch = useAppDispatch();
+  const controller = useContext(DocumentControllerContext);
+  const handleInsert = useCallback(
+    async (type: BlockType, data?: BlockData<any>) => {
+      if (!controller) return;
+      await dispatch(
+        triggerSlashCommandActionThunk({
+          controller,
+          id,
+          props: {
+            type,
+            data,
+          },
+        })
+      );
+      onClose?.();
+    },
+    [controller, dispatch, id, onClose]
+  );
+
+  const optionColumns = useMemo(
+    () => [
+      [
+        {
+          type: BlockType.TextBlock,
+          title: 'Text',
+          icon: <TextFields />,
+        },
+        {
+          type: BlockType.HeadingBlock,
+          title: 'Heading 1',
+          icon: <Title />,
+          props: {
+            level: 1,
+          },
+        },
+        {
+          type: BlockType.HeadingBlock,
+          title: 'Heading 2',
+          icon: <Title />,
+          props: {
+            level: 2,
+          },
+        },
+        {
+          type: BlockType.HeadingBlock,
+          title: 'Heading 3',
+          icon: <Title />,
+          props: {
+            level: 3,
+          },
+        },
+        {
+          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 />,
+        },
+      ],
+    ],
+    []
+  );
+  return (
+    <div
+      onMouseDown={(e) => {
+        e.preventDefault();
+        e.stopPropagation();
+      }}
+      className={'flex'}
+    >
+      {optionColumns.map((column, index) => (
+        <List key={index} className={'flex-1'}>
+          {column.map((option) => {
+            return (
+              <MenuItem
+                key={option.title}
+                title={option.title}
+                icon={option.icon}
+                onClick={() => {
+                  handleInsert(option.type, option.props);
+                }}
+              />
+            );
+          })}
+        </List>
+      ))}
+    </div>
+  );
+}
+
+export default BlockSlashMenu;

+ 72 - 0
frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSlash/index.hooks.ts

@@ -0,0 +1,72 @@
+import { useAppDispatch, useAppSelector } from '$app/stores/store';
+import React, { useCallback, useEffect, useMemo } from 'react';
+import { slashCommandActions } from '$app_reducers/document/slice';
+import { useSubscribeNode } from '$app/components/document/_shared/SubscribeNode.hooks';
+import { TextDelta } from '$app/interfaces/document';
+
+export function useBlockSlash() {
+  const dispatch = useAppDispatch();
+  const { blockId, visible, slashText } = useSubscribeSlash();
+  const [anchorEl, setAnchorEl] = React.useState<HTMLElement | null>(null);
+  useEffect(() => {
+    if (blockId && visible) {
+      const el = document.querySelector(`[data-block-id="${blockId}"]`) as HTMLElement;
+      if (el) {
+        setAnchorEl(el);
+        return;
+      }
+    }
+    setAnchorEl(null);
+  }, [blockId, visible]);
+
+  useEffect(() => {
+    if (!slashText) {
+      dispatch(slashCommandActions.closeSlashCommand());
+    }
+  }, [dispatch, slashText]);
+
+  const searchText = useMemo(() => {
+    if (!slashText) return '';
+    if (slashText[0] !== '/') return slashText;
+    return slashText.slice(1, slashText.length);
+  }, [slashText]);
+  const onClose = useCallback(() => {
+    dispatch(slashCommandActions.closeSlashCommand());
+  }, [dispatch]);
+
+  const open = Boolean(anchorEl);
+
+  return {
+    open,
+    anchorEl,
+    onClose,
+    blockId,
+    searchText,
+  };
+}
+export function useSubscribeSlash() {
+  const slashCommandState = useAppSelector((state) => state.documentSlashCommand);
+
+  const visible = useMemo(() => slashCommandState.isSlashCommand, [slashCommandState.isSlashCommand]);
+  const blockId = useMemo(() => slashCommandState.blockId, [slashCommandState.blockId]);
+  const { node } = useSubscribeNode(blockId || '');
+  const slashText = useMemo(() => {
+    if (!node) return '';
+    const delta = node.data.delta || [];
+    return delta
+      .map((op: TextDelta) => {
+        if (typeof op.insert === 'string') {
+          return op.insert;
+        } else {
+          return '';
+        }
+      })
+      .join('');
+  }, [node]);
+
+  return {
+    visible,
+    blockId,
+    slashText,
+  };
+}

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

@@ -0,0 +1,30 @@
+import React from 'react';
+import Popover from '@mui/material/Popover';
+import BlockSlashMenu from '$app/components/document/BlockSlash/BlockSlashMenu';
+import { useBlockSlash } from '$app/components/document/BlockSlash/index.hooks';
+
+function BlockSlash() {
+  const { blockId, open, onClose, anchorEl, searchText } = useBlockSlash();
+  if (!blockId) return null;
+
+  return (
+    <Popover
+      open={open}
+      anchorEl={anchorEl}
+      anchorOrigin={{
+        vertical: 'bottom',
+        horizontal: 'left',
+      }}
+      transformOrigin={{
+        vertical: 'top',
+        horizontal: 'left',
+      }}
+      disableAutoFocus
+      onClose={onClose}
+    >
+      <BlockSlashMenu id={blockId} onClose={onClose} searchText={searchText} />
+    </Popover>
+  );
+}
+
+export default BlockSlash;

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

@@ -2,6 +2,7 @@ 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';
 import TextActionMenu from '$app/components/document/TextActionMenu';
+import BlockSlash from '$app/components/document/BlockSlash';
 
 
 export default function Overlay({ container }: { container: HTMLDivElement }) {
 export default function Overlay({ container }: { container: HTMLDivElement }) {
   return (
   return (
@@ -9,6 +10,7 @@ export default function Overlay({ container }: { container: HTMLDivElement }) {
       <BlockSideToolbar container={container} />
       <BlockSideToolbar container={container} />
       <TextActionMenu container={container} />
       <TextActionMenu container={container} />
       <BlockSelection container={container} />
       <BlockSelection container={container} />
+      <BlockSlash />
     </>
     </>
   );
   );
 }
 }

+ 27 - 2
frontend/appflowy_tauri/src/appflowy_app/components/document/TextBlock/events/Events.hooks.ts

@@ -2,13 +2,16 @@ 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 { TextBlockKeyEventHandlerParams } from '$app/interfaces/document';
+import { BlockType, 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, useAppSelector } 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';
+import { getBeforeRangeAt } from '$app/utils/document/blocks/text/delta';
+import { slashCommandActions } from '$app_reducers/document/slice';
+import { useSubscribeNode } from '$app/components/document/_shared/SubscribeNode.hooks';
 
 
 export function useTextBlockKeyEvent(id: string, editor: ReactEditor) {
 export function useTextBlockKeyEvent(id: string, editor: ReactEditor) {
   const controller = useContext(DocumentControllerContext);
   const controller = useContext(DocumentControllerContext);
@@ -20,6 +23,9 @@ export function useTextBlockKeyEvent(id: string, editor: ReactEditor) {
     return anchor.id === id && focus.id === id;
     return anchor.id === id && focus.id === id;
   });
   });
 
 
+  const { node } = useSubscribeNode(id);
+  const nodeType = node?.type;
+
   const { turnIntoBlockEvents } = useTurnIntoBlock(id);
   const { turnIntoBlockEvents } = useTurnIntoBlock(id);
 
 
   // Here custom key events for TextBlock
   // Here custom key events for TextBlock
@@ -81,8 +87,27 @@ export function useTextBlockKeyEvent(id: string, editor: ReactEditor) {
           );
           );
         },
         },
       },
       },
+      {
+        // Here custom slash key event for TextBlock
+        triggerEventKey: keyBoardEventKeyMap.Slash,
+        canHandle: (...args: TextBlockKeyEventHandlerParams) => {
+          const [e, editor] = args;
+          if (!editor.selection) return false;
+
+          return isHotkey('/', e) && Editor.string(editor, getBeforeRangeAt(editor, editor.selection)) === '';
+        },
+        handler: (...args: TextBlockKeyEventHandlerParams) => {
+          const [e, _] = args;
+          if (!controller) return;
+          dispatch(
+            slashCommandActions.openSlashCommand({
+              blockId: id,
+            })
+          );
+        },
+      },
     ],
     ],
-    [defaultTextInputEvents, controller, dispatch, id]
+    [defaultTextInputEvents, controller, dispatch, id, nodeType]
   );
   );
 
 
   const onKeyDown = useCallback(
   const onKeyDown = useCallback(

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

@@ -43,12 +43,7 @@ export default function VirtualizedList({
               {virtualItems.map((virtualRow) => {
               {virtualItems.map((virtualRow) => {
                 const id = childIds[virtualRow.index];
                 const id = childIds[virtualRow.index];
                 return (
                 return (
-                  <div
-                    className='float-left w-[100%]'
-                    key={id}
-                    data-index={virtualRow.index}
-                    ref={virtualize.measureElement}
-                  >
+                  <div className='pt-0.5' key={id} data-index={virtualRow.index} ref={virtualize.measureElement}>
                     {virtualRow.index === 0 ? <DocumentTitle id={node.id} /> : null}
                     {virtualRow.index === 0 ? <DocumentTitle id={node.id} /> : null}
                     {renderNode(id)}
                     {renderNode(id)}
                   </div>
                   </div>

+ 1 - 0
frontend/appflowy_tauri/src/appflowy_app/constants/document/text_block.ts

@@ -9,4 +9,5 @@ export const keyBoardEventKeyMap = {
   Space: ' ',
   Space: ' ',
   Reduce: '-',
   Reduce: '-',
   Backquote: '`',
   Backquote: '`',
+  Slash: '/',
 };
 };

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

@@ -131,6 +131,10 @@ export interface DocumentState {
   // map of block id to children block ids
   // map of block id to children block ids
   children: Record<string, string[]>;
   children: Record<string, string[]>;
 }
 }
+export interface SlashCommandState {
+  isSlashCommand: boolean;
+  blockId?: string;
+}
 
 
 export interface RectSelectionState {
 export interface RectSelectionState {
   selection: string[];
   selection: string[];

+ 0 - 0
frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/text/delete.ts → frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/delete.ts


+ 22 - 0
frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/duplicate.ts

@@ -0,0 +1,22 @@
+import { DocumentState } from '$app/interfaces/document';
+import { DocumentController } from '$app/stores/effects/document/document_controller';
+import { createAsyncThunk } from '@reduxjs/toolkit';
+import { newBlock } from '$app/utils/document/blocks/common';
+
+export const duplicateBelowNodeThunk = createAsyncThunk(
+  'document/duplicateBelowNode',
+  async (payload: { id: string; controller: DocumentController }, thunkAPI) => {
+    const { id, controller } = payload;
+    const { getState } = thunkAPI;
+    const state = getState() as { document: DocumentState };
+    const node = state.document.nodes[id];
+    if (!node) return;
+    const parentId = node.parent;
+    if (!parentId) return;
+    // duplicate new node
+    const newNode = newBlock<any>(node.type, parentId, node.data);
+    await controller.applyActions([controller.getInsertAction(newNode, node.id)]);
+
+    return newNode.id;
+  }
+);

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

@@ -1 +1,4 @@
 export * from './text';
 export * from './text';
+export * from './delete';
+export * from './duplicate';
+export * from './insert';

+ 0 - 0
frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/text/insert.ts → frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/insert.ts


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

@@ -1,6 +1,4 @@
-export * from './delete';
 export * from './indent';
 export * from './indent';
-export * from './insert';
 export * from './backspace';
 export * from './backspace';
 export * from './outdent';
 export * from './outdent';
 export * from './split';
 export * from './split';

+ 96 - 0
frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/menu.ts

@@ -0,0 +1,96 @@
+import { createAsyncThunk } from '@reduxjs/toolkit';
+import { BlockData, BlockType, DocumentState, TextDelta } from '$app/interfaces/document';
+import { insertAfterNodeThunk } from '$app_reducers/document/async-actions/blocks';
+import { DocumentController } from '$app/stores/effects/document/document_controller';
+import { slashCommandActions } from '$app_reducers/document/slice';
+import { setCursorBeforeThunk } from '$app_reducers/document/async-actions/cursor';
+import { turnToBlockThunk } from '$app_reducers/document/async-actions/turn_to';
+import { blockConfig } from '$app/constants/document/config';
+
+/**
+ * add block below click
+ * 1. if current block is not empty, insert a new block after current block
+ * 2. if current block is empty, open slash command below current block
+ */
+export const addBlockBelowClickThunk = createAsyncThunk(
+  'document/addBlockBelowClick',
+  async (payload: { id: string; controller: DocumentController }, thunkAPI) => {
+    const { id, controller } = payload;
+    const { dispatch, getState } = thunkAPI;
+    const state = (getState() as { document: DocumentState }).document;
+    const node = state.nodes[id];
+    if (!node) return;
+    const delta = (node.data.delta as TextDelta[]) || [];
+    const text = delta.map((d) => d.insert).join('');
+
+    // if current block is not empty, insert a new block after current block
+    if (node.type !== BlockType.TextBlock || text !== '') {
+      const { payload: newBlockId } = await dispatch(
+        insertAfterNodeThunk({ id: id, type: BlockType.TextBlock, controller, data: { delta: [] } })
+      );
+      if (newBlockId) {
+        await dispatch(setCursorBeforeThunk({ id: newBlockId as string }));
+        dispatch(slashCommandActions.openSlashCommand({ blockId: newBlockId as string }));
+      }
+      return;
+    }
+    // if current block is empty, open slash command
+    await dispatch(setCursorBeforeThunk({ id }));
+    dispatch(slashCommandActions.openSlashCommand({ blockId: id }));
+  }
+);
+
+/**
+ * slash command action be triggered
+ * 1. if current block is empty, operate on current block
+ * 2. if current block is not empty, insert a new block after current block and operate on new block
+ */
+export const triggerSlashCommandActionThunk = createAsyncThunk(
+  'document/slashCommandAction',
+  async (
+    payload: {
+      id: string;
+      controller: DocumentController;
+      props: {
+        data?: BlockData<any>;
+        type: BlockType;
+      };
+    },
+    thunkAPI
+  ) => {
+    const { id, controller, props } = payload;
+    const { dispatch, getState } = thunkAPI;
+    const state = (getState() as { document: DocumentState }).document;
+    const node = state.nodes[id];
+    if (!node) return;
+    const delta = (node.data.delta as TextDelta[]) || [];
+    const text = delta.map((d) => d.insert).join('');
+    const defaultData = blockConfig[props.type].defaultData;
+    if (node.type === BlockType.TextBlock && (text === '' || text === '/')) {
+      dispatch(
+        turnToBlockThunk({
+          id,
+          controller,
+          type: props.type,
+          data: {
+            ...defaultData,
+            ...props.data,
+          },
+        })
+      );
+      return;
+    }
+    const { payload: newBlockId } = await dispatch(
+      insertAfterNodeThunk({
+        id,
+        controller,
+        type: props.type,
+        data: {
+          ...defaultData,
+          ...props.data,
+        },
+      })
+    );
+    dispatch(setCursorBeforeThunk({ id: newBlockId as string }));
+  }
+);

+ 31 - 1
frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/slice.ts

@@ -4,11 +4,11 @@ import {
   PointState,
   PointState,
   RangeSelectionState,
   RangeSelectionState,
   RectSelectionState,
   RectSelectionState,
+  SlashCommandState,
 } from '@/appflowy_app/interfaces/document';
 } 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: {},
@@ -25,6 +25,10 @@ const rangeSelectionInitialState: RangeSelectionState = {
   selection: [],
   selection: [],
 };
 };
 
 
+const slashCommandInitialState: SlashCommandState = {
+  isSlashCommand: false,
+};
+
 export const documentSlice = createSlice({
 export const documentSlice = createSlice({
   name: 'document',
   name: 'document',
   initialState: initialState,
   initialState: initialState,
@@ -126,12 +130,38 @@ export const rangeSelectionSlice = createSlice({
   },
   },
 });
 });
 
 
+export const slashCommandSlice = createSlice({
+  name: 'documentSlashCommand',
+  initialState: slashCommandInitialState,
+  reducers: {
+    openSlashCommand: (
+      state,
+      action: PayloadAction<{
+        blockId: string;
+      }>
+    ) => {
+      const { blockId } = action.payload;
+      return {
+        ...state,
+        isSlashCommand: true,
+        blockId,
+      };
+    },
+    closeSlashCommand: (state, _: PayloadAction) => {
+      return slashCommandInitialState;
+    },
+  },
+});
+
 export const documentReducers = {
 export const documentReducers = {
   [documentSlice.name]: documentSlice.reducer,
   [documentSlice.name]: documentSlice.reducer,
   [rectSelectionSlice.name]: rectSelectionSlice.reducer,
   [rectSelectionSlice.name]: rectSelectionSlice.reducer,
   [rangeSelectionSlice.name]: rangeSelectionSlice.reducer,
   [rangeSelectionSlice.name]: rangeSelectionSlice.reducer,
+  [slashCommandSlice.name]: slashCommandSlice.reducer,
 };
 };
 
 
 export const documentActions = documentSlice.actions;
 export const documentActions = documentSlice.actions;
 export const rectSelectionActions = rectSelectionSlice.actions;
 export const rectSelectionActions = rectSelectionSlice.actions;
 export const rangeSelectionActions = rangeSelectionSlice.actions;
 export const rangeSelectionActions = rangeSelectionSlice.actions;
+
+export const slashCommandActions = slashCommandSlice.actions;