Browse Source

feat: support code block (#2464)

Kilu.He 2 years ago
parent
commit
dad0419da0
29 changed files with 893 additions and 400 deletions
  1. 2 0
      frontend/appflowy_tauri/package.json
  2. 13 0
      frontend/appflowy_tauri/pnpm-lock.yaml
  3. 7 9
      frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSelection/BlockSelection.hooks.tsx
  4. 0 1
      frontend/appflowy_tauri/src/appflowy_app/components/document/CalloutBlock/CalloutBlock.hooks.ts
  5. 88 0
      frontend/appflowy_tauri/src/appflowy_app/components/document/CodeBlock/CodeBlock.hooks.ts
  6. 44 0
      frontend/appflowy_tauri/src/appflowy_app/components/document/CodeBlock/SelectLanguage.tsx
  7. 43 0
      frontend/appflowy_tauri/src/appflowy_app/components/document/CodeBlock/elements.tsx
  8. 38 2
      frontend/appflowy_tauri/src/appflowy_app/components/document/CodeBlock/index.tsx
  9. 1 1
      frontend/appflowy_tauri/src/appflowy_app/components/document/DocumentTitle/index.tsx
  10. 12 5
      frontend/appflowy_tauri/src/appflowy_app/components/document/Node/index.tsx
  11. 3 20
      frontend/appflowy_tauri/src/appflowy_app/components/document/TextBlock/TextBlock.hooks.ts
  12. 0 75
      frontend/appflowy_tauri/src/appflowy_app/components/document/TextBlock/events/Actions.hooks.ts
  13. 56 66
      frontend/appflowy_tauri/src/appflowy_app/components/document/TextBlock/events/Events.hooks.ts
  14. 13 3
      frontend/appflowy_tauri/src/appflowy_app/components/document/TextBlock/events/TurnIntoEvents.hooks.ts
  15. 2 2
      frontend/appflowy_tauri/src/appflowy_app/components/document/TextBlock/index.tsx
  16. 44 52
      frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/Text/TextInput.hooks.ts
  17. 103 0
      frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/Text/useTextEvents.ts
  18. 92 0
      frontend/appflowy_tauri/src/appflowy_app/constants/document/code.ts
  19. 4 0
      frontend/appflowy_tauri/src/appflowy_app/constants/document/config.ts
  20. 1 0
      frontend/appflowy_tauri/src/appflowy_app/constants/document/text_block.ts
  21. 16 83
      frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/text/backspace.ts
  22. 85 0
      frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/text/merge.ts
  23. 7 5
      frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/text/split.ts
  24. 95 0
      frontend/appflowy_tauri/src/appflowy_app/utils/document/blocks/code/decorate.ts
  25. 34 0
      frontend/appflowy_tauri/src/appflowy_app/utils/document/blocks/code/index.ts
  26. 1 10
      frontend/appflowy_tauri/src/appflowy_app/utils/document/blocks/common.ts
  27. 14 2
      frontend/appflowy_tauri/src/appflowy_app/utils/document/blocks/index.ts
  28. 74 0
      frontend/appflowy_tauri/src/appflowy_app/utils/document/blocks/text/delta.ts
  29. 1 64
      frontend/appflowy_tauri/src/appflowy_app/utils/document/blocks/text/hotkey.ts

+ 2 - 0
frontend/appflowy_tauri/package.json

@@ -34,6 +34,7 @@
     "is-hotkey": "^0.2.0",
     "jest": "^29.5.0",
     "nanoid": "^4.0.0",
+    "prismjs": "^1.29.0",
     "protoc-gen-ts": "^0.8.5",
     "react": "^18.2.0",
     "react-beautiful-dnd": "^13.1.1",
@@ -58,6 +59,7 @@
     "@types/google-protobuf": "^3.15.6",
     "@types/is-hotkey": "^0.1.7",
     "@types/node": "^18.7.10",
+    "@types/prismjs": "^1.26.0",
     "@types/react": "^18.0.15",
     "@types/react-beautiful-dnd": "^13.1.3",
     "@types/react-dom": "^18.0.6",

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

@@ -15,6 +15,7 @@ specifiers:
   '@types/google-protobuf': ^3.15.6
   '@types/is-hotkey': ^0.1.7
   '@types/node': ^18.7.10
+  '@types/prismjs': ^1.26.0
   '@types/react': ^18.0.15
   '@types/react-beautiful-dnd': ^13.1.3
   '@types/react-dom': ^18.0.6
@@ -39,6 +40,7 @@ specifiers:
   postcss: ^8.4.21
   prettier: 2.8.4
   prettier-plugin-tailwindcss: ^0.2.2
+  prismjs: ^1.29.0
   protoc-gen-ts: ^0.8.5
   react: ^18.2.0
   react-beautiful-dnd: ^13.1.1
@@ -82,6 +84,7 @@ dependencies:
   is-hotkey: 0.2.0
   jest: 29.5.0_@[email protected]
   nanoid: 4.0.1
+  prismjs: 1.29.0
   protoc-gen-ts: 0.8.6_ss7alqtodw6rv4lluxhr36xjoa
   react: 18.2.0
   react-beautiful-dnd: 13.1.1_biqbaboplfbrettd7655fr4n2y
@@ -106,6 +109,7 @@ devDependencies:
   '@types/google-protobuf': 3.15.6
   '@types/is-hotkey': 0.1.7
   '@types/node': 18.14.6
+  '@types/prismjs': 1.26.0
   '@types/react': 18.0.28
   '@types/react-beautiful-dnd': 13.1.4
   '@types/react-dom': 18.0.11
@@ -1564,6 +1568,10 @@ packages:
     resolution: {integrity: sha512-KufADq8uQqo1pYKVIYzfKbJfBAc0sOeXqGbFaSpv8MRmC/zXgowNZmFcbngndGk922QDmOASEXUZCaY48gs4cg==}
     dev: false
 
+  /@types/prismjs/1.26.0:
+    resolution: {integrity: sha512-ZTaqn/qSqUuAq1YwvOFQfVW1AR/oQJlLSZVustdjwI+GZ8kr0MSHBj0tsXPW1EqHubx50gtBEjbPGsdZwQwCjQ==}
+    dev: true
+
   /@types/prop-types/15.7.5:
     resolution: {integrity: sha512-JCB8C6SnDoQf0cNycqd/35A7MjcnK+ZTqE7judS6o7utxUCg6imJg3QK2qzHKszlTjcj2cn+NwMB2i96ubpj7w==}
 
@@ -4170,6 +4178,11 @@ packages:
       react-is: 18.2.0
     dev: false
 
+  /prismjs/1.29.0:
+    resolution: {integrity: sha512-Kx/1w86q/epKcmte75LNrEoT+lX8pBpavuAbvJWRXar7Hz8jrtF+e3vY751p0R8H9HdArwaCTNDDzHg/ScJK1Q==}
+    engines: {node: '>=6'}
+    dev: false
+
   /prompts/2.4.2:
     resolution: {integrity: sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==}
     engines: {node: '>= 6'}

+ 7 - 9
frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSelection/BlockSelection.hooks.tsx

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

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

@@ -22,7 +22,6 @@ export function useCalloutBlock(nodeId: string) {
   const onEmojiSelect = useCallback(
     (emoji: { native: string }) => {
       if (!controller) return;
-      console.log('emoji', emoji.native);
       void dispatch(
         updateNodeDataThunk({
           id: nodeId,

+ 88 - 0
frontend/appflowy_tauri/src/appflowy_app/components/document/CodeBlock/CodeBlock.hooks.ts

@@ -0,0 +1,88 @@
+import { useTextInput } from '$app/components/document/_shared/Text/TextInput.hooks';
+import isHotkey from 'is-hotkey';
+import { useCallback, useContext, useMemo } from 'react';
+import { Editor } from 'slate';
+import { BlockType, NestedBlock, 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 { splitNodeThunk } from '$app_reducers/document/async-actions';
+import { useDefaultTextInputEvents } from '$app/components/document/_shared/Text/useTextEvents';
+import { indent, outdent } from '$app/utils/document/blocks/code';
+
+export function useCodeBlock(node: NestedBlock<BlockType.CodeBlock>) {
+  const id = node.id;
+  const dispatch = useAppDispatch();
+  const controller = useContext(DocumentControllerContext);
+  const { editor, onChange, value, onDOMBeforeInput } = useTextInput(id);
+  const defaultTextInputEvents = useDefaultTextInputEvents(id);
+
+  const customEvents = useMemo(() => {
+    return [
+      {
+        // Here custom tab key event for TextBlock to insert 2 spaces
+        triggerEventKey: keyBoardEventKeyMap.Tab,
+        canHandle: (...args: TextBlockKeyEventHandlerParams) => isHotkey('tab', args[0]),
+        handler: (...args: TextBlockKeyEventHandlerParams) => {
+          const [e, editor] = args;
+          e.preventDefault();
+          indent(editor, 2);
+        },
+      },
+      {
+        // Here custom shift+tab key event for TextBlock to delete 2 spaces
+        triggerEventKey: keyBoardEventKeyMap.Tab,
+        canHandle: (...args: TextBlockKeyEventHandlerParams) => isHotkey('shift+tab', args[0]),
+        handler: (...args: TextBlockKeyEventHandlerParams) => {
+          const [e, editor] = args;
+          e.preventDefault();
+          outdent(editor, 2);
+        },
+      },
+      {
+        // Here custom enter key event for TextBlock
+        triggerEventKey: keyBoardEventKeyMap.Enter,
+        canHandle: (...args: TextBlockKeyEventHandlerParams) => isHotkey('enter', args[0]),
+        handler: (...args: TextBlockKeyEventHandlerParams) => {
+          const [e, editor] = args;
+          e.preventDefault();
+          Editor.insertText(editor, '\n');
+        },
+      },
+      {
+        // Here custom shift+enter key event for TextBlock
+        triggerEventKey: keyBoardEventKeyMap.Enter,
+        canHandle: (...args: TextBlockKeyEventHandlerParams) => isHotkey('shift+enter', args[0]),
+        handler: (...args: TextBlockKeyEventHandlerParams) => {
+          const [e, editor] = args;
+          e.preventDefault();
+          void (async () => {
+            if (!controller) return;
+            await dispatch(splitNodeThunk({ id, controller, editor }));
+          })();
+        },
+      },
+    ];
+  }, [controller, dispatch, id]);
+
+  const onKeyDown = useCallback<React.KeyboardEventHandler<HTMLDivElement>>(
+    (e) => {
+      const keyEvents = [...defaultTextInputEvents, ...customEvents];
+      keyEvents.forEach((keyEvent) => {
+        // Here we check if the key event can be handled by the current key event
+        if (keyEvent.canHandle(e, editor)) {
+          keyEvent.handler(e, editor);
+        }
+      });
+    },
+    [defaultTextInputEvents, customEvents, editor]
+  );
+
+  return {
+    editor,
+    onKeyDown,
+    onChange,
+    value,
+    onDOMBeforeInput,
+  };
+}

+ 44 - 0
frontend/appflowy_tauri/src/appflowy_app/components/document/CodeBlock/SelectLanguage.tsx

@@ -0,0 +1,44 @@
+import React, { useCallback, useContext } from 'react';
+import MenuItem from '@mui/material/MenuItem';
+import FormControl from '@mui/material/FormControl';
+import Select, { SelectChangeEvent } from '@mui/material/Select';
+import { updateNodeDataThunk } from '$app_reducers/document/async-actions';
+import { useAppDispatch } from '$app/stores/store';
+import { DocumentControllerContext } from '$app/stores/effects/document/document_controller';
+import { supportLanguage } from '$app/constants/document/code';
+
+function SelectLanguage({ id, language }: { id: string; language: string }) {
+  const dispatch = useAppDispatch();
+  const controller = useContext(DocumentControllerContext);
+
+  const onLanguageSelect = useCallback(
+    (event: SelectChangeEvent) => {
+      if (!controller) return;
+      const language = event.target.value;
+      dispatch(
+        updateNodeDataThunk({
+          id,
+          controller,
+          data: {
+            language,
+          },
+        })
+      );
+    },
+    [controller, dispatch, id]
+  );
+
+  return (
+    <FormControl variant='standard'>
+      <Select className={'h-[28px] w-[150px]'} value={language} onChange={onLanguageSelect} label='Language'>
+        {supportLanguage.map((item) => (
+          <MenuItem key={item.id} value={item.id}>
+            {item.title}
+          </MenuItem>
+        ))}
+      </Select>
+    </FormControl>
+  );
+}
+
+export default SelectLanguage;

+ 43 - 0
frontend/appflowy_tauri/src/appflowy_app/components/document/CodeBlock/elements.tsx

@@ -0,0 +1,43 @@
+import { RenderLeafProps, RenderElementProps } from 'slate-react';
+import { BaseText } from 'slate';
+
+interface CodeLeafProps extends RenderLeafProps {
+  leaf: BaseText & {
+    bold?: boolean;
+    italic?: boolean;
+    underlined?: boolean;
+    strikethrough?: boolean;
+    prism_token?: string;
+  };
+}
+
+export const CodeLeaf = (props: CodeLeafProps) => {
+  const { attributes, children, leaf } = props;
+
+  let newChildren = children;
+  if (leaf.bold) {
+    newChildren = <strong>{children}</strong>;
+  }
+
+  if (leaf.italic) {
+    newChildren = <em>{newChildren}</em>;
+  }
+
+  if (leaf.underlined) {
+    newChildren = <u>{newChildren}</u>;
+  }
+
+  return (
+    <span {...attributes} className={`token ${leaf.prism_token} ${leaf.strikethrough ? `line-through` : ''}`}>
+      {newChildren}
+    </span>
+  );
+};
+
+export const CodeBlockElement = (props: RenderElementProps) => {
+  return (
+    <pre className='code-block-element' {...props.attributes}>
+      <code>{props.children}</code>
+    </pre>
+  );
+};

+ 38 - 2
frontend/appflowy_tauri/src/appflowy_app/components/document/CodeBlock/index.tsx

@@ -1,3 +1,39 @@
-export default function CodeBlock({ id }: { id: string }) {
-  return <div>{id}</div>;
+import { BlockType, NestedBlock } from '$app/interfaces/document';
+import { useCodeBlock } from './CodeBlock.hooks';
+import { Editable, Slate } from 'slate-react';
+import BlockHorizontalToolbar from '$app/components/document/BlockHorizontalToolbar';
+import React from 'react';
+import { CodeLeaf, CodeBlockElement } from './elements';
+import SelectLanguage from './SelectLanguage';
+import { decorateCodeFunc } from '$app/utils/document/blocks/code/decorate';
+
+export default function CodeBlock({
+  node,
+  placeholder,
+  ...props
+}: { node: NestedBlock<BlockType.CodeBlock>; placeholder?: string } & React.HTMLAttributes<HTMLDivElement>) {
+  const { editor, value, onChange, onKeyDown, onDOMBeforeInput } = useCodeBlock(node);
+
+  const className = props.className ? ` ${props.className}` : '';
+  const id = node.id;
+  const language = node.data.language;
+  return (
+    <div {...props} className={`rounded bg-shade-6 p-6 ${className}`}>
+      <div className={'mb-2 w-[100%]'}>
+        <SelectLanguage id={id} language={language} />
+      </div>
+      <Slate editor={editor} onChange={onChange} value={value}>
+        <BlockHorizontalToolbar id={id} />
+
+        <Editable
+          onKeyDown={onKeyDown}
+          decorate={(entry) => decorateCodeFunc(entry, language)}
+          onDOMBeforeInput={onDOMBeforeInput}
+          renderLeaf={CodeLeaf}
+          renderElement={CodeBlockElement}
+          placeholder={placeholder || 'Please enter some text...'}
+        />
+      </Slate>
+    </div>
+  );
 }

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

@@ -8,7 +8,7 @@ export default function DocumentTitle({ id }: { id: string }) {
   if (!node) return null;
   return (
     <NodeContext.Provider value={node}>
-      <div data-block-id={node.id} className='doc-title relative mb-2 px-1 pt-[50px] text-4xl font-bold'>
+      <div data-block-id={node.id} className='doc-title relative mb-2 pt-[50px] text-4xl font-bold'>
         <TextBlock placeholder='Untitled' childIds={[]} node={node} />
       </div>
     </NodeContext.Provider>

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

@@ -15,6 +15,7 @@ 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';
+import CodeBlock from '$app/components/document/CodeBlock';
 
 function NodeComponent({ id, ...props }: { id: string } & React.HTMLAttributes<HTMLDivElement>) {
   const { node, childIds, isSelected, ref } = useNode(id);
@@ -48,12 +49,10 @@ function NodeComponent({ id, ...props }: { id: string } & React.HTMLAttributes<H
       case BlockType.CalloutBlock: {
         return <CalloutBlock node={node} childIds={childIds} />;
       }
+      case BlockType.CodeBlock:
+        return <CodeBlock node={node} />;
       default:
-        return (
-          <Alert severity='info' className='mb-2'>
-            <p>The current version does not support this Block.</p>
-          </Alert>
-        );
+        return <UnSupportedBlock />;
     }
   }, [node, childIds]);
 
@@ -76,4 +75,12 @@ const NodeWithErrorBoundary = withErrorBoundary(NodeComponent, {
   FallbackComponent: ErrorBoundaryFallbackComponent,
 });
 
+const UnSupportedBlock = () => {
+  return (
+    <Alert severity='info' className='mb-2'>
+      <p>The current version does not support this Block.</p>
+    </Alert>
+  );
+};
+
 export default React.memo(NodeWithErrorBoundary);

+ 3 - 20
frontend/appflowy_tauri/src/appflowy_app/components/document/TextBlock/TextBlock.hooks.ts

@@ -1,30 +1,13 @@
-import { useCallback } from 'react';
-import { useTextInput } from '../_shared/TextInput.hooks';
+import { useTextInput } from '../_shared/Text/TextInput.hooks';
 import { useTextBlockKeyEvent } from '$app/components/document/TextBlock/events/Events.hooks';
 
 export function useTextBlock(id: string) {
-  const { editor, onChange, value } = useTextInput(id);
+  const { editor, onChange, value, onDOMBeforeInput } = useTextInput(id);
   const { onKeyDown } = useTextBlockKeyEvent(id, editor);
 
-  const onKeyDownCapture = useCallback(
-    (event: React.KeyboardEvent<HTMLDivElement>) => {
-      onKeyDown(event);
-    },
-    [onKeyDown]
-  );
-
-  const onDOMBeforeInput = useCallback((e: InputEvent) => {
-    // COMPAT: in Apple, `compositionend` is dispatched after the `beforeinput` for "insertFromComposition".
-    // It will cause repeated characters when inputting Chinese.
-    // Here, prevent the beforeInput event and wait for the compositionend event to take effect.
-    if (e.inputType === 'insertFromComposition') {
-      e.preventDefault();
-    }
-  }, []);
-
   return {
     onChange,
-    onKeyDownCapture,
+    onKeyDown,
     onDOMBeforeInput,
     editor,
     value,

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

@@ -1,75 +0,0 @@
-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,
-  };
-}

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

@@ -1,93 +1,85 @@
 import { Editor } from 'slate';
 import { useTurnIntoBlock } from './TurnIntoEvents.hooks';
-import { useCallback, useMemo } from 'react';
+import { useCallback, useContext, 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/blocks/text/hotkey';
+import { triggerHotkey } from '$app/utils/document/blocks/text/hotkey';
 import { TextBlockKeyEventHandlerParams } from '$app/interfaces/document';
-import { useActions } from './Actions.hooks';
+import isHotkey from 'is-hotkey';
+import { indentNodeThunk, outdentNodeThunk, splitNodeThunk } from '$app_reducers/document/async-actions';
+import { DocumentControllerContext } from '$app/stores/effects/document/document_controller';
+import { useAppDispatch } from '$app/stores/store';
+import { useDefaultTextInputEvents } from '$app/components/document/_shared/Text/useTextEvents';
 
 export function useTextBlockKeyEvent(id: string, editor: Editor) {
-  const { indentAction, backSpaceAction, splitAction, wrapAction, focusPreLineAction, focusNextLineAction } =
-    useActions(id);
+  const controller = useContext(DocumentControllerContext);
+  const dispatch = useAppDispatch();
+
+  const defaultTextInputEvents = useDefaultTextInputEvents(id);
 
   const { turnIntoBlockEvents } = useTurnIntoBlock(id);
 
-  const events = useMemo(() => {
-    return [
+  // Here custom key events for TextBlock
+  const events = useMemo(
+    () => [
+      ...defaultTextInputEvents,
       {
+        // Here custom enter key event for TextBlock
         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,
+        canHandle: (...args: TextBlockKeyEventHandlerParams) => isHotkey('enter', args[0]),
         handler: (...args: TextBlockKeyEventHandlerParams) => {
-          void focusPreLineAction({
-            editor: args[1],
-          });
+          const [e, editor] = args;
+          e.preventDefault();
+          void (async () => {
+            if (!controller) return;
+            await dispatch(splitNodeThunk({ id, controller, editor }));
+          })();
         },
       },
       {
-        triggerEventKey: keyBoardEventKeyMap.Down,
-        canHandle: canHandleDownKey,
+        // Here custom shift+enter key event for TextBlock
+        triggerEventKey: keyBoardEventKeyMap.Enter,
+        canHandle: (...args: TextBlockKeyEventHandlerParams) => isHotkey('shift+enter', args[0]),
         handler: (...args: TextBlockKeyEventHandlerParams) => {
-          void focusNextLineAction({
-            editor: args[1],
-          });
+          const [e, editor] = args;
+          e.preventDefault();
+          Editor.insertText(editor, '\n');
         },
       },
       {
-        triggerEventKey: keyBoardEventKeyMap.Left,
-        canHandle: canHandleLeftKey,
+        // Here custom tab key event for TextBlock
+        triggerEventKey: keyBoardEventKeyMap.Tab,
+        canHandle: (...args: TextBlockKeyEventHandlerParams) => isHotkey('tab', args[0]),
         handler: (...args: TextBlockKeyEventHandlerParams) => {
-          void focusPreLineAction({
-            editor: args[1],
-            focusEnd: true,
-          });
+          const [e, _] = args;
+          e.preventDefault();
+          if (!controller) return;
+          dispatch(
+            indentNodeThunk({
+              id,
+              controller,
+            })
+          );
         },
       },
       {
-        triggerEventKey: keyBoardEventKeyMap.Right,
-        canHandle: canHandleRightKey,
+        // Here custom shift+tab key event for TextBlock
+        triggerEventKey: keyBoardEventKeyMap.Tab,
+        canHandle: (...args: TextBlockKeyEventHandlerParams) => isHotkey('shift+tab', args[0]),
         handler: (...args: TextBlockKeyEventHandlerParams) => {
-          void focusNextLineAction({
-            editor: args[1],
-            focusStart: true,
-          });
+          const [e, _] = args;
+          e.preventDefault();
+          if (!controller) return;
+          dispatch(
+            outdentNodeThunk({
+              id,
+              controller,
+            })
+          );
         },
       },
-    ];
-  }, [splitAction, wrapAction, indentAction, backSpaceAction, focusPreLineAction, focusNextLineAction]);
+    ],
+    [defaultTextInputEvents, controller, dispatch, id]
+  );
 
   const onKeyDown = useCallback(
     (event: React.KeyboardEvent<HTMLDivElement>) => {
@@ -100,8 +92,6 @@ export function useTextBlockKeyEvent(id: string, editor: Editor) {
         return;
       }
 
-      event.stopPropagation();
-      event.preventDefault();
       matchKeys.forEach((matchKey) => matchKey.handler(event, editor));
     },
     [editor, events, turnIntoBlockEvents]

+ 13 - 3
frontend/appflowy_tauri/src/appflowy_app/components/document/TextBlock/events/TurnIntoEvents.hooks.ts

@@ -1,12 +1,12 @@
 import { useContext, useMemo } from 'react';
-import { BlockData, BlockType, TextBlockKeyEventHandlerParams } from '$app/interfaces/document';
+import { 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, turnToDividerBlockThunk } from '$app_reducers/document/async-actions';
 import { blockConfig } from '$app/constants/document/config';
 import { Editor } from 'slate';
-import { getBeforeRangeAt } from '$app/utils/document/blocks/text/delta';
+import { getBeforeRangeAt, getDeltaAfterSelection } from '$app/utils/document/blocks/text/delta';
 import {
   getHeadingDataFromEditor,
   getQuoteDataFromEditor,
@@ -15,8 +15,8 @@ import {
   getNumberedListDataFromEditor,
   getToggleListDataFromEditor,
   getCalloutDataFromEditor,
+  getCodeBlockDataFromEditor,
 } from '$app/utils/document/blocks';
-import { getDeltaAfterSelection } from '$app/utils/document/blocks/common';
 
 export function useTurnIntoBlock(id: string) {
   const controller = useContext(DocumentControllerContext);
@@ -58,6 +58,16 @@ export function useTurnIntoBlock(id: string) {
           dispatch(turnToDividerBlockThunk({ id, controller, delta }));
         },
       },
+      {
+        triggerEventKey: keyBoardEventKeyMap.Backquote,
+        canHandle: canHandle(BlockType.CodeBlock, keyBoardEventKeyMap.Backquote),
+        handler: (...args: TextBlockKeyEventHandlerParams) => {
+          if (!controller) return;
+          const [_event, editor] = args;
+          const data = getCodeBlockDataFromEditor(editor);
+          dispatch(turnToBlockThunk({ id, data, type: BlockType.CodeBlock, controller }));
+        },
+      },
     ];
   }, [controller, dispatch, id]);
 

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

@@ -16,7 +16,7 @@ function TextBlock({
   childIds?: string[];
   placeholder?: string;
 } & React.HTMLAttributes<HTMLDivElement>) {
-  const { editor, value, onChange, onKeyDownCapture, onDOMBeforeInput } = useTextBlock(node.id);
+  const { editor, value, onChange, onKeyDown, onDOMBeforeInput } = useTextBlock(node.id);
   const className = props.className !== undefined ? ` ${props.className}` : '';
 
   return (
@@ -25,7 +25,7 @@ function TextBlock({
         <Slate editor={editor} onChange={onChange} value={value}>
           <BlockHorizontalToolbar id={node.id} />
           <Editable
-            onKeyDownCapture={onKeyDownCapture}
+            onKeyDown={onKeyDown}
             onDOMBeforeInput={onDOMBeforeInput}
             renderLeaf={(leafProps) => <Leaf {...leafProps} />}
             placeholder={placeholder || 'Please enter some text...'}

+ 44 - 52
frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/TextInput.hooks.ts → frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/Text/TextInput.hooks.ts

@@ -1,46 +1,61 @@
 import { createEditor, Descendant, Transforms } from 'slate';
-import { withReact, ReactEditor } from 'slate-react';
+import { ReactEditor, withReact } from 'slate-react';
 import * as Y from 'yjs';
-import { withYjs, YjsEditor, slateNodesToInsertDelta } from '@slate-yjs/core';
-import { useCallback, useContext, useMemo, useRef, useEffect, useState } from 'react';
+import { slateNodesToInsertDelta, withYjs, YjsEditor } from '@slate-yjs/core';
+import { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
 
 import { DocumentControllerContext } from '$app/stores/effects/document/document_controller';
 import { TextDelta, TextSelection } from '$app/interfaces/document';
-import { NodeContext } from './SubscribeNode.hooks';
-import { useAppDispatch, useAppSelector } from '@/appflowy_app/stores/store';
+import { NodeContext } from '../SubscribeNode.hooks';
+import { useAppDispatch, useAppSelector } from '$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 { deltaToSlateValue } from '$app/utils/document/blocks/common';
+import { documentActions } from '$app_reducers/document/slice';
 
 import { isSameDelta } from '$app/utils/document/blocks/text/delta';
 
 export function useTextInput(id: string) {
   const dispatch = useAppDispatch();
   const node = useContext(NodeContext);
+  const selectionRef = useRef<TextSelection | null>(null);
 
   const delta = useMemo(() => {
     if (!node || !('delta' in node.data)) {
       return [];
     }
     return node.data.delta;
-  }, [node?.data]);
+  }, [node]);
 
   const { editor, yText } = useBindYjs(id, delta);
 
   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)) return;
+    if (!ReactEditor.isFocused(editor)) {
+      selectionRef.current = null;
+      return;
+    }
+
     const selection = editor.selection as TextSelection;
+    if (selectionRef.current && JSON.stringify(selection) !== JSON.stringify(selectionRef.current)) {
+      Transforms.select(editor, selectionRef.current);
+      selectionRef.current = null;
+    }
+
     dispatch(documentActions.setTextSelection({ blockId: id, selection }));
-  }, [editor]);
+  }, [dispatch, editor, id]);
 
   const currentSelection = useAppSelector((state) => state.document.textSelections[id]);
   const restoreSelection = useCallback(() => {
-    setSelection(editor, currentSelection);
-  }, [editor, currentSelection]);
+    if (!currentSelection) return;
+    if (ReactEditor.isFocused(editor)) {
+      Transforms.select(editor, currentSelection);
+    } else {
+      selectionRef.current = currentSelection;
+      Transforms.select(editor, currentSelection);
+      ReactEditor.focus(editor);
+    }
+  }, [currentSelection, editor]);
 
   const onChange = useCallback(
     (e: Descendant[]) => {
@@ -55,7 +70,7 @@ export function useTextInput(id: string) {
     return () => {
       dispatch(documentActions.removeTextSelection(id));
     };
-  }, [id, restoreSelection]);
+  }, [dispatch, id, restoreSelection]);
 
   if (editor.selection && ReactEditor.isFocused(editor)) {
     const domSelection = window.getSelection();
@@ -66,11 +81,21 @@ export function useTextInput(id: string) {
     }
   }
 
+  const onDOMBeforeInput = useCallback((e: InputEvent) => {
+    // COMPAT: in Apple, `compositionend` is dispatched after the `beforeinput` for "insertFromComposition".
+    // It will cause repeated characters when inputting Chinese.
+    // Here, prevent the beforeInput event and wait for the compositionend event to take effect.
+    if (e.inputType === 'insertFromComposition') {
+      e.preventDefault();
+    }
+  }, []);
+
   return {
     editor,
     yText,
     onChange,
     value,
+    onDOMBeforeInput,
   };
 }
 function useBindYjs(id: string, delta: TextDelta[]) {
@@ -90,6 +115,8 @@ function useBindYjs(id: string, delta: TextDelta[]) {
     yTextRef.current = yText;
 
     return _sharedType;
+    // Here we only want to create the sharedType once
+    // eslint-disable-next-line react-hooks/exhaustive-deps
   }, []);
 
   const editor = useMemo(() => withYjs(withReact(createEditor()), sharedType), []);
@@ -116,8 +143,6 @@ function useBindYjs(id: string, delta: TextDelta[]) {
     };
   }, [sendDelta]);
 
-  const currentSelection = useAppSelector((state) => state.document.textSelections[id]);
-
   useEffect(() => {
     const yText = yTextRef.current;
     if (!yText) return;
@@ -128,9 +153,7 @@ function useBindYjs(id: string, delta: TextDelta[]) {
 
     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]);
+  }, [delta, editor]);
 
   return { editor, yText: yTextRef.current };
 }
@@ -150,41 +173,10 @@ function useController(id: string) {
         })
       );
     },
