Browse Source

Support divider block and callout block (#2457)

* feat: divider block

* feat: callout block
Kilu.He 2 years ago
parent
commit
ba8cbe170c
26 changed files with 328 additions and 78 deletions
  1. 3 0
      frontend/appflowy_tauri/package.json
  2. 24 0
      frontend/appflowy_tauri/pnpm-lock.yaml
  3. 1 1
      frontend/appflowy_tauri/src/appflowy_app/components/document/BlockHorizontalToolbar/FormatButton.tsx
  4. 1 1
      frontend/appflowy_tauri/src/appflowy_app/components/document/BlockHorizontalToolbar/index.hooks.ts
  5. 8 6
      frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSelection/BlockSelection.hooks.tsx
  6. 48 0
      frontend/appflowy_tauri/src/appflowy_app/components/document/CalloutBlock/CalloutBlock.hooks.ts
  7. 51 0
      frontend/appflowy_tauri/src/appflowy_app/components/document/CalloutBlock/index.tsx
  8. 7 0
      frontend/appflowy_tauri/src/appflowy_app/components/document/DividerBlock/index.tsx
  9. 8 0
      frontend/appflowy_tauri/src/appflowy_app/components/document/Node/index.tsx
  10. 1 1
      frontend/appflowy_tauri/src/appflowy_app/components/document/TextBlock/events/Events.hooks.ts
  11. 33 18
      frontend/appflowy_tauri/src/appflowy_app/components/document/TextBlock/events/TurnIntoEvents.hooks.ts
  12. 1 1
      frontend/appflowy_tauri/src/appflowy_app/components/document/TextBlock/index.tsx
  13. 3 2
      frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/TextInput.hooks.ts
  14. 29 6
      frontend/appflowy_tauri/src/appflowy_app/constants/document/config.ts
  15. 1 0
      frontend/appflowy_tauri/src/appflowy_app/constants/document/text_block.ts
  16. 13 1
      frontend/appflowy_tauri/src/appflowy_app/interfaces/document.ts
  17. 14 8
      frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/text/insert.ts
  18. 16 12
      frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/cursor.ts
  19. 28 1
      frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/turn_to.ts
  20. 1 1
      frontend/appflowy_tauri/src/appflowy_app/utils/document/blocks/common.ts
  21. 25 1
      frontend/appflowy_tauri/src/appflowy_app/utils/document/blocks/index.ts
  22. 0 16
      frontend/appflowy_tauri/src/appflowy_app/utils/document/blocks/text.ts
  23. 11 1
      frontend/appflowy_tauri/src/appflowy_app/utils/document/blocks/text/delta.ts
  24. 0 0
      frontend/appflowy_tauri/src/appflowy_app/utils/document/blocks/text/format.ts
  25. 1 1
      frontend/appflowy_tauri/src/appflowy_app/utils/document/blocks/text/hotkey.ts
  26. 0 0
      frontend/appflowy_tauri/src/appflowy_app/utils/document/blocks/text/toolbar.ts

+ 3 - 0
frontend/appflowy_tauri/package.json

@@ -15,6 +15,8 @@
     "tauri:dev": "tauri dev"
   },
   "dependencies": {
+    "@emoji-mart/data": "^1.1.2",
+    "@emoji-mart/react": "^1.1.1",
     "@emotion/react": "^11.10.6",
     "@emotion/styled": "^11.10.6",
     "@mui/icons-material": "^5.11.11",
@@ -24,6 +26,7 @@
     "@tanstack/react-virtual": "3.0.0-beta.54",
     "@tauri-apps/api": "^1.2.0",
     "dayjs": "^1.11.7",
+    "emoji-mart": "^5.5.2",
     "events": "^3.3.0",
     "google-protobuf": "^3.21.2",
     "i18next": "^22.4.10",

+ 24 - 0
frontend/appflowy_tauri/pnpm-lock.yaml

@@ -1,6 +1,8 @@
 lockfileVersion: 5.4
 
 specifiers:
+  '@emoji-mart/data': ^1.1.2
+  '@emoji-mart/react': ^1.1.1
   '@emotion/react': ^11.10.6
   '@emotion/styled': ^11.10.6
   '@mui/icons-material': ^5.11.11
@@ -23,6 +25,7 @@ specifiers:
   '@vitejs/plugin-react': ^3.0.0
   autoprefixer: ^10.4.13
   dayjs: ^1.11.7
+  emoji-mart: ^5.5.2
   eslint: ^8.34.0
   eslint-plugin-react: ^7.32.2
   eslint-plugin-react-hooks: ^4.6.0
@@ -60,6 +63,8 @@ specifiers:
   yjs: ^13.5.51
 
 dependencies:
+  '@emoji-mart/data': 1.1.2
+  '@emoji-mart/react': 1.1.1_kyrnz3vmphzqyjjk2ivrm6bcsu
   '@emotion/react': 11.10.6_pmekkgnqduwlme35zpnqhenc34
   '@emotion/styled': 11.10.6_oouaibmszuch5k64ms7uxp2aia
   '@mui/icons-material': 5.11.11_ao76n7r2cajsoyr3cbwrn7geoi
@@ -69,6 +74,7 @@ dependencies:
   '@tanstack/react-virtual': [email protected]
   '@tauri-apps/api': 1.2.0
   dayjs: 1.11.7
+  emoji-mart: 5.5.2
   events: 3.3.0
   google-protobuf: 3.21.2
   i18next: 22.4.10
@@ -467,6 +473,20 @@ packages:
     resolution: {integrity: sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==}
     dev: false
 
+  /@emoji-mart/data/1.1.2:
+    resolution: {integrity: sha512-1HP8BxD2azjqWJvxIaWAMyTySeZY0Osr83ukYjltPVkNXeJvTz7yDrPLBtnrD5uqJ3tg4CcLuuBW09wahqL/fg==}
+    dev: false
+
+  /@emoji-mart/react/1.1.1_kyrnz3vmphzqyjjk2ivrm6bcsu:
+    resolution: {integrity: sha512-NMlFNeWgv1//uPsvLxvGQoIerPuVdXwK/EUek8OOkJ6wVOWPUizRBJU0hDqWZCOROVpfBgCemaC3m6jDOXi03g==}
+    peerDependencies:
+      emoji-mart: ^5.2
+      react: ^16.8 || ^17 || ^18
+    dependencies:
+      emoji-mart: 5.5.2
+      react: 18.2.0
+    dev: false
+
   /@emotion/babel-plugin/11.10.6:
     resolution: {integrity: sha512-p2dAqtVrkhSa7xz1u/m9eHYdLi+en8NowrmXeF/dKtJpU8lCWli8RUAati7NcSl0afsBott48pdnANuD0wh9QQ==}
     dependencies:
@@ -2308,6 +2328,10 @@ packages:
     engines: {node: '>=12'}
     dev: false
 
+  /emoji-mart/5.5.2:
+    resolution: {integrity: sha512-Sqc/nso4cjxhOwWJsp9xkVm8OF5c+mJLZJFoFfzRuKO+yWiN7K8c96xmtughYb0d/fZ8UC6cLIQ/p4BR6Pv3/A==}
+    dev: false
+
   /emoji-regex/8.0.0:
     resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==}
     dev: false

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

@@ -1,4 +1,4 @@
-import { toggleFormat, isFormatActive } from '$app/utils/document/slate/format';
+import { toggleFormat, isFormatActive } from '$app/utils/document/blocks/text/format';
 import IconButton from '@mui/material/IconButton';
 import Tooltip from '@mui/material/Tooltip';
 

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

@@ -1,6 +1,6 @@
 import { useEffect, useRef } from 'react';
 import { useFocused, useSlate } from 'slate-react';
-import { calcToolbarPosition } from '$app/utils/document/slate/toolbar';
+import { calcToolbarPosition } from '$app/utils/document/blocks/text/toolbar';
 export function useHoveringToolbar(id: string) {
   const editor = useSlate();
   const inFocus = useFocused();

+ 8 - 6
frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSelection/BlockSelection.hooks.tsx

@@ -133,14 +133,16 @@ export function useBlockSelection({
 
   useEffect(() => {
     if (!ref.current) return;
-    document.addEventListener('mousedown', handleDragStart);
-    document.addEventListener('mousemove', handleDraging);
-    document.addEventListener('mouseup', handleDragEnd);
+    const doc = document.getElementById('appflowy-block-doc');
+    if (!doc) return;
+    doc.addEventListener('mousedown', handleDragStart);
+    doc.addEventListener('mousemove', handleDraging);
+    doc.addEventListener('mouseup', handleDragEnd);
 
     return () => {
-      document.removeEventListener('mousedown', handleDragStart);
-      document.removeEventListener('mousemove', handleDraging);
-      document.removeEventListener('mouseup', handleDragEnd);
+      doc.removeEventListener('mousedown', handleDragStart);
+      doc.removeEventListener('mousemove', handleDraging);
+      doc.removeEventListener('mouseup', handleDragEnd);
     };
   }, [handleDragStart, handleDragEnd, handleDraging]);
 

+ 48 - 0
frontend/appflowy_tauri/src/appflowy_app/components/document/CalloutBlock/CalloutBlock.hooks.ts

@@ -0,0 +1,48 @@
+import { useCallback, useContext, useMemo, useState } from 'react';
+import emojiData, { EmojiMartData, Emoji } from '@emoji-mart/data';
+import { useAppDispatch } from '$app/stores/store';
+import { DocumentControllerContext } from '$app/stores/effects/document/document_controller';
+import { updateNodeDataThunk } from '$app_reducers/document/async-actions';
+
+export function useCalloutBlock(nodeId: string) {
+  const [anchorEl, setAnchorEl] = useState<HTMLButtonElement | null>(null);
+  const open = useMemo(() => Boolean(anchorEl), [anchorEl]);
+  const id = useMemo(() => (open ? 'emoji-popover' : undefined), [open]);
+  const dispatch = useAppDispatch();
+  const controller = useContext(DocumentControllerContext);
+
+  const closeEmojiSelect = useCallback(() => {
+    setAnchorEl(null);
+  }, []);
+
+  const openEmojiSelect = useCallback((event: React.MouseEvent<HTMLButtonElement>) => {
+    setAnchorEl(event.currentTarget);
+  }, []);
+
+  const onEmojiSelect = useCallback(
+    (emoji: { native: string }) => {
+      if (!controller) return;
+      console.log('emoji', emoji.native);
+      void dispatch(
+        updateNodeDataThunk({
+          id: nodeId,
+          controller,
+          data: {
+            icon: emoji.native,
+          },
+        })
+      );
+      closeEmojiSelect();
+    },
+    [controller, dispatch, nodeId, closeEmojiSelect]
+  );
+
+  return {
+    anchorEl,
+    closeEmojiSelect,
+    openEmojiSelect,
+    open,
+    id,
+    onEmojiSelect,
+  };
+}

+ 51 - 0
frontend/appflowy_tauri/src/appflowy_app/components/document/CalloutBlock/index.tsx

@@ -0,0 +1,51 @@
+import { BlockType, NestedBlock } from '$app/interfaces/document';
+import TextBlock from '$app/components/document/TextBlock';
+import NodeChildren from '$app/components/document/Node/NodeChildren';
+import { IconButton, Popover } from '@mui/material';
+import emojiData from '@emoji-mart/data';
+import Picker from '@emoji-mart/react';
+import { useCalloutBlock } from '$app/components/document/CalloutBlock/CalloutBlock.hooks';
+
+export default function CalloutBlock({
+  node,
+  childIds,
+}: {
+  node: NestedBlock<BlockType.CalloutBlock>;
+  childIds?: string[];
+}) {
+  const { openEmojiSelect, open, closeEmojiSelect, id, anchorEl, onEmojiSelect } = useCalloutBlock(node.id);
+
+  return (
+    <div className={'my-1 flex rounded border border-solid border-main-accent bg-main-secondary p-4'}>
+      <div className={'w-[1.5em]'}>
+        <div className={'flex h-[calc(1.5em_+_2px)] w-[24px] select-none items-center justify-start'}>
+          <IconButton
+            aria-describedby={id}
+            onClick={openEmojiSelect}
+            className={`m-0 h-[100%] w-[100%] rounded-full p-0 transition`}
+          >
+            {node.data.icon}
+          </IconButton>
+          <Popover
+            id={id}
+            anchorEl={anchorEl}
+            open={open}
+            onClose={closeEmojiSelect}
+            anchorOrigin={{
+              vertical: 'bottom',
+              horizontal: 'left',
+            }}
+          >
+            <Picker searchPosition={'static'} locale={'en'} autoFocus data={emojiData} onEmojiSelect={onEmojiSelect} />
+          </Popover>
+        </div>
+      </div>
+      <div className={'flex-1'}>
+        <div>
+          <TextBlock node={node} />
+        </div>
+        <NodeChildren childIds={childIds} />
+      </div>
+    </div>
+  );
+}

+ 7 - 0
frontend/appflowy_tauri/src/appflowy_app/components/document/DividerBlock/index.tsx

@@ -0,0 +1,7 @@
+export default function DividerBlock() {
+  return (
+    <div className={`flex h-[1em] w-[100%] items-center justify-center`}>
+      <div className={'h-[1px] w-[100%] bg-shade-5'} />
+    </div>
+  );
+}

+ 8 - 0
frontend/appflowy_tauri/src/appflowy_app/components/document/Node/index.tsx

@@ -13,6 +13,8 @@ import QuoteBlock from '$app/components/document/QuoteBlock';
 import BulletedListBlock from '$app/components/document/BulletedListBlock';
 import NumberedListBlock from '$app/components/document/NumberedListBlock';
 import ToggleListBlock from '$app/components/document/ToggleListBlock';
+import DividerBlock from '$app/components/document/DividerBlock';
+import CalloutBlock from '$app/components/document/CalloutBlock';
 
 function NodeComponent({ id, ...props }: { id: string } & React.HTMLAttributes<HTMLDivElement>) {
   const { node, childIds, isSelected, ref } = useNode(id);
@@ -40,6 +42,12 @@ function NodeComponent({ id, ...props }: { id: string } & React.HTMLAttributes<H
       case BlockType.ToggleListBlock: {
         return <ToggleListBlock node={node} childIds={childIds} />;
       }
+      case BlockType.DividerBlock: {
+        return <DividerBlock />;
+      }
+      case BlockType.CalloutBlock: {
+        return <CalloutBlock node={node} childIds={childIds} />;
+      }
       default:
         return (
           <Alert severity='info' className='mb-2'>

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

@@ -12,7 +12,7 @@ import {
   canHandleUpKey,
   onHandleEnterKey,
   triggerHotkey,
-} from '$app/utils/document/slate/hotkey';
+} from '$app/utils/document/blocks/text/hotkey';
 import { TextBlockKeyEventHandlerParams } from '$app/interfaces/document';
 import { useActions } from './Actions.hooks';
 

+ 33 - 18
frontend/appflowy_tauri/src/appflowy_app/components/document/TextBlock/events/TurnIntoEvents.hooks.ts

@@ -3,10 +3,10 @@ import { BlockData, BlockType, TextBlockKeyEventHandlerParams } from '$app/inter
 import { keyBoardEventKeyMap } from '$app/constants/document/text_block';
 import { useAppDispatch } from '$app/stores/store';
 import { DocumentControllerContext } from '$app/stores/effects/document/document_controller';
-import { turnToBlockThunk } from '$app_reducers/document/async-actions';
+import { turnToBlockThunk, turnToDividerBlockThunk } from '$app_reducers/document/async-actions';
 import { blockConfig } from '$app/constants/document/config';
 import { Editor } from 'slate';
-import { getBeforeRangeAt } from '$app/utils/document/slate/text';
+import { getBeforeRangeAt } from '$app/utils/document/blocks/text/delta';
 import {
   getHeadingDataFromEditor,
   getQuoteDataFromEditor,
@@ -14,27 +14,29 @@ import {
   getBulletedDataFromEditor,
   getNumberedListDataFromEditor,
   getToggleListDataFromEditor,
+  getCalloutDataFromEditor,
 } from '$app/utils/document/blocks';
-
-const blockDataFactoryMap: Record<string, (editor: Editor) => BlockData<any> | undefined> = {
-  [BlockType.HeadingBlock]: getHeadingDataFromEditor,
-  [BlockType.TodoListBlock]: getTodoListDataFromEditor,
-  [BlockType.QuoteBlock]: getQuoteDataFromEditor,
-  [BlockType.BulletedListBlock]: getBulletedDataFromEditor,
-  [BlockType.NumberedListBlock]: getNumberedListDataFromEditor,
-  [BlockType.ToggleListBlock]: getToggleListDataFromEditor,
-};
+import { getDeltaAfterSelection } from '$app/utils/document/blocks/common';
 
 export function useTurnIntoBlock(id: string) {
   const controller = useContext(DocumentControllerContext);
   const dispatch = useAppDispatch();
 
   const turnIntoBlockEvents = useMemo(() => {
-    return Object.entries(blockDataFactoryMap).map(([type, getData]) => {
+    const spaceTriggerEvents = Object.entries({
+      [BlockType.HeadingBlock]: getHeadingDataFromEditor,
+      [BlockType.TodoListBlock]: getTodoListDataFromEditor,
+      [BlockType.QuoteBlock]: getQuoteDataFromEditor,
+      [BlockType.BulletedListBlock]: getBulletedDataFromEditor,
+      [BlockType.NumberedListBlock]: getNumberedListDataFromEditor,
+      [BlockType.ToggleListBlock]: getToggleListDataFromEditor,
+      [BlockType.CalloutBlock]: getCalloutDataFromEditor,
+    }).map(([type, getData]) => {
       const blockType = type as BlockType;
+      const triggerKey = keyBoardEventKeyMap.Space;
       return {
         triggerEventKey: keyBoardEventKeyMap.Space,
-        canHandle: canHandle(blockType),
+        canHandle: canHandle(blockType, triggerKey),
         handler: (...args: TextBlockKeyEventHandlerParams) => {
           if (!controller) return;
           const [_event, editor] = args;
@@ -43,7 +45,20 @@ export function useTurnIntoBlock(id: string) {
           dispatch(turnToBlockThunk({ id, data, type: blockType, controller }));
         },
       };
-    }, []);
+    });
+    return [
+      ...spaceTriggerEvents,
+      {
+        triggerEventKey: keyBoardEventKeyMap.Reduce,
+        canHandle: canHandle(BlockType.DividerBlock, keyBoardEventKeyMap.Reduce),
+        handler: (...args: TextBlockKeyEventHandlerParams) => {
+          if (!controller) return;
+          const [_event, editor] = args;
+          const delta = getDeltaAfterSelection(editor) || [];
+          dispatch(turnToDividerBlockThunk({ id, controller, delta }));
+        },
+      },
+    ];
   }, [controller, dispatch, id]);
 
   return {
@@ -51,7 +66,7 @@ export function useTurnIntoBlock(id: string) {
   };
 }
 
-function canHandle(type: BlockType) {
+function canHandle(type: BlockType, triggerKey: string) {
   const config = blockConfig[type];
 
   const regex = config.markdownRegexps;
@@ -62,16 +77,16 @@ function canHandle(type: BlockType) {
 
   return (...args: TextBlockKeyEventHandlerParams) => {
     const [event, editor] = args;
-    const isSpaceKey = event.key === keyBoardEventKeyMap.Space;
+    const isTrigger = event.key === triggerKey;
     const selection = editor.selection;
 
-    if (!isSpaceKey || !selection) {
+    if (!isTrigger || !selection) {
       return false;
     }
 
     const flag = Editor.string(editor, getBeforeRangeAt(editor, selection)).trim();
     if (flag === null) return false;
 
-    return regex.some((r) => r.test(flag));
+    return regex.some((r) => r.test(`${flag}${triggerKey}`));
   };
 }

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

@@ -3,7 +3,7 @@ import Leaf from './Leaf';
 import { useTextBlock } from './TextBlock.hooks';
 import BlockHorizontalToolbar from '../BlockHorizontalToolbar';
 import React from 'react';
-import { NestedBlock } from '$app/interfaces/document';
+import { BlockType, NestedBlock } from '$app/interfaces/document';
 import NodeChildren from '$app/components/document/Node/NodeChildren';
 
 function TextBlock({

+ 3 - 2
frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/TextInput.hooks.ts

@@ -11,7 +11,8 @@ import { useAppDispatch, useAppSelector } from '@/appflowy_app/stores/store';
 import { updateNodeDeltaThunk } from '$app_reducers/document/async-actions/blocks/text/update';
 import { deltaToSlateValue, getDeltaFromSlateNodes } from '$app/utils/document/blocks/common';
 import { documentActions } from '@/appflowy_app/stores/reducers/document/slice';
-import { isSameDelta } from '$app/utils/document/blocks/text';
+
+import { isSameDelta } from '$app/utils/document/blocks/text/delta';
 
 export function useTextInput(id: string) {
   const dispatch = useAppDispatch();
@@ -175,7 +176,7 @@ function setSelection(editor: ReactEditor, currentSelection: TextSelection) {
   const children = getDeltaFromSlateNodes(editor.children);
 
   // the path always has 2 elements,
-  // because the slate node is a two-dimensional array
+  // because the text node is a two-dimensional array
   const index = path[1];
   // It is possible that the current selection is out of range
   if (children[index].insert.length < offset) {

+ 29 - 6
frontend/appflowy_tauri/src/appflowy_app/constants/document/config.ts

@@ -58,7 +58,7 @@ export const blockConfig: Record<
     /**
      * # or ## or ###
      */
-    markdownRegexps: [/^(#{1,3})$/],
+    markdownRegexps: [/^(#{1,3})(\s)+$/],
   },
   [BlockType.TodoListBlock]: {
     canAddChild: true,
@@ -73,7 +73,7 @@ export const blockConfig: Record<
     /**
      * -[] or -[x] or -[ ] or [] or [x] or [ ]
      */
-    markdownRegexps: [/^((-)?\[(x|\s)?\])$/],
+    markdownRegexps: [/^((-)?\[(x|\s)?\])(\s)+$/],
   },
   [BlockType.BulletedListBlock]: {
     canAddChild: true,
@@ -88,7 +88,7 @@ export const blockConfig: Record<
     /**
      * - or + or *
      */
-    markdownRegexps: [/^(\s*[-+*])$/],
+    markdownRegexps: [/^(\s*[-+*])(\s)+$/],
   },
   [BlockType.NumberedListBlock]: {
     canAddChild: true,
@@ -104,7 +104,7 @@ export const blockConfig: Record<
      * 1. or 2. or 3.
      * a. or b. or c.
      */
-    markdownRegexps: [/^(\s*[\d|a-zA-Z]+\.)$/],
+    markdownRegexps: [/^(\s*[\d|a-zA-Z]+\.)(\s)+$/],
   },
   [BlockType.QuoteBlock]: {
     canAddChild: true,
@@ -119,7 +119,22 @@ export const blockConfig: Record<
     /**
      * " or “ or ”
      */
-    markdownRegexps: [/^("|“|”)$/],
+    markdownRegexps: [/^("|“|”)(\s)+$/],
+  },
+  [BlockType.CalloutBlock]: {
+    canAddChild: true,
+    defaultData: {
+      delta: [],
+      icon: 'bulb',
+    },
+    splitProps: {
+      nextLineRelationShip: SplitRelationship.NextSibling,
+      nextLineBlockType: BlockType.TextBlock,
+    },
+    /**
+     * [!TIP] or [!INFO] or [!WARNING] or [!DANGER]
+     */
+    markdownRegexps: [/^(\[!)(TIP|INFO|WARNING|DANGER)(\])(\s)+$/],
   },
   [BlockType.ToggleListBlock]: {
     canAddChild: true,
@@ -134,8 +149,16 @@ export const blockConfig: Record<
     /**
      * >
      */
-    markdownRegexps: [/^(>)$/],
+    markdownRegexps: [/^(>)(\s)+$/],
+  },
+  [BlockType.DividerBlock]: {
+    canAddChild: false,
+    /**
+     * ---
+     */
+    markdownRegexps: [/^(-{3,})$/],
   },
+
   [BlockType.CodeBlock]: {
     canAddChild: false,
     /**

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

@@ -7,4 +7,5 @@ export const keyBoardEventKeyMap = {
   Left: 'ArrowLeft',
   Right: 'ArrowRight',
   Space: ' ',
+  Reduce: '-',
 };

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

@@ -42,10 +42,16 @@ export interface QuoteBlockData extends TextBlockData {
   size: 'default' | 'large';
 }
 
+export interface CalloutBlockData extends TextBlockData {
+  icon: string;
+}
+
 export interface TextBlockData {
   delta: TextDelta[];
 }
 
+export interface DividerBlockData {}
+
 export type PageBlockData = TextBlockData;
 
 export type BlockData<Type> = Type extends BlockType.HeadingBlock
@@ -62,7 +68,13 @@ export type BlockData<Type> = Type extends BlockType.HeadingBlock
   ? NumberedListBlockData
   : Type extends BlockType.ToggleListBlock
   ? ToggleListBlockData
-  : TextBlockData;
+  : Type extends BlockType.DividerBlock
+  ? DividerBlockData
+  : Type extends BlockType.CalloutBlock
+  ? CalloutBlockData
+  : Type extends BlockType.TextBlock
+  ? TextBlockData
+  : any;
 
 export interface NestedBlock<Type = any> {
   id: string;

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

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

+ 16 - 12
frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/cursor.ts

@@ -9,7 +9,7 @@ import {
   getNodeBeginSelection,
   getNodeEndSelection,
   getStartLineSelectionByOffset,
-} from '$app/utils/document/slate/text';
+} from '$app/utils/document/blocks/text/delta';
 import { getNextLineId, getPrevLineId } from '$app/utils/document/blocks/common';
 
 export const setCursorBeforeThunk = createAsyncThunk(
@@ -43,13 +43,15 @@ export const setCursorPreLineThunk = createAsyncThunk(
     const state = (getState() as { document: DocumentState }).document;
     const prevId = getPrevLineId(state, id);
     if (!prevId) return;
-    const prevLineNode = state.nodes[prevId];
 
-    // if prev line have no delta, just set block is selected
-    if (!prevLineNode.data.delta) {
-      dispatch(documentActions.setSelectionById(prevId));
-      return;
+    let prevLineNode = state.nodes[prevId];
+    // Find the prev line that has delta
+    while (prevLineNode && !prevLineNode.data.delta) {
+      const id = getPrevLineId(state, prevLineNode.id);
+      if (!id) return;
+      prevLineNode = state.nodes[id];
     }
+    if (!prevLineNode) return;
 
     // whatever the selection is, set cursor to the end of prev line when focusEnd is true
     if (focusEnd) {
@@ -76,14 +78,16 @@ export const setCursorNextLineThunk = createAsyncThunk(
     const node = state.nodes[id];
     const nextId = getNextLineId(state, id);
     if (!nextId) return;
-    const nextLineNode = state.nodes[nextId];
-    const delta = nextLineNode.data.delta;
-    // if next line have no delta, just set block is selected
-    if (!delta) {
-      dispatch(documentActions.setSelectionById(nextId));
-      return;
+    let nextLineNode = state.nodes[nextId];
+    // Find the next line that has delta
+    while (nextLineNode && !nextLineNode.data.delta) {
+      const id = getNextLineId(state, nextLineNode.id);
+      if (!id) return;
+      nextLineNode = state.nodes[id];
     }
+    if (!nextLineNode) return;
 
+    const delta = nextLineNode.data.delta;
     // whatever the selection is, set cursor to the start of next line when focusStart is true
     if (focusStart) {
       await dispatch(setCursorBeforeThunk({ id: nextLineNode.id }));

+ 28 - 1
frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/turn_to.ts

@@ -1,9 +1,10 @@
 import { createAsyncThunk } from '@reduxjs/toolkit';
 import { DocumentController } from '$app/stores/effects/document/document_controller';
-import { BlockData, BlockType, DocumentState, NestedBlock } from '$app/interfaces/document';
+import { BlockData, BlockType, DocumentState, NestedBlock, TextDelta } from '$app/interfaces/document';
 import { setCursorBeforeThunk } from '$app_reducers/document/async-actions/cursor';
 import { blockConfig } from '$app/constants/document/config';
 import { newBlock } from '$app/utils/document/blocks/common';
+import { insertAfterNodeThunk } from '$app_reducers/document/async-actions/blocks';
 
 /**
  * transform to block
@@ -47,3 +48,29 @@ export const turnToBlockThunk = createAsyncThunk(
     await dispatch(setCursorBeforeThunk({ id: block.id }));
   }
 );
+
+/**
+ * turn to divider block
+ * 1. insert text block with delta after current block
+ * 2. turn current block to divider block
+ */
+export const turnToDividerBlockThunk = createAsyncThunk(
+  'document/turnToDividerBlock',
+  async (payload: { id: string; controller: DocumentController; delta: TextDelta[] }, thunkAPI) => {
+    const { id, controller, delta } = payload;
+    const { dispatch } = thunkAPI;
+    const { payload: newNodeId } = await dispatch(
+      insertAfterNodeThunk({
+        id,
+        controller,
+        type: BlockType.TextBlock,
+        data: {
+          delta,
+        },
+      })
+    );
+    if (!newNodeId) return;
+    await dispatch(turnToBlockThunk({ id, type: BlockType.DividerBlock, controller, data: {} }));
+    dispatch(setCursorBeforeThunk({ id: newNodeId as string }));
+  }
+);

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

@@ -3,7 +3,7 @@ import { Descendant, Editor, Element, Text } from 'slate';
 import { BlockPB } from '@/services/backend';
 import { Log } from '$app/utils/log';
 import { nanoid } from 'nanoid';
-import { getAfterRangeAt } from '$app/utils/document/slate/text';
+import { getAfterRangeAt } from '$app/utils/document/blocks/text/delta';
 
 export function deltaToSlateValue(delta: TextDelta[]) {
   const slateNode = {

+ 25 - 1
frontend/appflowy_tauri/src/appflowy_app/utils/document/blocks/index.ts

@@ -1,12 +1,13 @@
 import { Editor } from 'slate';
 import {
   BulletListBlockData,
+  CalloutBlockData,
   HeadingBlockData,
   NumberedListBlockData,
   TodoListBlockData,
   ToggleListBlockData,
 } from '$app/interfaces/document';
-import { getBeforeRangeAt } from '$app/utils/document/slate/text';
+import { getBeforeRangeAt } from '$app/utils/document/blocks/text/delta';
 import { getDeltaAfterSelection } from '$app/utils/document/blocks/common';
 
 /**
@@ -94,3 +95,26 @@ export function getToggleListDataFromEditor(editor: Editor): ToggleListBlockData
     collapsed: false,
   };
 }
+
+/**
+ * get callout data from editor, only support markdown
+ */
+export function getCalloutDataFromEditor(editor: Editor): CalloutBlockData | undefined {
+  const delta = getDeltaAfterSelection(editor);
+  if (!delta) return;
+  const selection = editor.selection;
+  if (!selection) return;
+  const hashTags = Editor.string(editor, getBeforeRangeAt(editor, selection));
+  const tag = hashTags.match(/(TIP|INFO|WARNING|DANGER)/g)?.[0];
+  if (!tag) return;
+  const iconMap: Record<string, string> = {
+    TIP: '💡',
+    INFO: '❗',
+    WARNING: '⚠️',
+    DANGER: '‼️',
+  };
+  return {
+    delta,
+    icon: iconMap[tag],
+  };
+}

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

@@ -1,16 +0,0 @@
-import { BlockType, NestedBlock, TextBlockData, TextDelta } from '$app/interfaces/document';
-import { newBlock } from '$app/utils/document/blocks/common';
-import * as Y from 'yjs';
-
-export function newTextBlock(parentId: string, data: TextBlockData): NestedBlock {
-  return newBlock<BlockType.TextBlock>(BlockType.TextBlock, parentId, data);
-}
-
-export function isSameDelta(referDelta: TextDelta[], delta: TextDelta[]) {
-  const ydoc = new Y.Doc();
-  const yText = ydoc.getText('1');
-  const yTextRefer = ydoc.getText('2');
-  yText.applyDelta(delta);
-  yTextRefer.applyDelta(referDelta);
-  return JSON.stringify(yText.toDelta()) === JSON.stringify(yTextRefer.toDelta());
-}

+ 11 - 1
frontend/appflowy_tauri/src/appflowy_app/utils/document/slate/text.ts → frontend/appflowy_tauri/src/appflowy_app/utils/document/blocks/text/delta.ts

@@ -1,5 +1,6 @@
-import { Editor, Element, Text, Location } from 'slate';
+import { Editor, Element, Location, Text } from 'slate';
 import { SelectionPoint, TextDelta, TextSelection } from '$app/interfaces/document';
+import * as Y from 'yjs';
 
 export function getDelta(editor: Editor, at: Location): TextDelta[] {
   const baseElement = Editor.fragment(editor, at)[0] as Element;
@@ -198,3 +199,12 @@ export function clonePoint(point: SelectionPoint): SelectionPoint {
     offset: point.offset,
   };
 }
+
+export function isSameDelta(referDelta: TextDelta[], delta: TextDelta[]) {
+  const ydoc = new Y.Doc();
+  const yText = ydoc.getText('1');
+  const yTextRefer = ydoc.getText('2');
+  yText.applyDelta(delta);
+  yTextRefer.applyDelta(referDelta);
+  return JSON.stringify(yText.toDelta()) === JSON.stringify(yTextRefer.toDelta());
+}

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


+ 1 - 1
frontend/appflowy_tauri/src/appflowy_app/utils/document/slate/hotkey.ts → frontend/appflowy_tauri/src/appflowy_app/utils/document/blocks/text/hotkey.ts

@@ -1,7 +1,7 @@
 import isHotkey from 'is-hotkey';
 import { toggleFormat } from './format';
 import { Editor, Range } from 'slate';
-import { clonePoint, getAfterRangeAt, getBeforeRangeAt, getDelta, pointInBegin, pointInEnd } from './text';
+import { clonePoint, getAfterRangeAt, getBeforeRangeAt, getDelta, pointInBegin, pointInEnd } from './delta';
 import { SelectionPoint, TextDelta, TextSelection } from '$app/interfaces/document';
 import { keyBoardEventKeyMap } from '$app/constants/document/text_block';
 

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