فهرست منبع

Support block config (#2419)

* fix: refactor block config
Kilu.He 2 سال پیش
والد
کامیت
cf97c8ba9c
35فایلهای تغییر یافته به همراه512 افزوده شده و 580 حذف شده
  1. 3 1
      frontend/appflowy_tauri/.eslintrc.cjs
  2. 1 0
      frontend/appflowy_tauri/package.json
  3. 11 0
      frontend/appflowy_tauri/pnpm-lock.yaml
  4. 2 201
      frontend/appflowy_tauri/src/appflowy_app/components/document/TextBlock/TextBlock.hooks.ts
  5. 75 0
      frontend/appflowy_tauri/src/appflowy_app/components/document/TextBlock/events/Actions.hooks.ts
  6. 113 0
      frontend/appflowy_tauri/src/appflowy_app/components/document/TextBlock/events/Events.hooks.ts
  7. 67 0
      frontend/appflowy_tauri/src/appflowy_app/components/document/TextBlock/events/TurnIntoEvents.hooks.ts
  8. 0 1
      frontend/appflowy_tauri/src/appflowy_app/components/document/TextBlock/index.tsx
  9. 0 86
      frontend/appflowy_tauri/src/appflowy_app/components/document/TextBlock/useMarkDown.hooks.ts
  10. 15 21
      frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/TextInput.hooks.ts
  11. 71 13
      frontend/appflowy_tauri/src/appflowy_app/constants/document/config.ts
  12. 2 0
      frontend/appflowy_tauri/src/appflowy_app/interfaces/document.ts
  13. 3 0
      frontend/appflowy_tauri/src/appflowy_app/stores/effects/document/document_controller.ts
  14. 0 3
      frontend/appflowy_tauri/src/appflowy_app/stores/effects/document/document_observer.ts
  15. 0 31
      frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/heading.ts
  16. 1 0
      frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/index.ts
  17. 0 31
      frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/quote.ts
  18. 1 1
      frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/text/backspace.ts
  19. 4 3
      frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/text/indent.ts
  20. 8 32
      frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/text/index.ts
  21. 14 6
      frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/text/split.ts
  22. 32 0
      frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/text/turn_to.ts
  23. 6 7
      frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/text/update.ts
  24. 0 31
      frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/todo_list.ts
  25. 1 0
      frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/cursor.ts
  26. 2 6
      frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/index.ts
  27. 12 9
      frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/turn_to.ts
  28. 0 22
      frontend/appflowy_tauri/src/appflowy_app/utils/document/blocks/heading.ts
  29. 52 0
      frontend/appflowy_tauri/src/appflowy_app/utils/document/blocks/index.ts
  30. 0 11
      frontend/appflowy_tauri/src/appflowy_app/utils/document/blocks/quote.ts
  31. 11 1
      frontend/appflowy_tauri/src/appflowy_app/utils/document/blocks/text.ts
  32. 0 21
      frontend/appflowy_tauri/src/appflowy_app/utils/document/blocks/todo_list.ts
  33. 0 39
      frontend/appflowy_tauri/src/appflowy_app/utils/document/slate/markdown.ts
  34. 1 2
      frontend/appflowy_tauri/src/appflowy_app/views/DocumentPage.hooks.ts
  35. 4 1
      frontend/rust-lib/flowy-notification/src/lib.rs

+ 3 - 1
frontend/appflowy_tauri/.eslintrc.cjs

@@ -12,8 +12,10 @@ module.exports = {
     sourceType: 'module',
     tsconfigRootDir: __dirname,
   },
-  plugins: ['@typescript-eslint'],
+  plugins: ['@typescript-eslint',  "react-hooks"],
   rules: {
+    "react-hooks/rules-of-hooks": "error",
+    "react-hooks/exhaustive-deps": "warn",
     '@typescript-eslint/adjacent-overload-signatures': 'error',
     '@typescript-eslint/no-empty-function': 'error',
     '@typescript-eslint/no-empty-interface': 'warn',

+ 1 - 0
frontend/appflowy_tauri/package.json

@@ -66,6 +66,7 @@
     "autoprefixer": "^10.4.13",
     "eslint": "^8.34.0",
     "eslint-plugin-react": "^7.32.2",
+    "eslint-plugin-react-hooks": "^4.6.0",
     "postcss": "^8.4.21",
     "prettier": "2.8.4",
     "prettier-plugin-tailwindcss": "^0.2.2",

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

@@ -25,6 +25,7 @@ specifiers:
   dayjs: ^1.11.7
   eslint: ^8.34.0
   eslint-plugin-react: ^7.32.2
+  eslint-plugin-react-hooks: ^4.6.0
   events: ^3.3.0
   google-protobuf: ^3.21.2
   i18next: ^22.4.10
@@ -110,6 +111,7 @@ devDependencies:
   autoprefixer: [email protected]
   eslint: 8.35.0
   eslint-plugin-react: [email protected]
+  eslint-plugin-react-hooks: [email protected]
   postcss: 8.4.21
   prettier: 2.8.4
   prettier-plugin-tailwindcss: [email protected]
@@ -2426,6 +2428,15 @@ packages:
     resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==}
     engines: {node: '>=10'}
 
+  /eslint-plugin-react-hooks/[email protected]:
+    resolution: {integrity: sha512-oFc7Itz9Qxh2x4gNHStv3BqJq54ExXmfC+a1NjAta66IAN87Wu0R/QArgIS9qKzX3dXKPI9H5crl9QchNMY9+g==}
+    engines: {node: '>=10'}
+    peerDependencies:
+      eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0
+    dependencies:
+      eslint: 8.35.0
+    dev: true
+
   /eslint-plugin-react/[email protected]:
     resolution: {integrity: sha512-t2fBMa+XzonrrNkyVirzKlvn5RXzzPwRHtMvLAtVZrt8oxgnTQaYbU6SXTOO1mwQgp1y5+toMSKInnzGr0Knqg==}
     engines: {node: '>=4'}

+ 2 - 201
frontend/appflowy_tauri/src/appflowy_app/components/document/TextBlock/TextBlock.hooks.ts

@@ -1,31 +1,6 @@
-import { useCallback, useContext, useMemo } from 'react';
-import { Editor } from 'slate';
-import { TextBlockKeyEventHandlerParams, TextDelta, TextSelection } from '$app/interfaces/document';
+import { useCallback } from 'react';
 import { useTextInput } from '../_shared/TextInput.hooks';
-import { useAppDispatch } from '@/appflowy_app/stores/store';
-import { DocumentControllerContext } from '@/appflowy_app/stores/effects/document/document_controller';
-import {
-  backspaceNodeThunk,
-  indentNodeThunk,
-  splitNodeThunk,
-  setCursorNextLineThunk,
-  setCursorPreLineThunk,
-} from '@/appflowy_app/stores/reducers/document/async-actions';
-import { documentActions } from '@/appflowy_app/stores/reducers/document/slice';
-import {
-  canHandleBackspaceKey,
-  canHandleDownKey,
-  canHandleEnterKey,
-  canHandleLeftKey,
-  canHandleRightKey,
-  canHandleTabKey,
-  canHandleUpKey,
-  onHandleEnterKey,
-  triggerHotkey,
-} from '$app/utils/document/slate/hotkey';
-import { updateNodeDeltaThunk } from '$app_reducers/document/async-actions/blocks/text/update';
-import { useMarkDown } from './useMarkDown.hooks';
-import { keyBoardEventKeyMap } from '$app/constants/document/text_block';
+import { useTextBlockKeyEvent } from '$app/components/document/TextBlock/events/Events.hooks';
 
 export function useTextBlock(id: string) {
   const { editor, onChange, value } = useTextInput(id);
@@ -55,177 +30,3 @@ export function useTextBlock(id: string) {
     value,
   };
 }
-
-function useTextBlockKeyEvent(id: string, editor: Editor) {
-  const { indentAction, backSpaceAction, splitAction, wrapAction, focusPreLineAction, focusNextLineAction } =
-    useActions(id);
-
-  const { markdownEvents } = useMarkDown(id);
-
-  const enterEvent = useMemo(() => {
-    return {
-      triggerEventKey: keyBoardEventKeyMap.Enter,
-      canHandle: canHandleEnterKey,
-      handler: (...args: TextBlockKeyEventHandlerParams) => {
-        onHandleEnterKey(...args, {
-          onSplit: splitAction,
-          onWrap: wrapAction,
-        });
-      },
-    };
-  }, [splitAction, wrapAction]);
-
-  const tabEvent = useMemo(() => {
-    return {
-      triggerEventKey: keyBoardEventKeyMap.Tab,
-      canHandle: canHandleTabKey,
-      handler: (..._args: TextBlockKeyEventHandlerParams) => {
-        void indentAction();
-      },
-    };
-  }, [indentAction]);
-
-  const backSpaceEvent = useMemo(() => {
-    return {
-      triggerEventKey: keyBoardEventKeyMap.Backspace,
-      canHandle: canHandleBackspaceKey,
-      handler: (..._args: TextBlockKeyEventHandlerParams) => {
-        void backSpaceAction();
-      },
-    };
-  }, [backSpaceAction]);
-
-  const upEvent = useMemo(() => {
-    return {
-      triggerEventKey: keyBoardEventKeyMap.Up,
-      canHandle: canHandleUpKey,
-      handler: (...args: TextBlockKeyEventHandlerParams) => {
-        void focusPreLineAction({
-          editor: args[1],
-        });
-      },
-    };
-  }, [focusPreLineAction]);
-
-  const downEvent = useMemo(() => {
-    return {
-      triggerEventKey: keyBoardEventKeyMap.Down,
-      canHandle: canHandleDownKey,
-      handler: (...args: TextBlockKeyEventHandlerParams) => {
-        void focusNextLineAction({
-          editor: args[1],
-        });
-      },
-    };
-  }, [focusNextLineAction]);
-
-  const leftEvent = useMemo(() => {
-    return {
-      triggerEventKey: keyBoardEventKeyMap.Left,
-      canHandle: canHandleLeftKey,
-      handler: (...args: TextBlockKeyEventHandlerParams) => {
-        void focusPreLineAction({
-          editor: args[1],
-          focusEnd: true,
-        });
-      },
-    };
-  }, [focusPreLineAction]);
-
-  const rightEvent = useMemo(() => {
-    return {
-      triggerEventKey: keyBoardEventKeyMap.Right,
-      canHandle: canHandleRightKey,
-      handler: (...args: TextBlockKeyEventHandlerParams) => {
-        void focusNextLineAction({
-          editor: args[1],
-          focusStart: true,
-        });
-      },
-    };
-  }, [focusNextLineAction]);
-
-  const onKeyDown = useCallback(
-    (event: React.KeyboardEvent<HTMLDivElement>) => {
-      // This is list of key events that can be handled by TextBlock
-      const keyEvents = [enterEvent, backSpaceEvent, tabEvent, upEvent, downEvent, leftEvent, rightEvent];
-
-      keyEvents.push(...markdownEvents);
-      const matchKeys = keyEvents.filter((keyEvent) => keyEvent.canHandle(event, editor));
-      if (matchKeys.length === 0) {
-        triggerHotkey(event, editor);
-        return;
-      }
-
-      event.stopPropagation();
-      event.preventDefault();
-      matchKeys.forEach((matchKey) => matchKey.handler(event, editor));
-    },
-    [editor, enterEvent, backSpaceEvent, tabEvent, upEvent, downEvent, leftEvent, rightEvent, markdownEvents]
-  );
-
-  return {
-    onKeyDown,
-  };
-}
-
-function useActions(id: string) {
-  const dispatch = useAppDispatch();
-  const controller = useContext(DocumentControllerContext);
-
-  const indentAction = useCallback(async () => {
-    if (!controller) return;
-    await dispatch(
-      indentNodeThunk({
-        id,
-        controller,
-      })
-    );
-  }, [id, controller]);
-
-  const backSpaceAction = useCallback(async () => {
-    if (!controller) return;
-    await dispatch(backspaceNodeThunk({ id, controller }));
-  }, [controller, id]);
-
-  const splitAction = useCallback(
-    async (retain: TextDelta[], insert: TextDelta[]) => {
-      if (!controller) return;
-      await dispatch(splitNodeThunk({ id, retain, insert, controller }));
-    },
-    [controller, id]
-  );
-
-  const wrapAction = useCallback(
-    async (delta: TextDelta[], selection: TextSelection) => {
-      if (!controller) return;
-      await dispatch(updateNodeDeltaThunk({ id, delta, controller }));
-      // This is a hack to make sure the selection is updated after next render
-      dispatch(documentActions.setTextSelection({ blockId: id, selection }));
-    },
-    [controller, id]
-  );
-
-  const focusPreLineAction = useCallback(
-    async (params: { editor: Editor; focusEnd?: boolean }) => {
-      await dispatch(setCursorPreLineThunk({ id, ...params }));
-    },
-    [id]
-  );
-
-  const focusNextLineAction = useCallback(
-    async (params: { editor: Editor; focusStart?: boolean }) => {
-      await dispatch(setCursorNextLineThunk({ id, ...params }));
-    },
-    [id]
-  );
-
-  return {
-    indentAction,
-    backSpaceAction,
-    splitAction,
-    wrapAction,
-    focusPreLineAction,
-    focusNextLineAction,
-  };
-}

+ 75 - 0
frontend/appflowy_tauri/src/appflowy_app/components/document/TextBlock/events/Actions.hooks.ts

@@ -0,0 +1,75 @@
+import { useAppDispatch } from '$app/stores/store';
+import { useCallback, useContext } from 'react';
+import { DocumentControllerContext } from '$app/stores/effects/document/document_controller';
+import {
+  backspaceNodeThunk,
+  indentNodeThunk,
+  setCursorNextLineThunk,
+  setCursorPreLineThunk,
+  splitNodeThunk,
+  updateNodeDeltaThunk,
+} from '$app_reducers/document/async-actions';
+import { TextDelta, TextSelection } from '$app/interfaces/document';
+import { documentActions } from '$app_reducers/document/slice';
+import { Editor } from 'slate';
+
+export function useActions(id: string) {
+  const dispatch = useAppDispatch();
+  const controller = useContext(DocumentControllerContext);
+
+  const indentAction = useCallback(async () => {
+    if (!controller) return;
+    await dispatch(
+      indentNodeThunk({
+        id,
+        controller,
+      })
+    );
+  }, [id, controller]);
+
+  const backSpaceAction = useCallback(async () => {
+    if (!controller) return;
+    await dispatch(backspaceNodeThunk({ id, controller }));
+  }, [controller, id]);
+
+  const splitAction = useCallback(
+    async (retain: TextDelta[], insert: TextDelta[]) => {
+      if (!controller) return;
+      await dispatch(splitNodeThunk({ id, retain, insert, controller }));
+    },
+    [controller, id]
+  );
+
+  const wrapAction = useCallback(
+    async (delta: TextDelta[], selection: TextSelection) => {
+      if (!controller) return;
+      await dispatch(updateNodeDeltaThunk({ id, delta, controller }));
+      // This is a hack to make sure the selection is updated after next render
+      dispatch(documentActions.setTextSelection({ blockId: id, selection }));
+    },
+    [controller, id]
+  );
+
+  const focusPreLineAction = useCallback(
+    async (params: { editor: Editor; focusEnd?: boolean }) => {
+      await dispatch(setCursorPreLineThunk({ id, ...params }));
+    },
+    [id]
+  );
+
+  const focusNextLineAction = useCallback(
+    async (params: { editor: Editor; focusStart?: boolean }) => {
+      await dispatch(setCursorNextLineThunk({ id, ...params }));
+    },
+    [id]
+  );
+
+  return {
+    indentAction,
+    backSpaceAction,
+    splitAction,
+    wrapAction,
+    focusPreLineAction,
+    focusNextLineAction,
+  };
+}

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

@@ -0,0 +1,113 @@
+import { Editor } from 'slate';
+import { useTurnIntoBlock } from './TurnIntoEvents.hooks';
+import { useCallback, useMemo } from 'react';
+import { keyBoardEventKeyMap } from '$app/constants/document/text_block';
+import {
+  canHandleBackspaceKey,
+  canHandleDownKey,
+  canHandleEnterKey,
+  canHandleLeftKey,
+  canHandleRightKey,
+  canHandleTabKey,
+  canHandleUpKey,
+  onHandleEnterKey,
+  triggerHotkey,
+} from '$app/utils/document/slate/hotkey';
+import { TextBlockKeyEventHandlerParams } from '$app/interfaces/document';
+import { useActions } from './Actions.hooks';
+
+export function useTextBlockKeyEvent(id: string, editor: Editor) {
+  const { indentAction, backSpaceAction, splitAction, wrapAction, focusPreLineAction, focusNextLineAction } =
+    useActions(id);
+
+  const { turnIntoBlockEvents } = useTurnIntoBlock(id);
+
+  const events = useMemo(() => {
+    return [
+      {
+        triggerEventKey: keyBoardEventKeyMap.Enter,
+        canHandle: canHandleEnterKey,
+        handler: (...args: TextBlockKeyEventHandlerParams) => {
+          onHandleEnterKey(...args, {
+            onSplit: splitAction,
+            onWrap: wrapAction,
+          });
+        },
+      },
+      {
+        triggerEventKey: keyBoardEventKeyMap.Tab,
+        canHandle: canHandleTabKey,
+        handler: (..._args: TextBlockKeyEventHandlerParams) => {
+          void indentAction();
+        },
+      },
+      {
+        triggerEventKey: keyBoardEventKeyMap.Backspace,
+        canHandle: canHandleBackspaceKey,
+        handler: (..._args: TextBlockKeyEventHandlerParams) => {
+          void backSpaceAction();
+        },
+      },
+      {
+        triggerEventKey: keyBoardEventKeyMap.Up,
+        canHandle: canHandleUpKey,
+        handler: (...args: TextBlockKeyEventHandlerParams) => {
+          void focusPreLineAction({
+            editor: args[1],
+          });
+        },
+      },
+      {
+        triggerEventKey: keyBoardEventKeyMap.Down,
+        canHandle: canHandleDownKey,
+        handler: (...args: TextBlockKeyEventHandlerParams) => {
+          void focusNextLineAction({
+            editor: args[1],
+          });
+        },
+      },
+      {
+        triggerEventKey: keyBoardEventKeyMap.Left,
+        canHandle: canHandleLeftKey,
+        handler: (...args: TextBlockKeyEventHandlerParams) => {
+          void focusPreLineAction({
+            editor: args[1],
+            focusEnd: true,
+          });
+        },
+      },
+      {
+        triggerEventKey: keyBoardEventKeyMap.Right,
+        canHandle: canHandleRightKey,
+        handler: (...args: TextBlockKeyEventHandlerParams) => {
+          void focusNextLineAction({
+            editor: args[1],
+            focusStart: true,
+          });
+        },
+      },
+    ];
+  }, [splitAction, wrapAction, indentAction, backSpaceAction, focusPreLineAction, focusNextLineAction]);
+
+  const onKeyDown = useCallback(
+    (event: React.KeyboardEvent<HTMLDivElement>) => {
+      // This is list of key events that can be handled by TextBlock
+      const keyEvents = [...events, ...turnIntoBlockEvents];
+
+      const matchKeys = keyEvents.filter((keyEvent) => keyEvent.canHandle(event, editor));
+      if (matchKeys.length === 0) {
+        triggerHotkey(event, editor);
+        return;
+      }
+
+      event.stopPropagation();
+      event.preventDefault();
+      matchKeys.forEach((matchKey) => matchKey.handler(event, editor));
+    },
+    [editor, events, turnIntoBlockEvents]
+  );
+
+  return {
+    onKeyDown,
+  };
+}

+ 67 - 0
frontend/appflowy_tauri/src/appflowy_app/components/document/TextBlock/events/TurnIntoEvents.hooks.ts

@@ -0,0 +1,67 @@
+import { useContext, useMemo } from 'react';
+import { BlockData, BlockType, TextBlockKeyEventHandlerParams } from '$app/interfaces/document';
+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 { blockConfig } from '$app/constants/document/config';
+import { Editor } from 'slate';
+import { getBeforeRangeAt } from '$app/utils/document/slate/text';
+import { getHeadingDataFromEditor, getQuoteDataFromEditor, getTodoListDataFromEditor } from '$app/utils/document/blocks';
+
+const blockDataFactoryMap: Record<string, (editor: Editor) => BlockData<any> | undefined> = {
+  [BlockType.HeadingBlock]: getHeadingDataFromEditor,
+  [BlockType.TodoListBlock]: getTodoListDataFromEditor,
+  [BlockType.QuoteBlock]: getQuoteDataFromEditor,
+};
+
+export function useTurnIntoBlock(id: string) {
+  const controller = useContext(DocumentControllerContext);
+  const dispatch = useAppDispatch();
+
+  const turnIntoBlockEvents = useMemo(() => {
+    return Object.entries(blockDataFactoryMap).map(([type, getData]) => {
+      const blockType = type as BlockType;
+      return {
+        triggerEventKey: keyBoardEventKeyMap.Space,
+        canHandle: canHandle(blockType),
+        handler: (...args: TextBlockKeyEventHandlerParams) => {
+          if (!controller) return;
+          const [_event, editor] = args;
+          const data = getData(editor);
+          if (!data) return;
+          dispatch(turnToBlockThunk({ id, data, type: blockType, controller }));
+        },
+      };
+    }, []);
+  }, [controller, dispatch, id]);
+
+  return {
+    turnIntoBlockEvents,
+  };
+}
+
+function canHandle(type: BlockType) {
+  const config = blockConfig[type];
+
+  const regex = config.markdownRegexps;
+  // This error will be thrown if the block type is not in the config, and it will happen in development environment
+  if (!regex) {
+    throw new Error(`canHandle: block type ${type} is not supported`);
+  }
+
+  return (...args: TextBlockKeyEventHandlerParams) => {
+    const [event, editor] = args;
+    const isSpaceKey = event.key === keyBoardEventKeyMap.Space;
+    const selection = editor.selection;
+
+    if (!isSpaceKey || !selection) {
+      return false;
+    }
+
+    const flag = Editor.string(editor, getBeforeRangeAt(editor, selection)).trim();
+    if (flag === null) return false;
+
+    return regex.some((r) => r.test(flag));
+  };
+}

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

@@ -1,7 +1,6 @@
 import { Slate, Editable } from 'slate-react';
 import Leaf from './Leaf';
 import { useTextBlock } from './TextBlock.hooks';
-import NodeComponent from '../Node';
 import BlockHorizontalToolbar from '../BlockHorizontalToolbar';
 import React from 'react';
 import { NestedBlock } from '$app/interfaces/document';

+ 0 - 86
frontend/appflowy_tauri/src/appflowy_app/components/document/TextBlock/useMarkDown.hooks.ts

@@ -1,86 +0,0 @@
-import { useCallback, useContext, useMemo } from 'react';
-import { TextBlockKeyEventHandlerParams } from '$app/interfaces/document';
-import { keyBoardEventKeyMap } from '$app/constants/document/text_block';
-import {
-  canHandleToHeadingBlock,
-  canHandleToCheckboxBlock,
-  canHandleToQuoteBlock,
-} from '$app/utils/document/slate/markdown';
-import { useAppDispatch } from '$app/stores/store';
-import { DocumentControllerContext } from '$app/stores/effects/document/document_controller';
-import { turnToHeadingBlockThunk } from '$app_reducers/document/async-actions/blocks/heading';
-import { turnToTodoListBlockThunk } from '$app_reducers/document/async-actions/blocks/todo_list';
-import { turnToQuoteBlockThunk } from '$app_reducers/document/async-actions/blocks/quote';
-
-export function useMarkDown(id: string) {
-  const { toHeadingBlockAction, toCheckboxBlockAction, toQuoteBlockAction } = useActions(id);
-  const toHeadingBlockEvent = useMemo(() => {
-    return {
-      triggerEventKey: keyBoardEventKeyMap.Space,
-      canHandle: canHandleToHeadingBlock,
-      handler: toHeadingBlockAction,
-    };
-  }, [toHeadingBlockAction]);
-
-  const toCheckboxBlockEvent = useMemo(() => {
-    return {
-      triggerEventKey: keyBoardEventKeyMap.Space,
-      canHandle: canHandleToCheckboxBlock,
-      handler: toCheckboxBlockAction,
-    };
-  }, [toCheckboxBlockAction]);
-
-  const toQuoteBlockEvent = useMemo(() => {
-    return {
-      triggerEventKey: keyBoardEventKeyMap.Space,
-      canHandle: canHandleToQuoteBlock,
-      handler: toQuoteBlockAction,
-    };
-  }, [toQuoteBlockAction]);
-
-  const markdownEvents = useMemo(
-    () => [toHeadingBlockEvent, toCheckboxBlockEvent, toQuoteBlockEvent],
-    [toHeadingBlockEvent, toCheckboxBlockEvent, toQuoteBlockEvent]
-  );
-
-  return {
-    markdownEvents,
-  };
-}
-
-function useActions(id: string) {
-  const controller = useContext(DocumentControllerContext);
-  const dispatch = useAppDispatch();
-  const toHeadingBlockAction = useCallback(
-    (...args: TextBlockKeyEventHandlerParams) => {
-      if (!controller) return;
-      const [_event, editor] = args;
-      dispatch(turnToHeadingBlockThunk({ id, editor, controller }));
-    },
-    [controller, dispatch, id]
-  );
-
-  const toCheckboxBlockAction = useCallback(
-    (...args: TextBlockKeyEventHandlerParams) => {
-      if (!controller) return;
-      const [_event, editor] = args;
-      dispatch(turnToTodoListBlockThunk({ id, controller, editor }));
-    },
-    [controller, dispatch, id]
-  );
-
-  const toQuoteBlockAction = useCallback(
-    (...args: TextBlockKeyEventHandlerParams) => {
-      if (!controller) return;
-      const [_event, editor] = args;
-      dispatch(turnToQuoteBlockThunk({ id, controller, editor }));
-    },
-    [controller, dispatch, id]
-  );
-
-  return {
-    toHeadingBlockAction,
-    toCheckboxBlockAction,
-    toQuoteBlockAction,
-  };
-}

+ 15 - 21
frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/TextInput.hooks.ts

@@ -11,6 +11,7 @@ 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';
 
 export function useTextInput(id: string) {
   const dispatch = useAppDispatch();
@@ -25,27 +26,18 @@ export function useTextInput(id: string) {
 
   const { editor, yText } = useBindYjs(id, delta);
 
-  useEffect(() => {
-    return () => {
-      dispatch(documentActions.removeTextSelection(id));
-    };
-  }, [id]);
-
   const [value, setValue] = useState<Descendant[]>([]);
 
   const storeSelection = useCallback(() => {
     // This is a hack to make sure the selection is updated after next render
     // It will save the selection to the store, and the selection will be restored
-    if (!ReactEditor.isFocused(editor) || !editor.selection || !editor.selection.anchor || !editor.selection.focus)
-      return;
-    const { anchor, focus } = editor.selection;
-    const selection = { anchor, focus } as TextSelection;
+    if (!ReactEditor.isFocused(editor)) return;
+    const selection = editor.selection as TextSelection;
     dispatch(documentActions.setTextSelection({ blockId: id, selection }));
   }, [editor]);
 
   const currentSelection = useAppSelector((state) => state.document.textSelections[id]);
   const restoreSelection = useCallback(() => {
-    if (editor.selection && JSON.stringify(currentSelection) === JSON.stringify(editor.selection)) return;
     setSelection(editor, currentSelection);
   }, [editor, currentSelection]);
 
@@ -54,13 +46,15 @@ export function useTextInput(id: string) {
       setValue(e);
       storeSelection();
     },
-
     [storeSelection]
   );
 
   useEffect(() => {
     restoreSelection();
-  }, [restoreSelection]);
+    return () => {
+      dispatch(documentActions.removeTextSelection(id));
+    };
+  }, [id, restoreSelection]);
 
   if (editor.selection && ReactEditor.isFocused(editor)) {
     const domSelection = window.getSelection();
@@ -128,12 +122,13 @@ function useBindYjs(id: string, delta: TextDelta[]) {
     if (!yText) return;
 
     // If the delta is not equal to the current yText, then we need to update the yText
-    if (JSON.stringify(yText.toDelta()) !== JSON.stringify(delta)) {
-      yText.delete(0, yText.length);
-      yText.applyDelta(delta);
-      // It should be noted that the selection will be lost after the yText is updated
-      setSelection(editor, currentSelection);
-    }
+    const isSame = isSameDelta(delta, yText.toDelta());
+    if (isSame) return;
+
+    yText.delete(0, yText.length);
+    yText.applyDelta(delta);
+    // It should be noted that the selection will be lost after the yText is updated
+    setSelection(editor, currentSelection);
   }, [delta, currentSelection, editor]);
 
   return { editor, yText: yTextRef.current };