-    [docController, id]
+    [dispatch, docController, id]
   );
 
   return {
     sendDelta,
   };
 }
-
-function setSelection(editor: ReactEditor, currentSelection: TextSelection) {
-  // If the current selection is empty, blur the editor and deselect the selection
-  if (!currentSelection || !currentSelection.anchor || !currentSelection.focus) {
-    if (ReactEditor.isFocused(editor)) {
-      ReactEditor.blur(editor);
-    }
-    return;
-  }
-
-  // If the editor is focused and the current selection is the same as the editor's selection, no need to set the selection
-  if (ReactEditor.isFocused(editor) && JSON.stringify(currentSelection) === JSON.stringify(editor.selection)) {
-    return;
-  }
-
-  const { path, offset } = currentSelection.focus;
-  const children = getDeltaFromSlateNodes(editor.children);
-
-  // the path always has 2 elements,
-  // 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) {
-    return;
-  }
-
-  // the order of the following two lines is important
-  // if we reverse the order, the selection will be lost or always at the start
-  Transforms.select(editor, currentSelection);
-  ReactEditor.focus(editor);
-}

+ 103 - 0
frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/Text/useTextEvents.ts

@@ -0,0 +1,103 @@
+import { useAppDispatch } from '$app/stores/store';
+import { useCallback, useContext } from 'react';
+import { DocumentControllerContext } from '$app/stores/effects/document/document_controller';
+import { Editor } from 'slate';
+import { backspaceNodeThunk, setCursorNextLineThunk, setCursorPreLineThunk } from '$app_reducers/document/async-actions';
+import { keyBoardEventKeyMap } from '$app/constants/document/text_block';
+import {
+  canHandleBackspaceKey,
+  canHandleDownKey,
+  canHandleLeftKey,
+  canHandleRightKey,
+  canHandleUpKey,
+} from '$app/utils/document/blocks/text/hotkey';
+import { TextBlockKeyEventHandlerParams } from '$app/interfaces/document';
+
+export function useDefaultTextInputEvents(id: string) {
+  const dispatch = useAppDispatch();
+  const controller = useContext(DocumentControllerContext);
+
+  const focusPreLineAction = useCallback(
+    async (params: { editor: Editor; focusEnd?: boolean }) => {
+      await dispatch(setCursorPreLineThunk({ id, ...params }));
+    },
+    [dispatch, id]
+  );
+
+  const focusNextLineAction = useCallback(
+    async (params: { editor: Editor; focusStart?: boolean }) => {
+      await dispatch(setCursorNextLineThunk({ id, ...params }));
+    },
+    [dispatch, id]
+  );
+  return [
+    {
+      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,
+        });
+      },
+    },
+    {
+      triggerEventKey: keyBoardEventKeyMap.Backspace,
+      canHandle: canHandleBackspaceKey,
+      handler: (...args: TextBlockKeyEventHandlerParams) => {
+        const [e, _] = args;
+        e.preventDefault();
+        void (async () => {
+          if (!controller) return;
+          await dispatch(backspaceNodeThunk({ id, controller }));
+        })();
+      },
+    },
+    // Here prevent the default behavior of the enter key
+    {
+      triggerEventKey: keyBoardEventKeyMap.Enter,
+      canHandle: (...args: TextBlockKeyEventHandlerParams) => args[0].key === 'Enter',
+      handler: (...args: TextBlockKeyEventHandlerParams) => {
+        const [e] = args;
+        e.preventDefault();
+      },
+    },
+    // Here prevent the default behavior of the tab key
+    {
+      triggerEventKey: keyBoardEventKeyMap.Tab,
+      canHandle: (...args: TextBlockKeyEventHandlerParams) => args[0].key === 'Tab',
+      handler: (...args: TextBlockKeyEventHandlerParams) => {
+        const [e] = args;
+        e.preventDefault();
+      },
+    },
+  ];
+}

+ 92 - 0
frontend/appflowy_tauri/src/appflowy_app/constants/document/code.ts

@@ -0,0 +1,92 @@
+export const supportLanguage = [
+  {
+    id: 'css',
+    title: 'CSS',
+  },
+  {
+    id: 'html',
+    title: 'HTML',
+  },
+  {
+    id: 'javascript',
+    title: 'JavaScript',
+  },
+  {
+    id: 'json',
+    title: 'JSON',
+  },
+  {
+    id: 'markdown',
+    title: 'Markdown',
+  },
+  {
+    id: 'python',
+    title: 'Python',
+  },
+  {
+    id: 'typescript',
+    title: 'TypeScript',
+  },
+  {
+    id: 'xml',
+    title: 'XML',
+  },
+  {
+    id: 'yaml',
+    title: 'YAML',
+  },
+  {
+    id: 'bash',
+    title: 'Bash',
+  },
+  {
+    id: 'c',
+    title: 'C',
+  },
+  {
+    id: 'cpp',
+    title: 'C++',
+  },
+  {
+    id: 'csharp',
+    title: 'C#',
+  },
+  {
+    id: 'go',
+    title: 'Go',
+  },
+  {
+    id: 'java',
+    title: 'Java',
+  },
+
+  {
+    id: 'php',
+    title: 'PHP',
+  },
+  {
+    id: 'ruby',
+    title: 'Ruby',
+  },
+  {
+    id: 'rust',
+    title: 'Rust',
+  },
+
+  {
+    id: 'swift',
+    title: 'Swift',
+  },
+  {
+    id: 'sql',
+    title: 'SQL',
+  },
+  {
+    id: 'vb',
+    title: 'Visual Basic',
+  },
+  {
+    id: 'dart',
+    title: 'Dart',
+  },
+];

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