@@ -167,7 +162,6 @@ function setSelection(editor: ReactEditor, currentSelection: TextSelection) {
   if (!currentSelection || !currentSelection.anchor || !currentSelection.focus) {
     if (ReactEditor.isFocused(editor)) {
       ReactEditor.blur(editor);
-      ReactEditor.deselect(editor);
     }
     return;
   }
@@ -178,12 +172,12 @@ function setSelection(editor: ReactEditor, currentSelection: TextSelection) {
   }
 
   const { path, offset } = currentSelection.focus;
-  // It is possible that the current selection is out of range
   const children = getDeltaFromSlateNodes(editor.children);
 
   // the path always has 2 elements,
   // because the slate 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) {
     return;
   }

+ 71 - 13
frontend/appflowy_tauri/src/appflowy_app/constants/document/config.ts

@@ -1,17 +1,75 @@
 import { BlockType } from '$app/interfaces/document';
 
 /**
- * Block types that are allowed to have children
+ * If the block type is not in the config, it will be thrown an error in development env
  */
-export const allowedChildrenBlockTypes = [
-  BlockType.TextBlock,
-  BlockType.PageBlock,
-  BlockType.TodoListBlock,
-  BlockType.QuoteBlock,
-  BlockType.CalloutBlock,
-];
-
-/**
- * Block types that split node can extend to the next line
- */
-export const splitableBlockTypes = [BlockType.TextBlock, BlockType.TodoListBlock];
+export const blockConfig: Record<
+  string,
+  {
+    /**
+     * Whether the block can have children
+     */
+    canAddChild: boolean;
+    /**
+     * the type of the block that will be split from the current block
+     */
+    splitType: BlockType;
+    /**
+     * The regexps that will be used to match the markdown flag
+     */
+    markdownRegexps?: RegExp[];
+  }
+> = {
+  [BlockType.TextBlock]: {
+    canAddChild: true,
+    splitType: BlockType.TextBlock,
+  },
+  [BlockType.HeadingBlock]: {
+    canAddChild: false,
+    splitType: BlockType.TextBlock,
+    /**
+     * # or ## or ###
+     */
+    markdownRegexps: [/^(#{1,3})$/],
+  },
+  [BlockType.TodoListBlock]: {
+    canAddChild: true,
+    splitType: BlockType.TodoListBlock,
+    /**
+     * -[] or -[x] or -[ ] or [] or [x] or [ ]
+     */
+    markdownRegexps: [/^((-)?\[(x|\s)?\])$/],
+  },
+  [BlockType.BulletedListBlock]: {
+    canAddChild: true,
+    splitType: BlockType.BulletedListBlock,
+    /**
+     * - or + or *
+     */
+    markdownRegexps: [/^(\s*[-+*])$/],
+  },
+  [BlockType.NumberedListBlock]: {
+    canAddChild: true,
+    splitType: BlockType.NumberedListBlock,
+    /**
+     * 1. or 2. or 3.
+     */
+    markdownRegexps: [/^(\s*\d+\.)$/],
+  },
+  [BlockType.QuoteBlock]: {
+    canAddChild: true,
+    splitType: BlockType.TextBlock,
+    /**
+     * " or “ or ”
+     */
+    markdownRegexps: [/^("|“|”)$/],
+  },
+  [BlockType.CodeBlock]: {
+    canAddChild: false,
+    splitType: BlockType.TextBlock,
+    /**
+     * ```
+     */
+    markdownRegexps: [/^(```)$/],
+  },
+};

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

@@ -5,6 +5,8 @@ export enum BlockType {
   HeadingBlock = 'heading',
   TextBlock = 'text',
   TodoListBlock = 'todo_list',
+  BulletedListBlock = 'bulleted_list',
+  NumberedListBlock = 'numbered_list',
   CodeBlock = 'code',
   EmbedBlock = 'embed',
   QuoteBlock = 'quote',

+ 3 - 0
frontend/appflowy_tauri/src/appflowy_app/stores/effects/document/document_controller.ts

@@ -15,6 +15,7 @@ import * as Y from 'yjs';
 import { BLOCK_MAP_NAME, CHILDREN_MAP_NAME, META_NAME } from '$app/constants/document/block';
 import { get } from '@/appflowy_app/utils/tool';
 import { blockPB2Node } from '$app/utils/document/blocks/common';
+import { Log } from '$app/utils/log';
 
 export const DocumentControllerContext = createContext<DocumentController | null>(null);
 
@@ -65,6 +66,7 @@ export class DocumentController {
   };
 
   applyActions = async (actions: ReturnType<typeof BlockActionPB.prototype.toObject>[]) => {
+    Log.debug('applyActions', actions);
     await this.backendService.applyActions(actions);
   };
 
@@ -153,6 +155,7 @@ export class DocumentController {
     if (!this.onDocChange) return;
     const { events, is_remote } = DocEventPB.deserializeBinary(payload);
 
+    Log.debug('DocumentController', 'updated', { events, is_remote });
     events.forEach((blockEvent) => {
       blockEvent.event.forEach((_payload) => {
         this.onDocChange?.({

+ 0 - 3
frontend/appflowy_tauri/src/appflowy_app/stores/effects/document/document_observer.ts

@@ -1,6 +1,3 @@
-import { Ok, Result } from 'ts-results';
-import { ChangeNotifier } from '$app/utils/change_notifier';
-import { FolderNotificationObserver } from '../folder/notifications/observer';
 import { DocumentNotification } from '@/services/backend';
 import { DocumentNotificationObserver } from './notifications/observer';
 

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

@@ -1,31 +0,0 @@
-import { createAsyncThunk } from '@reduxjs/toolkit';
-import { Editor } from 'slate';
-import { DocumentController } from '$app/stores/effects/document/document_controller';
-import { BlockType } from '$app/interfaces/document';
-import { turnToBlockThunk } from '$app_reducers/document/async-actions/turn_to';
-import { getHeadingDataFromEditor } from '$app/utils/document/blocks/heading';
-
-/**
- * transform to heading block
- * 1. insert heading block after current block
- * 2. move all children to parent after heading block, because heading block can't have children
- * 3. delete current block
- */
-export const turnToHeadingBlockThunk = createAsyncThunk(
-  'document/turnToHeadingBlock',
-  async (payload: { id: string; editor: Editor; controller: DocumentController }, thunkAPI) => {
-    const { id, editor, controller } = payload;
-    const { dispatch } = thunkAPI;
-
-    const data = getHeadingDataFromEditor(editor);
-    if (!data) return;
-    await dispatch(
-      turnToBlockThunk({
-        id,
-        controller,
-        type: BlockType.HeadingBlock,
-        data,
-      })
-    );
-  }
-);

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

@@ -0,0 +1 @@
+export * from './text';

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

@@ -1,31 +0,0 @@
-import { createAsyncThunk } from '@reduxjs/toolkit';
-import { DocumentController } from '$app/stores/effects/document/document_controller';
-import { BlockType } from '$app/interfaces/document';
-import { turnToBlockThunk } from '$app_reducers/document/async-actions/turn_to';
-import { Editor } from 'slate';
-import { getQuoteDataFromEditor } from '$app/utils/document/blocks/quote';
-
-/**
- * transform to quote block
- * 1. insert quote block after current block
- * 2. move children to quote block
- * 3. delete current block
- */
-export const turnToQuoteBlockThunk = createAsyncThunk(
-  'document/turnToQuoteBlock',
-  async (payload: { id: string; editor: Editor; controller: DocumentController }, thunkAPI) => {
-    const { id, controller, editor } = payload;
-    const { dispatch } = thunkAPI;
-    const data = getQuoteDataFromEditor(editor);
-    if (!data) return;
-
-    await dispatch(
-      turnToBlockThunk({
-        id,
-        controller,
-        type: BlockType.QuoteBlock,
-        data,
-      })
-    );
-  }
-);

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

@@ -4,8 +4,8 @@ import { createAsyncThunk } from '@reduxjs/toolkit';
 import { documentActions } from '$app_reducers/document/slice';
 import { outdentNodeThunk } from './outdent';
 import { setCursorAfterThunk } from '../../cursor';
-import { turnToTextBlockThunk } from '$app_reducers/document/async-actions/blocks/text/index';
 import { getPrevLineId } from '$app/utils/document/blocks/common';
+import { turnToTextBlockThunk } from '$app_reducers/document/async-actions/blocks/text/turn_to';
 
 const composeNodeThunk = createAsyncThunk(
   'document/composeNode',

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

@@ -1,7 +1,7 @@
-import { BlockType, DocumentState } from '$app/interfaces/document';
+import { DocumentState } from '$app/interfaces/document';
 import { DocumentController } from '$app/stores/effects/document/document_controller';
 import { createAsyncThunk } from '@reduxjs/toolkit';
-import { allowedChildrenBlockTypes } from '$app/constants/document/config';
+import { blockConfig } from '$app/constants/document/config';
 
 export const indentNodeThunk = createAsyncThunk(
   'document/indentNode',
@@ -20,7 +20,8 @@ export const indentNodeThunk = createAsyncThunk(
     const newParentId = children[index - 1];
     const prevNode = state.nodes[newParentId];
     // check if prev node is allowed to have children
-    if (!allowedChildrenBlockTypes.includes(prevNode.type)) return;
+    const config = blockConfig[prevNode.type];
+    if (!config.canAddChild) return;
     // check if prev node has children and get last child for new prev node
     const prevNodeChildren = state.children[prevNode.children];
     const newPrevId = prevNodeChildren[prevNodeChildren.length - 1];

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

@@ -1,32 +1,8 @@
-import { createAsyncThunk } from '@reduxjs/toolkit';
-import { DocumentController } from '$app/stores/effects/document/document_controller';
-import { BlockType, DocumentState } from '$app/interfaces/document';
-import { turnToBlockThunk } from '$app_reducers/document/async-actions/turn_to';
-
-/**
- * transform to text block
- * 1. insert text block after current block
- * 2. move children to text block
- * 3. delete current block
- */
-export const turnToTextBlockThunk = createAsyncThunk(
-  'document/turnToTextBlock',
-  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];
-    const data = {
-      delta: node.data.delta,
-    };
-
-    await dispatch(
-      turnToBlockThunk({
-        id,
-        controller,
-        type: BlockType.TextBlock,
-        data,
-      })
-    );
-  }
-);
+export * from './delete';
+export * from './indent';
+export * from './insert';
+export * from './backspace';
+export * from './outdent';
+export * from './split';
+export * from './turn_to';
+export * from './update';

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

@@ -1,10 +1,10 @@
-import { BlockType, DocumentState, TextDelta } from '$app/interfaces/document';
+import { DocumentState, TextDelta } from '$app/interfaces/document';
 import { DocumentController } from '$app/stores/effects/document/document_controller';
 import { createAsyncThunk } from '@reduxjs/toolkit';
 import { documentActions } from '$app_reducers/document/slice';
 import { setCursorBeforeThunk } from '../../cursor';
 import { getDefaultBlockData, newBlock } from '$app/utils/document/blocks/common';
-import { splitableBlockTypes } from '$app/constants/document/config';
+import { blockConfig } from '$app/constants/document/config';
 
 export const splitNodeThunk = createAsyncThunk(
   'document/splitNode',
@@ -18,10 +18,11 @@ export const splitNodeThunk = createAsyncThunk(
     const node = state.nodes[id];
     if (!node.parent) return;
     const children = state.children[node.children];
-    const prevId = children.length > 0 ? null : node.id;
-    const parent = children.length > 0 ? node : state.nodes[node.parent];
+    const prevId = node.id;
+    const parent = state.nodes[node.parent];
 
-    const newNodeType = splitableBlockTypes.includes(node.type) ? node.type : BlockType.TextBlock;
+    const config = blockConfig[node.type];
+    const newNodeType = config.splitType;
     const defaultData = getDefaultBlockData(newNodeType);
     const newNode = newBlock<any>(newNodeType, parent.id, {
       ...defaultData,
@@ -34,7 +35,14 @@ export const splitNodeThunk = createAsyncThunk(
         delta: retain,
       },
     };
-    await controller.applyActions([controller.getInsertAction(newNode, prevId), controller.getUpdateAction(retainNode)]);
+    const insertAction = controller.getInsertAction(newNode, prevId);
+    const updateAction = controller.getUpdateAction(retainNode);
+    const moveChildrenAction = controller.getMoveChildrenAction(
+      children.map((id) => state.nodes[id]),
+      newNode.id,
+      ''
+    );
+    await controller.applyActions([insertAction, ...moveChildrenAction, updateAction]);
     // update local node data
     dispatch(documentActions.updateNodeData({ id: retainNode.id, data: { delta: retain } }));
     // set cursor

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

@@ -0,0 +1,32 @@
+import { createAsyncThunk } from '@reduxjs/toolkit';
+import { DocumentController } from '$app/stores/effects/document/document_controller';
+import { BlockType, DocumentState } from '$app/interfaces/document';
+import { turnToBlockThunk } from '$app_reducers/document/async-actions/turn_to';
+
+/**
+ * transform to text block
+ * 1. insert text block after current block
+ * 2. move children to text block
+ * 3. delete current block
+ */
+export const turnToTextBlockThunk = createAsyncThunk(
+  'document/turnToTextBlock',
+  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];
+    const data = {
+      delta: node.data.delta,
+    };
+
+    await dispatch(
+      turnToBlockThunk({
+        id,
+        controller,
+        type: BlockType.TextBlock,
+        data,
+      })
+    );
+  }
+);

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

@@ -9,11 +9,11 @@ export const updateNodeDeltaThunk = createAsyncThunk(
     const { id, delta, controller } = payload;
     const { dispatch, getState } = thunkAPI;
     const state = (getState() as { document: DocumentState }).document;
+    const node = state.nodes[id];
     // The block map should be updated immediately
     // or the component will use the old data to update the editor
     dispatch(documentActions.updateNodeData({ id, data: { delta } }));
 
-    const node = state.nodes[id];
     // the transaction is delayed to avoid too many updates
     debounceApplyUpdate(controller, {
       ...node,
@@ -47,17 +47,16 @@ export const updateNodeDataThunk = createAsyncThunk<
   const { id, data, controller } = payload;
   const { dispatch, getState } = thunkAPI;
   const state = (getState() as { document: DocumentState }).document;
+  const node = state.nodes[id];
 
-  dispatch(documentActions.updateNodeData({ id, data: { ...data } }));
+  const newData = { ...node.data, ...data };
+
+  dispatch(documentActions.updateNodeData({ id, data: newData }));
 
-  const node = state.nodes[id];
   await controller.applyActions([
     controller.getUpdateAction({
       ...node,
-      data: {
-        ...node.data,
-        ...data,
-      },
+      data: newData,
     }),
   ]);
 });

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

@@ -1,31 +0,0 @@
-import { createAsyncThunk } from '@reduxjs/toolkit';
-import { DocumentController } from '$app/stores/effects/document/document_controller';
-import { BlockType } from '$app/interfaces/document';
-import { turnToBlockThunk } from '$app_reducers/document/async-actions/turn_to';
-import { Editor } from 'slate';
-import { getTodoListDataFromEditor } from '$app/utils/document/blocks/todo_list';
-
-/**
- * transform to todolist block
- * 1. insert todolist block after current block
- * 2. move children to todolist block
- * 3. delete current block
- */
-export const turnToTodoListBlockThunk = createAsyncThunk(
-  'document/turnToTodoListBlock',
-  async (payload: { id: string; editor: Editor; controller: DocumentController }, thunkAPI) => {
-    const { id, controller, editor } = payload;
-    const { dispatch } = thunkAPI;
-    const data = getTodoListDataFromEditor(editor);
-    if (!data) return;
-
-    await dispatch(
-      turnToBlockThunk({
-        id,
-        controller,
-        type: BlockType.TodoListBlock,
-        data,
-      })
-    );
-  }
-);

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

@@ -95,6 +95,7 @@ export const setCursorNextLineThunk = createAsyncThunk(
 
     // set the cursor to next line with the relative offset
     const newSelection = getStartLineSelectionByOffset(delta, textOffset);
+
     dispatch(documentActions.setTextSelection({ blockId: nextLineNode.id, selection: newSelection }));
   }
 );

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

@@ -1,7 +1,3 @@
-export * from './blocks/text/delete';
-export * from './blocks/text/indent';
-export * from './blocks/text/insert';
-export * from './blocks/text/backspace';
-export * from './blocks/text/outdent';
-export * from './blocks/text/split';
 export * from './cursor';
+export * from './blocks';
+export * from './turn_to';

+ 12 - 9
frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/turn_to.ts

@@ -2,9 +2,17 @@ import { createAsyncThunk } from '@reduxjs/toolkit';
 import { DocumentController } from '$app/stores/effects/document/document_controller';
 import { BlockData, BlockType, DocumentState, NestedBlock } from '$app/interfaces/document';
 import { setCursorBeforeThunk } from '$app_reducers/document/async-actions/cursor';
-import { allowedChildrenBlockTypes } from '$app/constants/document/config';
+import { blockConfig } from '$app/constants/document/config';
 import { newBlock } from '$app/utils/document/blocks/common';
 
+/**
+ * transform to block
+ * 1. insert block after current block
+ * 2. move all children
+ *    - if new block is not allowed to have children, move children to parent
+ *    - otherwise, move children to new block
+ * 3. delete current block
+ */
 export const turnToBlockThunk = createAsyncThunk(
   'document/turnToBlock',
   async (payload: { id: string; controller: DocumentController; type: BlockType; data: BlockData<any> }, thunkAPI) => {
@@ -18,19 +26,14 @@ export const turnToBlockThunk = createAsyncThunk(
     const parent = state.nodes[node.parent];
     const children = state.children[node.children].map((id) => state.nodes[id]);
 
-    /**
-     * transform to block
-     * 1. insert block after current block
-     * 2. move all children
-     * 3. delete current block
-     */
-
     const block = newBlock<any>(type, parent.id, data);
     // insert new block after current block
     const insertHeadingAction = controller.getInsertAction(block, node.id);
 
+    // check if prev node is allowed to have children
+    const config = blockConfig[block.type];
     // if new block is not allowed to have children, move children to parent
-    const newParent = allowedChildrenBlockTypes.includes(block.type) ? block : parent;
+    const newParent = config.canAddChild ? block : parent;
     // if move children to parent, set prev to current block, otherwise the prev is empty
     const newPrev = newParent.id === parent.id ? block.id : '';
     const moveChildrenActions = controller.getMoveChildrenAction(children, newParent.id, newPrev);

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

@@ -1,22 +0,0 @@
-import { Editor } from 'slate';
-import { getAfterRangeAt, getBeforeRangeAt } from '$app/utils/document/slate/text';
-import { HeadingBlockData } from '$app/interfaces/document';
-import { getDeltaAfterSelection, getDeltaFromSlateNodes } from '$app/utils/document/blocks/common';
-
-/**
- * get heading data from editor, only support markdown
- * @param editor
- */
-export function getHeadingDataFromEditor(editor: Editor): HeadingBlockData | undefined {
-  const selection = editor.selection;
-  if (!selection) return;
-  const hashTags = Editor.string(editor, getBeforeRangeAt(editor, selection));
-  const level = hashTags.match(/#/g)?.length;
-  if (!level) return;
-  const delta = getDeltaAfterSelection(editor);
-  if (!delta) return;
-  return {
-    level,
-    delta,
-  };
-}

+ 52 - 0
frontend/appflowy_tauri/src/appflowy_app/utils/document/blocks/index.ts

@@ -0,0 +1,52 @@
+import { Editor } from 'slate';
+import { HeadingBlockData, TodoListBlockData } from '$app/interfaces/document';
+import { getAfterRangeAt, getBeforeRangeAt } from '$app/utils/document/slate/text';
+import { getDeltaAfterSelection, getDeltaFromSlateNodes } from '$app/utils/document/blocks/common';
+
+/**
+ * get heading data from editor, only support markdown
+ * @param editor
+ */
+export function getHeadingDataFromEditor(editor: Editor): HeadingBlockData | undefined {
+  const selection = editor.selection;
+  if (!selection) return;
+  const hashTags = Editor.string(editor, getBeforeRangeAt(editor, selection));
+  const level = hashTags.match(/#/g)?.length;
+  if (!level) return;
+  const delta = getDeltaAfterSelection(editor);
+  if (!delta) return;
+  return {
+    level,
+    delta,
+  };
+}
+
+/**
+ * get quote data from editor, only support markdown
+ * @param editor
+ */
+export function getQuoteDataFromEditor(editor: Editor) {
+  const delta = getDeltaAfterSelection(editor);
+  if (!delta) return;
+  return {
+    delta,
+    size: 'default',
+  };
+}
+
+/**
+ * get todo_list data from editor, only support markdown
+ * @param editor
+ */
+export function getTodoListDataFromEditor(editor: Editor): TodoListBlockData | undefined {
+  const selection = editor.selection;
+  if (!selection) return;
+  const hashTags = Editor.string(editor, getBeforeRangeAt(editor, selection));
+  const checked = hashTags.match(/x/g)?.length;
+  const slateNodes = Editor.fragment(editor, getAfterRangeAt(editor, selection));
+  const delta = getDeltaFromSlateNodes(slateNodes);
+  return {
+    delta,
+    checked: !!checked,
+  };
+}

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

@@ -1,11 +0,0 @@
-import { Editor } from 'slate';
-import { getDeltaAfterSelection } from '$app/utils/document/blocks/common';
-
-export function getQuoteDataFromEditor(editor: Editor) {
-  const delta = getDeltaAfterSelection(editor);
-  if (!delta) return;
-  return {
-    delta,
-    size: 'default',
-  };
-}

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

@@ -1,6 +1,16 @@
-import { BlockType, NestedBlock, TextBlockData } from '$app/interfaces/document';
+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());
+}

+ 0 - 21
frontend/appflowy_tauri/src/appflowy_app/utils/document/blocks/todo_list.ts

@@ -1,21 +0,0 @@
-import { Editor } from 'slate';
-import { TodoListBlockData } from '$app/interfaces/document';
-import { getAfterRangeAt, getBeforeRangeAt } from '$app/utils/document/slate/text';
-import { getDeltaFromSlateNodes } from '$app/utils/document/blocks/common';
-
-/**
- * get todo_list data from editor, only support markdown
- * @param editor
- */
-export function getTodoListDataFromEditor(editor: Editor): TodoListBlockData | undefined {
-  const selection = editor.selection;
-  if (!selection) return;
-  const hashTags = Editor.string(editor, getBeforeRangeAt(editor, selection));
-  const checked = hashTags.match(/x/g)?.length;
-  const slateNodes = Editor.fragment(editor, getAfterRangeAt(editor, selection));
-  const delta = getDeltaFromSlateNodes(slateNodes);
-  return {
-    delta,
-    checked: !!checked,
-  };
-}

+ 0 - 39
frontend/appflowy_tauri/src/appflowy_app/utils/document/slate/markdown.ts

@@ -1,39 +0,0 @@
-import { keyBoardEventKeyMap } from '$app/constants/document/text_block';
-import { getBeforeRangeAt } from '$app/utils/document/slate/text';
-import { Editor } from 'slate';
-
-export function canHandleToHeadingBlock(event: React.KeyboardEvent<HTMLDivElement>, editor: Editor): boolean {
-  const flag = getMarkdownFlag(event, editor);
-  if (!flag) return false;
-  const isHeadingMarkdown = /^(#{1,3})$/.test(flag);
-
-  return isHeadingMarkdown;
-}
-
-export function canHandleToCheckboxBlock(event: React.KeyboardEvent<HTMLDivElement>, editor: Editor) {
-  const flag = getMarkdownFlag(event, editor);
-  if (!flag) return false;
-
-  const isCheckboxMarkdown = /^((-)?\[(x|\s)?\])$/.test(flag);
-  return isCheckboxMarkdown;
-}
-
-export function canHandleToQuoteBlock(event: React.KeyboardEvent<HTMLDivElement>, editor: Editor) {
-  const flag = getMarkdownFlag(event, editor);
-  if (!flag) return false;
-
-  const isQuoteMarkdown = /^("|“|”)$/.test(flag);
-
-  return isQuoteMarkdown;
-}
-
-function getMarkdownFlag(event: React.KeyboardEvent<HTMLDivElement>, editor: Editor) {
-  const isSpaceKey = event.key === keyBoardEventKeyMap.Space;
-  const selection = editor.selection;
-
-  if (!isSpaceKey || !selection) {
-    return null;
-  }
-
-  return Editor.string(editor, getBeforeRangeAt(editor, selection)).trim();
-}

+ 1 - 2
frontend/appflowy_tauri/src/appflowy_app/views/DocumentPage.hooks.ts

@@ -41,8 +41,7 @@ export const useDocument = () => {
       }
       Log.debug('close document', params.id);
     };
-    // dispose controller before unload
-    window.addEventListener('beforeunload', closeDocument);
+
     return closeDocument;
   }, [params.id]);
 

+ 4 - 1
frontend/rust-lib/flowy-notification/src/lib.rs

@@ -14,7 +14,10 @@ lazy_static! {
 pub fn register_notification_sender<T: NotificationSender>(sender: T) {
   let box_sender = Box::new(sender);
   match NOTIFICATION_SENDER.write() {
-    Ok(mut write_guard) => write_guard.push(box_sender),
+    Ok(mut write_guard) => {
+      write_guard.pop();
+      write_guard.push(box_sender)
+    },
     Err(err) => tracing::error!("Failed to push notification sender: {:?}", err),
   }
 }