@@ -161,6 +161,10 @@ export const blockConfig: Record<
 
   [BlockType.CodeBlock]: {
     canAddChild: false,
+    defaultData: {
+      delta: [],
+      language: 'javascript',
+    },
     /**
      * ```
      */

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

@@ -8,4 +8,5 @@ export const keyBoardEventKeyMap = {
   Right: 'ArrowRight',
   Space: ' ',
   Reduce: '-',
+  Backquote: '`',
 };

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

@@ -1,75 +1,17 @@
 import { BlockType, DocumentState } 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 { outdentNodeThunk } from './outdent';
-import { setCursorAfterThunk } from '../../cursor';
-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',
-  async (payload: { id: string; composeId: string; controller: DocumentController }, thunkAPI) => {
-    const { id, composeId, controller } = payload;
-    const { dispatch, getState } = thunkAPI;
-    const state = (getState() as { document: DocumentState }).document;
-    const node = state.nodes[id];
-
-    const composeNode = state.nodes[composeId];
-    // set cursor in compose node end
-    // It must be stored before update, for the cursor can be restored after update
-    await dispatch(setCursorAfterThunk({ id: composeId }));
-
-    // merge delta and update
-    const nodeDelta = node.data?.delta || [];
-    const composeDelta = composeNode.data?.delta || [];
-    const newNode = {
-      ...composeNode,
-      data: {
-        ...composeNode.data,
-        delta: [...composeDelta, ...nodeDelta],
-      },
-    };
-    const updateAction = controller.getUpdateAction(newNode);
-
-    // move children
-    const children = state.children[node.children].map((id) => state.nodes[id]);
-    const moveActions = controller.getMoveChildrenAction(children, newNode.id, '');
-
-    // delete node
-    const deleteAction = controller.getDeleteAction(node);
-
-    // move must be before delete
-    await controller.applyActions([...moveActions, deleteAction, updateAction]);
-    // update local node data
-    dispatch(documentActions.updateNodeData({ id: newNode.id, data: { delta: newNode.data.delta } }));
-  }
-);
-
-const composeParentThunk = createAsyncThunk(
-  'document/composeParent',
-  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.parent) return;
-    await dispatch(composeNodeThunk({ id: id, composeId: node.parent, controller }));
-  }
-);
-
-const composePrevNodeThunk = createAsyncThunk(
-  'document/composePrevNode',
-  async (payload: { prevNodeId: string; id: string; controller: DocumentController }, thunkAPI) => {
-    const { id, prevNodeId, controller } = payload;
-    const { dispatch, getState } = thunkAPI;
-    const state = (getState() as { document: DocumentState }).document;
-    const prevLineId = getPrevLineId(state, id);
-    if (!prevLineId) return;
-    await dispatch(composeNodeThunk({ id: id, composeId: prevLineId, controller }));
-  }
-);
-
+import { mergeToPrevLineThunk } from '$app_reducers/document/async-actions/blocks/text/merge';
+
+/**
+ * 1. If current node is not text block, turn it to text block
+ * 2. If current node is text block
+ *    2.1 If the current node has next node, merge it to the previous line
+ *    2.2 If the parent is root, merge it to the previous line
+ *    2.3 If the parent is not root and has no next node, outdent it
+ */
 export const backspaceNodeThunk = createAsyncThunk(
   'document/backspaceNode',
   async (payload: { id: string; controller: DocumentController }, thunkAPI) => {
@@ -79,31 +21,22 @@ export const backspaceNodeThunk = createAsyncThunk(
     const node = state.nodes[id];
     if (!node.parent) return;
     const parent = state.nodes[node.parent];
-    const ancestorId = parent.parent;
     const children = state.children[parent.children];
     const index = children.indexOf(id);
-    const prevNodeId = children[index - 1];
     const nextNodeId = children[index + 1];
     // turn to text block
     if (node.type !== BlockType.TextBlock) {
       await dispatch(turnToTextBlockThunk({ id, controller }));
       return;
     }
-    // compose to previous line when it has next sibling or no ancestor
-    if (nextNodeId || !ancestorId) {
-      // do nothing when it is the first line
-      if (!prevNodeId && !ancestorId) return;
-      // compose to parent when it has no previous sibling
-      if (!prevNodeId) {
-        await dispatch(composeParentThunk({ id, controller }));
-        return;
-      }
-      await dispatch(composePrevNodeThunk({ prevNodeId, id, controller }));
-      return;
-    } else {
-      // outdent when it has no next sibling
-      await dispatch(outdentNodeThunk({ id, controller }));
+    const parentIsRoot = !parent.parent;
+    // merge to previous line when parent is root
+    if (parentIsRoot || nextNodeId) {
+      // merge to previous line
+      await dispatch(mergeToPrevLineThunk({ id, controller, deleteCurrentNode: true }));
       return;
     }
+    // outdent
+    await dispatch(outdentNodeThunk({ id, controller }));
   }
 );

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

@@ -0,0 +1,85 @@
+import { createAsyncThunk } from '@reduxjs/toolkit';
+import { DocumentController } from '$app/stores/effects/document/document_controller';
+import { DocumentState } from '$app/interfaces/document';
+import { getPrevLineId } from '$app/utils/document/blocks/common';
+import { setCursorAfterThunk } from '$app_reducers/document/async-actions';
+import { documentActions } from '$app_reducers/document/slice';
+import { blockConfig } from '$app/constants/document/config';
+import { getNodeEndSelection } from '$app/utils/document/blocks/text/delta';
+
+/**
+ * It will merge delta to the prev line
+ * 1. find the prev line and has delta
+ *    1.1 Set cursor after the prev line
+ *    1.2 merge delta
+ * 2. If deleteCurrentNode is true, delete the current node and move children
+ *    2.2.1 if the prev line can add children, move children to the prev line.
+ *    2.2.2 Otherwise, move children to the parent and below the prev line
+ * 3. If deleteCurrentNode is false, clear the current node delta
+ */
+export const mergeToPrevLineThunk = createAsyncThunk(
+  'document/codeBlockBackspace',
+  async (payload: { id: string; controller: DocumentController; deleteCurrentNode?: boolean }, thunkAPI) => {
+    const { id, controller, deleteCurrentNode = false } = payload;
+    const { dispatch, getState } = thunkAPI;
+    const state = (getState() as { document: DocumentState }).document;
+    const node = state.nodes[id];
+    const prevLineId = getPrevLineId(state, id);
+    if (!prevLineId) return;
+    let prevLine = state.nodes[prevLineId];
+    // Find the prev line that has delta
+    while (prevLine && !prevLine.data.delta) {
+      const id = getPrevLineId(state, prevLine.id);
+      if (!id) return;
+      prevLine = state.nodes[id];
+    }
+    if (!prevLine) return;
+
+    const prevLineDelta = prevLine.data.delta;
+
+    const selection = getNodeEndSelection(prevLineDelta);
+
+    const mergeDelta = [...prevLineDelta, ...node.data.delta];
+
+    dispatch(documentActions.updateNodeData({ id: prevLine.id, data: { delta: mergeDelta } }));
+
+    const updateAction = controller.getUpdateAction({
+      ...prevLine,
+      data: {
+        ...prevLine.data,
+        delta: mergeDelta,
+      },
+    });
+
+    const actions = [updateAction];
+
+    if (deleteCurrentNode) {
+      // move children
+      const config = blockConfig[prevLine.type];
+      const children = state.children[node.children].map((id) => state.nodes[id]);
+      const targetParentId = config.canAddChild ? prevLine.id : prevLine.parent;
+      if (!targetParentId) return;
+      const targetPrevId = targetParentId === prevLine.id ? '' : prevLine.id;
+      const moveActions = controller.getMoveChildrenAction(children, targetParentId, targetPrevId);
+      actions.push(...moveActions);
+      // delete current block
+      const deleteAction = controller.getDeleteAction(node);
+      actions.push(deleteAction);
+    } else {
+      // clear current block delta
+      dispatch(documentActions.updateNodeData({ id: node.id, data: { delta: [] } }));
+      const updateAction = controller.getUpdateAction({
+        ...node,
+        data: {
+          ...node.data,
+          delta: [],
+        },
+      });
+      actions.push(updateAction);
+    }
+    await controller.applyActions(actions);
+
+    // set cursor after the prev line
+    dispatch(documentActions.setTextSelection({ blockId: prevLine.id, selection }));
+  }
+);

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

@@ -5,14 +5,16 @@ import { documentActions } from '$app_reducers/document/slice';
 import { setCursorBeforeThunk } from '../../cursor';
 import { newBlock } from '$app/utils/document/blocks/common';
 import { blockConfig, SplitRelationship } from '$app/constants/document/config';
+import { Editor } from 'slate';
+import { getSplitDelta } from '@/appflowy_app/utils/document/blocks/text/delta';
 
 export const splitNodeThunk = createAsyncThunk(
   'document/splitNode',
-  async (
-    payload: { id: string; retain: TextDelta[]; insert: TextDelta[]; controller: DocumentController },
-    thunkAPI
-  ) => {
-    const { id, controller, retain, insert } = payload;
+  async (payload: { id: string; editor: Editor; controller: DocumentController }, thunkAPI) => {
+    const { id, controller, editor } = payload;
+    // get the split content
+    const { retain, insert } = getSplitDelta(editor);
+
     const { dispatch, getState } = thunkAPI;
     const state = (getState() as { document: DocumentState }).document;
     const node = state.nodes[id];

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

@@ -0,0 +1,95 @@
+import Prism from 'prismjs';
+import 'prismjs/themes/prism.css';
+import 'prismjs/components/prism-bash';
+import 'prismjs/components/prism-c';
+import 'prismjs/components/prism-cpp';
+import 'prismjs/components/prism-csharp';
+import 'prismjs/components/prism-css';
+import 'prismjs/components/prism-dart';
+import 'prismjs/components/prism-docker';
+import 'prismjs/components/prism-go';
+import 'prismjs/components/prism-graphql';
+import 'prismjs/components/prism-groovy';
+import 'prismjs/components/prism-http';
+import 'prismjs/components/prism-java';
+import 'prismjs/components/prism-javascript';
+import 'prismjs/components/prism-json';
+import 'prismjs/components/prism-less';
+import 'prismjs/components/prism-typescript';
+import 'prismjs/components/prism-markdown';
+import 'prismjs/components/prism-python';
+import 'prismjs/components/prism-yaml';
+import 'prismjs/components/prism-regex';
+import 'prismjs/components/prism-ruby';
+import 'prismjs/components/prism-rust';
+import 'prismjs/components/prism-sass';
+import 'prismjs/components/prism-swift';
+import 'prismjs/components/prism-php';
+import 'prismjs/components/prism-sql';
+import 'prismjs/components/prism-visual-basic';
+
+import { BaseRange, NodeEntry, Text, Path } from 'slate';
+
+const push_string = (
+  token: string | Prism.Token,
+  path: Path,
+  start: number,
+  ranges: BaseRange[],
+  token_type = 'text'
+) => {
+  let newStart = start;
+  ranges.push({
+    // eslint-disable-next-line @typescript-eslint/ban-ts-comment
+    // @ts-ignore
+    prism_token: token_type,
+    anchor: { path, offset: newStart },
+    focus: { path, offset: newStart + token.length },
+  });
+  newStart += token.length;
+  return newStart;
+};
+
+// This recurses through the Prism.tokenizes result and creates stylized ranges based on the token type
+const recurseTokenize = (
+  token: string | Prism.Token,
+  path: Path,
+  ranges: BaseRange[],
+  start: number,
+  parent_tag?: string
+) => {
+  // Uses the parent's token type if a Token only has a string as its content
+  if (typeof token === 'string') {
+    return push_string(token, path, start, ranges, parent_tag);
+  }
+  if ('content' in token) {
+    if (token.content instanceof Array) {
+      // Calls recurseTokenize on nested Tokens in content
+      let newStart = start;
+      for (const subToken of token.content) {
+        newStart = recurseTokenize(subToken, path, ranges, newStart, token.type) || 0;
+      }
+      return newStart;
+    }
+
+    return push_string(token.content, path, start, ranges, token.type);
+  }
+};
+
+export const decorateCodeFunc = ([node, path]: NodeEntry, language: string) => {
+  const ranges: BaseRange[] = [];
+  if (!Text.isText(node)) {
+    return ranges;
+  }
+
+  try {
+    const tokens = Prism.tokenize(node.text, Prism.languages[language]);
+
+    let start = 0;
+    for (const token of tokens) {
+      start = recurseTokenize(token, path, ranges, start) || 0;
+    }
+    return ranges;
+  } catch {
+    return ranges;
+  }
+};

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

@@ -0,0 +1,34 @@
+import { getPointOfCurrentLineBeginning } from '$app/utils/document/blocks/text/delta';
+import { Editor, Transforms } from 'slate';
+
+export function indent(editor: Editor, distance: number) {
+  const beginPoint = getPointOfCurrentLineBeginning(editor);
+  const emptyStr = ''.padStart(distance);
+
+  Transforms.insertText(editor, emptyStr, {
+    at: beginPoint,
+  });
+}
+export function outdent(editor: Editor, distance: number) {
+  const beginPoint = getPointOfCurrentLineBeginning(editor);
+  if (!beginPoint) return;
+  const afterBeginPoint = Editor.after(editor, beginPoint, {
+    distance,
+  });
+  if (!afterBeginPoint) return;
+  const deleteChar = Editor.string(editor, {
+    anchor: beginPoint,
+    focus: afterBeginPoint,
+  });
+  const emptyStr = ''.padStart(distance);
+  if (deleteChar !== emptyStr) {
+    if (distance > 1) {
+      outdent(editor, distance - 1);
+    }
+    return;
+  }
+  Transforms.delete(editor, {
+    at: beginPoint,
+    distance,
+  });
+}

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

@@ -1,9 +1,8 @@
 import { BlockData, BlockType, DocumentState, NestedBlock, TextDelta } from '$app/interfaces/document';
-import { Descendant, Editor, Element, Text } from 'slate';
+import { Descendant, 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/blocks/text/delta';
 
 export function deltaToSlateValue(delta: TextDelta[]) {
   const slateNode = {
@@ -22,14 +21,6 @@ export function deltaToSlateValue(delta: TextDelta[]) {
   return slateNodes;
 }
 
-export function getDeltaAfterSelection(editor: Editor): TextDelta[] | undefined {
-  const selection = editor.selection;
-  if (!selection) return;
-  const slateNodes = Editor.fragment(editor, getAfterRangeAt(editor, selection));
-  const delta = getDeltaFromSlateNodes(slateNodes);
-  return delta;
-}
-
 export function getDeltaFromSlateNodes(slateNodes: Descendant[]) {
   const element = slateNodes[0] as Element;
   const children = element.children as Text[];

+ 14 - 2
frontend/appflowy_tauri/src/appflowy_app/utils/document/blocks/index.ts

@@ -7,8 +7,7 @@ import {
   TodoListBlockData,
   ToggleListBlockData,
 } from '$app/interfaces/document';
-import { getBeforeRangeAt } from '$app/utils/document/blocks/text/delta';
-import { getDeltaAfterSelection } from '$app/utils/document/blocks/common';
+import { getAfterRangeAt, getBeforeRangeAt, getDeltaAfterSelection } from '$app/utils/document/blocks/text/delta';
 
 /**
  * get heading data from editor, only support markdown
@@ -118,3 +117,16 @@ export function getCalloutDataFromEditor(editor: Editor): CalloutBlockData | und
     icon: iconMap[tag],
   };
 }
+
+/**
+ * get code block data from editor, only support markdown
+ */
+export function getCodeBlockDataFromEditor(editor: Editor) {
+  const delta = getDeltaAfterSelection(editor);
+  if (!delta) return;
+  return {
+    delta,
+    language: 'javascript',
+    wrap: true,
+  };
+}

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

@@ -1,6 +1,7 @@
 import { Editor, Element, Location, Text } from 'slate';
 import { SelectionPoint, TextDelta, TextSelection } from '$app/interfaces/document';
 import * as Y from 'yjs';
+import { getDeltaFromSlateNodes } from '$app/utils/document/blocks/common';
 
 export function getDelta(editor: Editor, at: Location): TextDelta[] {
   const baseElement = Editor.fragment(editor, at)[0] as Element;
@@ -118,6 +119,23 @@ export function getLastLineOffsetByDelta(delta: TextDelta[]): number {
   return index === -1 ? 0 : index + 1;
 }
 
+/**
+ * get the offset of per line beginning
+ * @param editor
+ */
+export function getOffsetOfPerLineBeginning(editor: Editor): number[] {
+  const delta = getDeltaFromSlateNodes(editor.children);
+  const lines = getLinesByDelta(delta);
+  const offsets: number[] = [];
+  let offset = 0;
+  for (let i = 0; i < lines.length; i++) {
+    const lineText = lines[i] + '\n';
+    offsets.push(offset);
+    offset += lineText.length;
+  }
+  return offsets;
+}
+
 /**
  * get the selection of the end line by offset
  * @param delta
@@ -168,6 +186,21 @@ export function getSelectionByTextOffset(delta: TextDelta[], offset: number) {
   return selection;
 }
 
+/**
+ * get the text offset by selection
+ * @param delta
+ * @param point
+ */
+export function getTextOffsetBySelection(delta: TextDelta[], point: SelectionPoint) {
+  let textOffset = 0;
+  for (let i = 0; i < point.path[1]; i++) {
+    const item = delta[i];
+    textOffset += item.insert.length;
+  }
+  textOffset += point.offset;
+  return textOffset;
+}
+
 /**
  * get the point by text offset
  * @param delta
@@ -208,3 +241,44 @@ export function isSameDelta(referDelta: TextDelta[], delta: TextDelta[]) {
   yTextRefer.applyDelta(referDelta);
   return JSON.stringify(yText.toDelta()) === JSON.stringify(yTextRefer.toDelta());
 }
+
+export function getDeltaBeforeSelection(editor: Editor) {
+  const selection = editor.selection;
+  if (!selection) return;
+  const beforeRange = getBeforeRangeAt(editor, selection);
+  return getDelta(editor, beforeRange);
+}
+
+export function getDeltaAfterSelection(editor: Editor): TextDelta[] | undefined {
+  const selection = editor.selection;
+  if (!selection) return;
+  const afterRange = getAfterRangeAt(editor, selection);
+  return getDelta(editor, afterRange);
+}
+
+export function getSplitDelta(editor: Editor) {
+  // get the retain content
+  const retain = getDeltaBeforeSelection(editor) || [];
+  // get the insert content
+  const insert = getDeltaAfterSelection(editor) || [];
+  return { retain, insert };
+}
+
+export function getPointOfCurrentLineBeginning(editor: Editor) {
+  const { selection } = editor;
+  if (!selection) return;
+  const delta = getDeltaFromSlateNodes(editor.children);
+  const textOffset = getTextOffsetBySelection(delta, selection.anchor as SelectionPoint);
+  const offsets = getOffsetOfPerLineBeginning(editor);
+  let lineNumber = offsets.findIndex((item) => item > textOffset);
+  if (lineNumber === -1) {
+    lineNumber = offsets.length - 1;
+  } else {
+    lineNumber -= 1;
+  }
+
+  const lineBeginOffset = offsets[lineNumber];
+
+  const beginPoint = getPointByTextOffset(delta, lineBeginOffset);
+  return beginPoint;
+}

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

@@ -1,8 +1,7 @@
 import isHotkey from 'is-hotkey';
 import { toggleFormat } from './format';
 import { Editor, Range } from 'slate';
-import { clonePoint, getAfterRangeAt, getBeforeRangeAt, getDelta, pointInBegin, pointInEnd } from './delta';
-import { SelectionPoint, TextDelta, TextSelection } from '$app/interfaces/document';
+import { getAfterRangeAt, getBeforeRangeAt, pointInBegin, pointInEnd } from './delta';
 import { keyBoardEventKeyMap } from '$app/constants/document/text_block';
 
 const HOTKEYS: Record<string, string> = {
@@ -24,11 +23,6 @@ export function triggerHotkey(event: React.KeyboardEvent<HTMLDivElement>, editor
   }
 }
 
-export function canHandleEnterKey(event: React.KeyboardEvent<HTMLDivElement>, editor: Editor) {
-  const isEnter = event.key === 'Enter';
-  return isEnter && editor.selection;
-}
-
 export function canHandleBackspaceKey(event: React.KeyboardEvent<HTMLDivElement>, editor: Editor) {
   const isBackspaceKey = isHotkey('backspace', event);
   const selection = editor.selection;
@@ -41,10 +35,6 @@ export function canHandleBackspaceKey(event: React.KeyboardEvent<HTMLDivElement>
   return isCollapsed && pointInBegin(editor, selection);
 }
 
-export function canHandleTabKey(event: React.KeyboardEvent<HTMLDivElement>, _: Editor) {
-  return isHotkey('tab', event);
-}
-
 export function canHandleUpKey(event: React.KeyboardEvent<HTMLDivElement>, editor: Editor) {
   const isUpKey = event.key === keyBoardEventKeyMap.Up;
   const selection = editor.selection;
@@ -98,56 +88,3 @@ export function canHandleRightKey(event: React.KeyboardEvent<HTMLDivElement>, ed
   const isCollapsed = Range.isCollapsed(selection);
   return isCollapsed && pointInEnd(editor, selection);
 }
-
-export function onHandleEnterKey(
-  event: React.KeyboardEvent<HTMLDivElement>,
-  editor: Editor,
-  {
-    onSplit,
-    onWrap,
-  }: {
-    onSplit: (...args: [TextDelta[], TextDelta[]]) => Promise<void>;
-    onWrap: (newDelta: TextDelta[], _selection: TextSelection) => Promise<void>;
-  }
-) {
-  const selection = editor.selection;
-  if (!selection) return;
-  // get the retain content
-  const retainRange = getBeforeRangeAt(editor, selection);
-  const retain = getDelta(editor, retainRange);
-  // get the insert content
-  const insertRange = getAfterRangeAt(editor, selection);
-  const insert = getDelta(editor, insertRange);
-
-  // if the shift key is pressed, break wrap the current node
-  if (isHotkey('shift+enter', event)) {
-    const newSelection = getSelectionAfterBreakWrap(editor);
-    if (!newSelection) return;
-
-    // insert `\n` after the retain content
-    void onWrap([...retain, { insert: '\n' }, ...insert], newSelection);
-    return;
-  }
-
-  // if the enter key is pressed, split the current node
-  if (isHotkey('enter', event)) {
-    // retain this node and insert a new node
-    void onSplit(retain, insert);
-    return;
-  }
-
-  // other cases, do nothing
-  return;
-}
-
-function getSelectionAfterBreakWrap(editor: Editor) {
-  const selection = editor.selection;
-  if (!selection) return;
-  const start = Range.start(selection);
-  const cursor = { path: start.path, offset: start.offset + 1 } as SelectionPoint;
-  const newSelection = {
-    anchor: clonePoint(cursor),
-    focus: clonePoint(cursor),
-  } as TextSelection;
-  return newSelection;
-}