瀏覽代碼

Copy and paste appflowy editor data (#2714)

* feat: copy and paste appflowy editor data

* fix: review suggestion
Kilu.He 1 年之前
父節點
當前提交
d02b8c609b
共有 22 個文件被更改,包括 631 次插入112 次删除
  1. 26 5
      frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSelection/RangeKeyDown.hooks.ts
  2. 1 1
      frontend/appflowy_tauri/src/appflowy_app/components/document/CodeBlock/useKeyDown.ts
  3. 4 0
      frontend/appflowy_tauri/src/appflowy_app/components/document/Overlay/index.tsx
  4. 5 8
      frontend/appflowy_tauri/src/appflowy_app/components/document/TextBlock/useKeyDown.ts
  5. 37 0
      frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/CopyPasteHooks/useCopy.ts
  6. 36 0
      frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/CopyPasteHooks/usePaste.ts
  7. 17 0
      frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/EditorHooks/useCommonKeyEvents.ts
  8. 12 15
      frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SlateEditor/useEditor.ts
  9. 9 9
      frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SlateEditor/useSlateYjs.ts
  10. 5 0
      frontend/appflowy_tauri/src/appflowy_app/constants/document/copy_paste.ts
  11. 10 0
      frontend/appflowy_tauri/src/appflowy_app/constants/document/keyboard.ts
  12. 13 1
      frontend/appflowy_tauri/src/appflowy_app/interfaces/document.ts
  13. 1 1
      frontend/appflowy_tauri/src/appflowy_app/stores/effects/document/document_controller.ts
  14. 6 5
      frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/merge.ts
  15. 202 0
      frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/copyPaste.ts
  16. 8 4
      frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/format.ts
  17. 17 21
      frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/range.ts
  18. 65 28
      frontend/appflowy_tauri/src/appflowy_app/utils/document/action.ts
  19. 82 0
      frontend/appflowy_tauri/src/appflowy_app/utils/document/copy_paste.ts
  20. 45 11
      frontend/appflowy_tauri/src/appflowy_app/utils/document/delta.ts
  21. 28 0
      frontend/appflowy_tauri/src/appflowy_app/utils/document/format.ts
  22. 2 3
      frontend/appflowy_tauri/src/appflowy_app/utils/document/node.ts

+ 26 - 5
frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSelection/RangeKeyDown.hooks.ts

@@ -8,6 +8,8 @@ import isHotkey from 'is-hotkey';
 import { deleteRangeAndInsertEnterThunk } from '$app_reducers/document/async-actions/range';
 import { useRangeRef } from '$app/components/document/_shared/SubscribeSelection.hooks';
 import { isPrintableKeyEvent } from '$app/utils/document/action';
+import { toggleFormatThunk } from '$app_reducers/document/async-actions/format';
+import { isFormatHotkey, parseFormat } from '$app/utils/document/format';
 
 export function useRangeKeyDown() {
   const rangeRef = useRangeRef();
@@ -33,7 +35,7 @@ export function useRangeKeyDown() {
       {
         // handle char input
         canHandle: (e: KeyboardEvent) => {
-          return isPrintableKeyEvent(e);
+          return isPrintableKeyEvent(e) && !e.shiftKey && !e.ctrlKey && !e.metaKey;
         },
         handler: (e: KeyboardEvent) => {
           if (!controller) return;
@@ -94,11 +96,26 @@ export function useRangeKeyDown() {
           );
         },
       },
+      {
+        // handle format shortcuts
+        canHandle: isFormatHotkey,
+        handler: (e: KeyboardEvent) => {
+          if (!controller) return;
+          const format = parseFormat(e);
+          if (!format) return;
+          dispatch(
+            toggleFormatThunk({
+              format,
+              controller,
+            })
+          );
+        },
+      },
     ],
     [controller, dispatch]
   );
 
-  const onKeyDown = useCallback(
+  const onKeyDownCapture = useCallback(
     (e: KeyboardEvent) => {
       if (!rangeRef.current) {
         return;
@@ -108,12 +125,16 @@ export function useRangeKeyDown() {
         return;
       }
       e.stopPropagation();
-      e.preventDefault();
       const filteredEvents = interceptEvents.filter((event) => event.canHandle(e));
-      filteredEvents.forEach((event) => event.handler(e));
+      const lastIndex = filteredEvents.length - 1;
+      if (lastIndex < 0) {
+        return;
+      }
+      const lastEvent = filteredEvents[lastIndex];
+      lastEvent?.handler(e);
     },
     [interceptEvents, rangeRef]
   );
 
-  return onKeyDown;
+  return onKeyDownCapture;
 }

+ 1 - 1
frontend/appflowy_tauri/src/appflowy_app/components/document/CodeBlock/useKeyDown.ts

@@ -14,8 +14,8 @@ export function useKeyDown(id: string) {
   const customEvents = useMemo(() => {
     return [
       ...commonKeyEvents,
-
       {
+        // rewrite only shift + enter key and no other key is pressed
         canHandle: (e: React.KeyboardEvent<HTMLDivElement>) => {
           return isHotkey(Keyboard.keys.SHIFT_ENTER, e);
         },

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

@@ -3,8 +3,12 @@ import BlockSideToolbar from '../BlockSideToolbar';
 import BlockSelection from '../BlockSelection';
 import TextActionMenu from '$app/components/document/TextActionMenu';
 import BlockSlash from '$app/components/document/BlockSlash';
+import { useCopy } from '$app/components/document/_shared/CopyPasteHooks/useCopy';
+import { usePaste } from '$app/components/document/_shared/CopyPasteHooks/usePaste';
 
 export default function Overlay({ container }: { container: HTMLDivElement }) {
+  useCopy(container);
+  usePaste(container);
   return (
     <>
       <BlockSideToolbar container={container} />

+ 5 - 8
frontend/appflowy_tauri/src/appflowy_app/components/document/TextBlock/useKeyDown.ts

@@ -20,7 +20,7 @@ export function useKeyDown(id: string) {
     return [
       ...commonKeyEvents,
       {
-        // Prevent all enter key
+        // Prevent all enter key unless it be rewritten
         canHandle: (e: React.KeyboardEvent<HTMLDivElement>) => {
           return e.key === Keyboard.keys.ENTER;
         },
@@ -29,7 +29,7 @@ export function useKeyDown(id: string) {
         },
       },
       {
-        // handle enter key and no other key is pressed
+        // rewrite only enter key and no other key is pressed
         canHandle: (e: React.KeyboardEvent<HTMLDivElement>) => {
           return isHotkey(Keyboard.keys.ENTER, e);
         },
@@ -43,9 +43,8 @@ export function useKeyDown(id: string) {
           );
         },
       },
-
       {
-        // Prevent tab key from indenting
+        // Prevent all tab key unless it be rewritten
         canHandle: (e: React.KeyboardEvent<HTMLDivElement>) => {
           return e.key === Keyboard.keys.TAB;
         },
@@ -54,7 +53,7 @@ export function useKeyDown(id: string) {
         },
       },
       {
-        // handle tab key and no other key is pressed
+        // rewrite only tab key and no other key is pressed
         canHandle: (e: React.KeyboardEvent<HTMLDivElement>) => {
           return isHotkey(Keyboard.keys.TAB, e);
         },
@@ -69,7 +68,7 @@ export function useKeyDown(id: string) {
         },
       },
       {
-        // handle shift + tab key and no other key is pressed
+        // rewrite only shift+tab key and no other key is pressed
         canHandle: (e: React.KeyboardEvent<HTMLDivElement>) => {
           return isHotkey(Keyboard.keys.SHIFT_TAB, e);
         },
@@ -83,14 +82,12 @@ export function useKeyDown(id: string) {
           );
         },
       },
-
       ...turnIntoEvents,
     ];
   }, [commonKeyEvents, controller, dispatch, id, turnIntoEvents]);
 
   const onKeyDown = useCallback(
     (e: React.KeyboardEvent<HTMLDivElement>) => {
-
       e.stopPropagation();
 
       const filteredEvents = interceptEvents.filter((event) => event.canHandle(e));

+ 37 - 0
frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/CopyPasteHooks/useCopy.ts

@@ -0,0 +1,37 @@
+import { useCallback, useContext, useEffect } from 'react';
+import { copyThunk } from '$app_reducers/document/async-actions/copyPaste';
+import { useAppDispatch } from '$app/stores/store';
+import { DocumentControllerContext } from '$app/stores/effects/document/document_controller';
+import { BlockCopyData } from '$app/interfaces/document';
+import { clipboardTypes } from '$app/constants/document/copy_paste';
+
+export function useCopy(container: HTMLDivElement) {
+  const dispatch = useAppDispatch();
+  const controller = useContext(DocumentControllerContext);
+
+  const handleCopyCapture = useCallback(
+    (e: ClipboardEvent) => {
+      if (!controller) return;
+      e.stopPropagation();
+      e.preventDefault();
+      const setClipboardData = (data: BlockCopyData) => {
+        e.clipboardData?.setData(clipboardTypes.JSON, data.json);
+        e.clipboardData?.setData(clipboardTypes.TEXT, data.text);
+        e.clipboardData?.setData(clipboardTypes.HTML, data.html);
+      };
+      dispatch(
+        copyThunk({
+          setClipboardData,
+        })
+      );
+    },
+    [controller, dispatch]
+  );
+
+  useEffect(() => {
+    container.addEventListener('copy', handleCopyCapture, true);
+    return () => {
+      container.removeEventListener('copy', handleCopyCapture, true);
+    };
+  }, [container, handleCopyCapture]);
+}

+ 36 - 0
frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/CopyPasteHooks/usePaste.ts

@@ -0,0 +1,36 @@
+import { useCallback, useContext, useEffect } from 'react';
+import { useAppDispatch } from '$app/stores/store';
+import { DocumentControllerContext } from '$app/stores/effects/document/document_controller';
+import { pasteThunk } from '$app_reducers/document/async-actions/copyPaste';
+import { clipboardTypes } from '$app/constants/document/copy_paste';
+
+export function usePaste(container: HTMLDivElement) {
+  const dispatch = useAppDispatch();
+  const controller = useContext(DocumentControllerContext);
+
+  const handlePasteCapture = useCallback(
+    (e: ClipboardEvent) => {
+      if (!controller) return;
+      e.stopPropagation();
+      e.preventDefault();
+      dispatch(
+        pasteThunk({
+          controller,
+          data: {
+            json: e.clipboardData?.getData(clipboardTypes.JSON) || '',
+            text: e.clipboardData?.getData(clipboardTypes.TEXT) || '',
+            html: e.clipboardData?.getData(clipboardTypes.HTML) || '',
+          },
+        })
+      );
+    },
+    [controller, dispatch]
+  );
+
+  useEffect(() => {
+    container.addEventListener('paste', handlePasteCapture, true);
+    return () => {
+      container.removeEventListener('paste', handlePasteCapture, true);
+    };
+  }, [container, handlePasteCapture]);
+}

+ 17 - 0
frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/EditorHooks/useCommonKeyEvents.ts

@@ -10,6 +10,8 @@ import { useContext, useMemo } from 'react';
 import { useFocused } from '$app/components/document/_shared/SubscribeSelection.hooks';
 import { DocumentControllerContext } from '$app/stores/effects/document/document_controller';
 import { useAppDispatch } from '$app/stores/store';
+import { isFormatHotkey, parseFormat } from '$app/utils/document/format';
+import { toggleFormatThunk } from '$app_reducers/document/async-actions/format';
 
 export function useCommonKeyEvents(id: string) {
   const { focused, caretRef } = useFocused(id);
@@ -73,6 +75,21 @@ export function useCommonKeyEvents(id: string) {
           dispatch(rightActionForBlockThunk({ id }));
         },
       },
+      {
+        // handle format shortcuts
+        canHandle: isFormatHotkey,
+        handler: (e: React.KeyboardEvent<HTMLDivElement>) => {
+          if (!controller) return;
+          const format = parseFormat(e);
+          if (!format) return;
+          dispatch(
+            toggleFormatThunk({
+              format,
+              controller,
+            })
+          );
+        },
+      },
     ];
   }, [caretRef, controller, dispatch, focused, id]);
   return commonKeyEvents;

+ 12 - 15
frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SlateEditor/useEditor.ts

@@ -1,19 +1,19 @@
-import { EditorProps } from "$app/interfaces/document";
-import { useCallback, useEffect, useMemo, useRef } from "react";
-import { ReactEditor } from "slate-react";
-import { BaseRange, Descendant, Editor, NodeEntry, Range, Selection } from "slate";
+import { EditorProps } from '$app/interfaces/document';
+import { useCallback, useEffect, useMemo, useRef } from 'react';
+import { ReactEditor } from 'slate-react';
+import { BaseRange, Descendant, Editor, NodeEntry, Range, Selection, Transforms } from 'slate';
 import {
   converToIndexLength,
   convertToDelta,
   convertToSlateSelection,
   indent,
-  outdent
-} from "$app/utils/document/slate_editor";
-import { focusNodeByIndex } from "$app/utils/document/node";
-import { Keyboard } from "$app/constants/document/keyboard";
-import Delta from "quill-delta";
-import isHotkey from "is-hotkey";
-import { useSlateYjs } from "$app/components/document/_shared/SlateEditor/useSlateYjs";
+  outdent,
+} from '$app/utils/document/slate_editor';
+import { focusNodeByIndex } from '$app/utils/document/node';
+import { Keyboard } from '$app/constants/document/keyboard';
+import Delta from 'quill-delta';
+import isHotkey from 'is-hotkey';
+import { useSlateYjs } from '$app/components/document/_shared/SlateEditor/useSlateYjs';
 
 export function useEditor({
   onChange,
@@ -109,7 +109,6 @@ export function useEditor({
     [editor, onKeyDown, isCodeBlock]
   );
 
-
   const onBlur = useCallback(
     (_event: React.FocusEvent<HTMLDivElement>) => {
       editor.deselect();
@@ -122,10 +121,9 @@ export function useEditor({
     const slateSelection = convertToSlateSelection(selection.index, selection.length, editor.children);
     if (!slateSelection) return;
     const isFocused = ReactEditor.isFocused(editor);
-
     if (isFocused && JSON.stringify(slateSelection) === JSON.stringify(editor.selection)) return;
-
     focusNodeByIndex(ref.current, selection.index, selection.length);
+    Transforms.select(editor, slateSelection);
   }, [editor, selection]);
 
   return {
@@ -139,4 +137,3 @@ export function useEditor({
     onBlur,
   };
 }
-

+ 9 - 9
frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SlateEditor/useSlateYjs.ts

@@ -1,16 +1,16 @@
-import Delta from "quill-delta";
-import { useEffect, useMemo, useRef } from "react";
-import * as Y from "yjs";
-import { convertToSlateValue } from "$app/utils/document/slate_editor";
-import { slateNodesToInsertDelta, withYjs, YjsEditor } from "@slate-yjs/core";
-import { withReact } from "slate-react";
-import { createEditor } from "slate";
+import Delta from 'quill-delta';
+import { useEffect, useMemo, useRef } from 'react';
+import * as Y from 'yjs';
+import { convertToSlateValue } from '$app/utils/document/slate_editor';
+import { slateNodesToInsertDelta, withYjs, YjsEditor } from '@slate-yjs/core';
+import { withReact } from 'slate-react';
+import { createEditor } from 'slate';
 
 export function useSlateYjs({ delta }: { delta?: Delta }) {
   const yTextRef = useRef<Y.Text>();
   const sharedType = useMemo(() => {
     const yDoc = new Y.Doc();
-    const sharedType = yDoc.get("content", Y.XmlText) as Y.XmlText;
+    const sharedType = yDoc.get('content', Y.XmlText) as Y.XmlText;
     const value = convertToSlateValue(delta || new Delta());
     const insertDelta = slateNodesToInsertDelta(value);
     sharedType.applyDelta(insertDelta);
@@ -40,4 +40,4 @@ export function useSlateYjs({ delta }: { delta?: Delta }) {
   }, [delta, editor]);
 
   return editor;
-}
+}

+ 5 - 0
frontend/appflowy_tauri/src/appflowy_app/constants/document/copy_paste.ts

@@ -0,0 +1,5 @@
+export const clipboardTypes = {
+  JSON: 'application/json',
+  TEXT: 'text/plain',
+  HTML: 'text/html',
+};

+ 10 - 0
frontend/appflowy_tauri/src/appflowy_app/constants/document/keyboard.ts

@@ -28,5 +28,15 @@ export const Keyboard = {
     Space: ' ',
     Reduce: '-',
     BackQuote: '`',
+    FORMAT: {
+      BOLD: 'Mod+b',
+      ITALIC: 'Mod+i',
+      UNDERLINE: 'Mod+u',
+      STRIKE: 'Mod+Shift+s',
+      CODE: 'Mod+Shift+c',
+    },
+    COPY: 'Mod+c',
+    CUT: 'Mod+x',
+    PASTE: 'Mod+v',
   },
 };

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

@@ -3,6 +3,12 @@ import { BlockActionTypePB } from '@/services/backend';
 import { Sources } from 'quill';
 import React from 'react';
 
+export interface DocumentBlockJSON {
+  type: BlockType;
+  data: BlockData<any>;
+  children: DocumentBlockJSON[];
+}
+
 export interface RangeStatic {
   id: string;
   length: number;
@@ -12,7 +18,7 @@ export interface RangeStatic {
 export enum BlockType {
   PageBlock = 'page',
   HeadingBlock = 'heading',
-  TextBlock = 'text',
+  TextBlock = 'paragraph',
   TodoListBlock = 'todo_list',
   BulletedListBlock = 'bulleted_list',
   NumberedListBlock = 'numbered_list',
@@ -252,3 +258,9 @@ export interface EditorProps {
   onChange?: (delta: Delta, oldDelta: Delta, source?: Sources) => void;
   onKeyDown?: (event: React.KeyboardEvent<HTMLDivElement>) => void;
 }
+
+export interface BlockCopyData {
+  json: string;
+  text: string;
+  html: string;
+}

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

@@ -1,4 +1,4 @@
-import { DocumentData, Node } from '@/appflowy_app/interfaces/document';
+import { DocumentBlockJSON, DocumentData, Node } from '@/appflowy_app/interfaces/document';
 import { createContext } from 'react';
 import { DocumentBackendService } from './document_bd_svc';
 import {

+ 6 - 5
frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/merge.ts

@@ -3,6 +3,7 @@ import { DocumentController } from '$app/stores/effects/document/document_contro
 import { DocumentState } from '$app/interfaces/document';
 import Delta from 'quill-delta';
 import { blockConfig } from '$app/constants/document/config';
+import { getMoveChildrenActions } from '$app/utils/document/action';
 
 /**
  * Merge two blocks
@@ -33,12 +34,12 @@ export const mergeDeltaThunk = createAsyncThunk(
 
     const actions = [updateAction];
     // move children
-    const config = blockConfig[target.type];
     const children = state.children[source.children].map((id) => state.nodes[id]);
-    const targetParentId = config.canAddChild ? target.id : target.parent;
-    if (!targetParentId) return;
-    const targetPrevId = targetParentId === target.id ? '' : target.id;
-    const moveActions = controller.getMoveChildrenAction(children, targetParentId, targetPrevId);
+    const moveActions = getMoveChildrenActions({
+      controller,
+      children,
+      target,
+    });
     actions.push(...moveActions);
     // delete current block
     const deleteAction = controller.getDeleteAction(source);

+ 202 - 0
frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/copyPaste.ts

@@ -0,0 +1,202 @@
+import { createAsyncThunk } from '@reduxjs/toolkit';
+import { RootState } from '$app/stores/store';
+import { getMiddleIds, getMoveChildrenActions, getStartAndEndIdsByRange } from '$app/utils/document/action';
+import { BlockCopyData, BlockType, DocumentBlockJSON } from '$app/interfaces/document';
+import Delta from 'quill-delta';
+import { getDeltaByRange } from '$app/utils/document/delta';
+import { deleteRangeAndInsertThunk } from '$app_reducers/document/async-actions/range';
+import { DocumentController } from '$app/stores/effects/document/document_controller';
+import {
+  generateBlocks,
+  getAppendBlockDeltaAction,
+  getCopyBlock,
+  getInsertBlockActions,
+} from '$app/utils/document/copy_paste';
+import { rangeActions } from '$app_reducers/document/slice';
+
+export const copyThunk = createAsyncThunk<
+  void,
+  {
+    setClipboardData: (data: BlockCopyData) => void;
+  }
+>('document/copy', async (payload, thunkAPI) => {
+  const { getState } = thunkAPI;
+  const { setClipboardData } = payload;
+  const state = getState() as RootState;
+  const { document, documentRange } = state;
+  const startAndEndIds = getStartAndEndIdsByRange(documentRange);
+  if (startAndEndIds.length === 0) return;
+  const result: DocumentBlockJSON[] = [];
+  if (startAndEndIds.length === 1) {
+    // copy single block
+    const id = startAndEndIds[0];
+    const node = document.nodes[id];
+    const nodeDelta = new Delta(node.data.delta);
+    const range = documentRange.ranges[id] || { index: 0, length: 0 };
+    const isFull = range.index === 0 && range.length === nodeDelta.length();
+    if (isFull) {
+      result.push(getCopyBlock(id, document, documentRange));
+    } else {
+      result.push({
+        type: BlockType.TextBlock,
+        children: [],
+        data: {
+          delta: getDeltaByRange(nodeDelta, range).ops,
+        },
+      });
+    }
+  } else {
+    // copy multiple blocks
+    const copyIds: string[] = [];
+    const [startId, endId] = startAndEndIds;
+    const middleIds = getMiddleIds(document, startId, endId);
+    copyIds.push(startId, ...middleIds, endId);
+    const map = new Map<string, DocumentBlockJSON>();
+    copyIds.forEach((id) => {
+      const block = getCopyBlock(id, document, documentRange);
+      map.set(id, block);
+      const node = document.nodes[id];
+      const parent = node.parent;
+      if (parent && map.has(parent)) {
+        map.get(parent)!.children.push(block);
+      } else {
+        result.push(block);
+      }
+    });
+  }
+  setClipboardData({
+    json: JSON.stringify(result),
+    // TODO: implement plain text and html
+    text: '',
+    html: '',
+  });
+});
+
+/**
+ * Paste data to document
+ * 1. delete range blocks
+ * 2. if current block is empty text block, insert paste data below current block and delete current block
+ * 3. otherwise:
+ *    3.1 split current block, before part merge the first block of paste data and update current block
+ *    3.2 after part append to the last block of paste data
+ *    3.3 move the first block children of paste data to current block
+ *    3.4 delete the first block of paste data
+ */
+export const pasteThunk = createAsyncThunk<
+  void,
+  {
+    data: BlockCopyData;
+    controller: DocumentController;
+  }
+>('document/paste', async (payload, thunkAPI) => {
+  const { getState, dispatch } = thunkAPI;
+  const { data, controller } = payload;
+  // delete range blocks
+  await dispatch(deleteRangeAndInsertThunk({ controller }));
+
+  let pasteData;
+  if (data.json) {
+    pasteData = JSON.parse(data.json) as DocumentBlockJSON[];
+  } else if (data.text) {
+    // TODO: implement plain text
+  } else if (data.html) {
+    // TODO: implement html
+  }
+  if (!pasteData) return;
+  const { document, documentRange } = getState() as RootState;
+  const { caret } = documentRange;
+  if (!caret) return;
+  const currentBlock = document.nodes[caret.id];
+  if (!currentBlock.parent) return;
+  const pasteBlocks = generateBlocks(pasteData, currentBlock.parent);
+  const currentBlockDelta = new Delta(currentBlock.data.delta);
+  const type = currentBlock.type;
+  const actions = getInsertBlockActions(pasteBlocks, currentBlock.id, controller);
+  const firstPasteBlock = pasteBlocks[0];
+  const firstPasteBlockChildren = pasteBlocks.filter((block) => block.parent === firstPasteBlock.id);
+
+  const lastPasteBlock = pasteBlocks[pasteBlocks.length - 1];
+  if (type === BlockType.TextBlock && currentBlockDelta.length() === 0) {
+    // move current block children to first paste block
+    const children = document.children[currentBlock.children].map((id) => document.nodes[id]);
+    const firstPasteBlockLastChild =
+      firstPasteBlockChildren.length > 0 ? firstPasteBlockChildren[firstPasteBlockChildren.length - 1] : undefined;
+    const prevId = firstPasteBlockLastChild ? firstPasteBlockLastChild.id : undefined;
+    const moveChildrenActions = getMoveChildrenActions({
+      target: firstPasteBlock,
+      children,
+      controller,
+      prevId,
+    });
+    actions.push(...moveChildrenActions);
+    // delete current block
+    actions.push(controller.getDeleteAction(currentBlock));
+    await controller.applyActions(actions);
+    // set caret to the end of the last paste block
+    dispatch(
+      rangeActions.setCaret({
+        id: lastPasteBlock.id,
+        index: new Delta(lastPasteBlock.data.delta).length(),
+        length: 0,
+      })
+    );
+    return;
+  }
+
+  // split current block
+  const currentBeforeDelta = getDeltaByRange(currentBlockDelta, { index: 0, length: caret.index });
+  const currentAfterDelta = getDeltaByRange(currentBlockDelta, {
+    index: caret.index,
+    length: currentBlockDelta.length() - caret.index,
+  });
+
+  let newCaret;
+  const firstPasteBlockDelta = new Delta(firstPasteBlock.data.delta);
+  const lastPasteBlockDelta = new Delta(lastPasteBlock.data.delta);
+  let mergeDelta = new Delta(currentBeforeDelta.ops).concat(firstPasteBlockDelta);
+  if (firstPasteBlock.id !== lastPasteBlock.id) {
+    // update the last block of paste data
+    actions.push(getAppendBlockDeltaAction(lastPasteBlock, currentAfterDelta, false, controller));
+    newCaret = {
+      id: lastPasteBlock.id,
+      index: lastPasteBlockDelta.length(),
+      length: 0,
+    };
+  } else {
+    newCaret = {
+      id: currentBlock.id,
+      index: mergeDelta.length(),
+      length: 0,
+    };
+    mergeDelta = mergeDelta.concat(currentAfterDelta);
+  }
+
+  // update current block and merge the first block of paste data
+  actions.push(
+    controller.getUpdateAction({
+      ...currentBlock,
+      data: {
+        ...currentBlock.data,
+        delta: mergeDelta.ops,
+      },
+    })
+  );
+
+  // move the first block children of paste data to current block
+  if (firstPasteBlockChildren.length > 0) {
+    const moveChildrenActions = getMoveChildrenActions({
+      target: currentBlock,
+      children: firstPasteBlockChildren,
+      controller,
+    });
+    actions.push(...moveChildrenActions);
+  }
+
+  // delete first block of paste data
+  actions.push(controller.getDeleteAction(firstPasteBlock));
+  await controller.applyActions(actions);
+  // set caret to the end of the last paste block
+  if (!newCaret) return;
+
+  dispatch(rangeActions.setCaret(newCaret));
+});

+ 8 - 4
frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/format.ts

@@ -3,7 +3,6 @@ import { RootState } from '$app/stores/store';
 import { TextAction } from '$app/interfaces/document';
 import { DocumentController } from '$app/stores/effects/document/document_controller';
 import Delta from 'quill-delta';
-import { rangeActions } from '$app_reducers/document/slice';
 
 export const getFormatActiveThunk = createAsyncThunk<boolean, TextAction>(
   'document/getFormatActive',
@@ -29,12 +28,17 @@ export const getFormatActiveThunk = createAsyncThunk<boolean, TextAction>(
 
 export const toggleFormatThunk = createAsyncThunk(
   'document/toggleFormat',
-  async (payload: { format: TextAction; controller: DocumentController; isActive: boolean }, thunkAPI) => {
+  async (payload: { format: TextAction; controller: DocumentController; isActive?: boolean }, thunkAPI) => {
     const { getState, dispatch } = thunkAPI;
-    const { format, controller, isActive } = payload;
+    const { format, controller } = payload;
+    let isActive = payload.isActive;
+    if (isActive === undefined) {
+      const { payload: active } = await dispatch(getFormatActiveThunk(format));
+      isActive = !!active;
+    }
     const state = getState() as RootState;
     const { document } = state;
-    const { ranges, caret } = state.documentRange;
+    const { ranges } = state.documentRange;
 
     const toggle = (delta: Delta, format: TextAction) => {
       const newOps = delta.ops.map((op) => {

+ 17 - 21
frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/range.ts

@@ -1,15 +1,15 @@
 import { createAsyncThunk } from '@reduxjs/toolkit';
 import { RootState } from '$app/stores/store';
 import { rangeActions } from '$app_reducers/document/slice';
-import { getNextLineId } from '$app/utils/document/block';
 import Delta from 'quill-delta';
 import { DocumentController } from '$app/stores/effects/document/document_controller';
 import {
   getAfterMergeCaretByRange,
   getInsertEnterNodeAction,
   getMergeEndDeltaToStartActionsByRange,
+  getMiddleIds,
   getMiddleIdsByRange,
-  getStartAndEndDeltaExpectRange,
+  getStartAndEndExtentDelta,
 } from '$app/utils/document/action';
 import { RangeState, SplitRelationship } from '$app/interfaces/document';
 import { blockConfig } from '$app/constants/document/config';
@@ -74,25 +74,19 @@ export const storeRangeThunk = createAsyncThunk('document/storeRange', (payload:
   const startId = isForward ? anchor.id : focus.id;
   const endId = isForward ? focus.id : anchor.id;
 
-  let currentId: string | undefined = startId;
-  while (currentId && currentId !== endId) {
-    const nextId = getNextLineId(state.document, currentId);
-    if (nextId && nextId !== endId) {
-      const node = state.document.nodes[nextId];
+  const middleIds = getMiddleIds(state.document, startId, endId);
+  middleIds.forEach((id) => {
+    const node = state.document.nodes[id];
 
-      if (!node || !node.data.delta) return;
-      const delta = new Delta(node.data.delta);
-
-      // set full range
-      const rangeStatic = {
-        index: 0,
-        length: delta.length(),
-      };
+    if (!node || !node.data.delta) return;
+    const delta = new Delta(node.data.delta);
+    const rangeStatic = {
+      index: 0,
+      length: delta.length(),
+    };
 
-      ranges[nextId] = rangeStatic;
-    }
-    currentId = nextId;
-  }
+    ranges[id] = rangeStatic;
+  });
 
   dispatch(rangeActions.setRanges(ranges));
 });
@@ -110,6 +104,8 @@ export const deleteRangeAndInsertThunk = createAsyncThunk(
     const { getState, dispatch } = thunkAPI;
     const state = getState() as RootState;
     const rangeState = state.documentRange;
+    // if no range, just return
+    if (rangeState.caret && rangeState.caret.length === 0) return;
     const actions = [];
     // get merge actions
     const mergeActions = getMergeEndDeltaToStartActionsByRange(state, controller, insertDelta);
@@ -153,11 +149,11 @@ export const deleteRangeAndInsertEnterThunk = createAsyncThunk(
     const rangeState = state.documentRange;
     const actions = [];
 
-    const { startDelta, endDelta, endNode, startNode } = getStartAndEndDeltaExpectRange(state) || {};
+    const { startDelta, endDelta, endNode, startNode } = getStartAndEndExtentDelta(state) || {};
     if (!startDelta || !endDelta || !endNode || !startNode) return;
 
     // get middle nodes
-    const middleIds = getMiddleIdsByRange(rangeState, state.document);
+    const middleIds = getMiddleIds(state.document, startNode.id, endNode.id);
 
     let newStartDelta = new Delta(startDelta);
     let caret = null;

+ 65 - 28
frontend/appflowy_tauri/src/appflowy_app/utils/document/action.ts

@@ -15,6 +15,8 @@ import { blockConfig } from '$app/constants/document/config';
 import {
   caretInBottomEdgeByDelta,
   caretInTopEdgeByDelta,
+  getAfterExtentDeltaByRange,
+  getBeofreExtentDeltaByRange,
   getDeltaText,
   getIndexRelativeEnter,
   getLastLineIndex,
@@ -22,25 +24,34 @@ import {
   transformIndexToPrevLine,
 } from '$app/utils/document/delta';
 
-export function getMiddleIdsByRange(rangeState: RangeState, document: DocumentState) {
-  const { anchor, focus } = rangeState;
-  if (!anchor || !focus) return;
-  if (anchor.id === focus.id) return;
-  const isForward = anchor.point.y < focus.point.y;
-  // get all ids between anchor and focus
-  const amendIds = [];
-  const startId = isForward ? anchor.id : focus.id;
-  const endId = isForward ? focus.id : anchor.id;
-
+export function getMiddleIds(document: DocumentState, startId: string, endId: string) {
+  const middleIds = [];
   let currentId: string | undefined = startId;
   while (currentId && currentId !== endId) {
     const nextId = getNextLineId(document, currentId);
     if (nextId && nextId !== endId) {
-      amendIds.push(nextId);
+      middleIds.push(nextId);
     }
     currentId = nextId;
   }
-  return amendIds;
+  return middleIds;
+}
+
+export function getStartAndEndIdsByRange(rangeState: RangeState) {
+  const { anchor, focus } = rangeState;
+  if (!anchor || !focus) return [];
+  if (anchor.id === focus.id) return [anchor.id];
+  const isForward = anchor.point.y < focus.point.y;
+  const startId = isForward ? anchor.id : focus.id;
+  const endId = isForward ? focus.id : anchor.id;
+  return [startId, endId];
+}
+
+export function getMiddleIdsByRange(rangeState: RangeState, document: DocumentState) {
+  const ids = getStartAndEndIdsByRange(rangeState);
+  if (ids.length < 2) return;
+  const [startId, endId] = ids;
+  return getMiddleIds(document, startId, endId);
 }
 
 export function getAfterMergeCaretByRange(rangeState: RangeState, insertDelta?: Delta) {
@@ -61,42 +72,40 @@ export function getAfterMergeCaretByRange(rangeState: RangeState, insertDelta?:
   };
 }
 
-export function getStartAndEndDeltaExpectRange(state: RootState) {
+export function getStartAndEndExtentDelta(state: RootState) {
   const rangeState = state.documentRange;
-  const { anchor, focus, ranges } = rangeState;
-  if (!anchor || !focus) return;
-  if (anchor.id === focus.id) return;
-
-  const isForward = anchor.point.y < focus.point.y;
-  const startId = isForward ? anchor.id : focus.id;
-  const endId = isForward ? focus.id : anchor.id;
-
+  const ids = getStartAndEndIdsByRange(rangeState);
+  if (ids.length === 0) return;
+  const startId = ids[0];
+  const endId = ids[ids.length - 1];
+  const { ranges } = rangeState;
   // get start and end delta
   const startRange = ranges[startId];
   const endRange = ranges[endId];
   if (!startRange || !endRange) return;
   const startNode = state.document.nodes[startId];
-  let startDelta = new Delta(startNode.data.delta);
-  startDelta = startDelta.slice(0, startRange.index);
+  const startNodeDelta = new Delta(startNode.data.delta);
+  const startBeforeExtentDelta = getBeofreExtentDeltaByRange(startNodeDelta, startRange);
 
   const endNode = state.document.nodes[endId];
-  let endDelta = new Delta(endNode.data.delta);
-  endDelta = endDelta.slice(endRange.index + endRange.length);
+  const endNodeDelta = new Delta(endNode.data.delta);
+  const endAfterExtentDelta = getAfterExtentDeltaByRange(endNodeDelta, endRange);
 
   return {
     startNode,
     endNode,
-    startDelta,
-    endDelta,
+    startDelta: startBeforeExtentDelta,
+    endDelta: endAfterExtentDelta,
   };
 }
+
 export function getMergeEndDeltaToStartActionsByRange(
   state: RootState,
   controller: DocumentController,
   insertDelta?: Delta
 ) {
   const actions = [];
-  const { startDelta, endDelta, endNode, startNode } = getStartAndEndDeltaExpectRange(state) || {};
+  const { startDelta, endDelta, endNode, startNode } = getStartAndEndExtentDelta(state) || {};
   if (!startDelta || !endDelta || !endNode || !startNode) return;
   // merge start and end nodes
   const mergeDelta = startDelta.concat(insertDelta || new Delta()).concat(endDelta);
@@ -109,6 +118,14 @@ export function getMergeEndDeltaToStartActionsByRange(
     })
   );
   if (endNode.id !== startNode.id) {
+    const children = state.document.children[endNode.children].map((id) => state.document.nodes[id]);
+
+    const moveChildrenActions = getMoveChildrenActions({
+      target: startNode,
+      children,
+      controller,
+    });
+    actions.push(...moveChildrenActions);
     // delete end node
     actions.push(controller.getDeleteAction(endNode));
   }
@@ -116,6 +133,26 @@ export function getMergeEndDeltaToStartActionsByRange(
   return actions;
 }
 
+export function getMoveChildrenActions({
+  target,
+  children,
+  controller,
+  prevId = '',
+}: {
+  target: NestedBlock;
+  children: NestedBlock[];
+  controller: DocumentController;
+  prevId?: string;
+}) {
+  // move children
+  const config = blockConfig[target.type];
+  const targetParentId = config.canAddChild ? target.id : target.parent;
+  if (!targetParentId) return [];
+  const targetPrevId = targetParentId === target.id ? prevId : target.id;
+  const moveActions = controller.getMoveChildrenAction(children, targetParentId, targetPrevId);
+  return moveActions;
+}
+
 export function getInsertEnterNodeFields(sourceNode: NestedBlock) {
   if (!sourceNode.parent) return;
   const parentId = sourceNode.parent;

+ 82 - 0
frontend/appflowy_tauri/src/appflowy_app/utils/document/copy_paste.ts

@@ -0,0 +1,82 @@
+import { BlockData, DocumentBlockJSON, DocumentState, NestedBlock, RangeState } from '$app/interfaces/document';
+import { getDeltaByRange } from '$app/utils/document/delta';
+import Delta from 'quill-delta';
+import { generateId } from '$app/utils/document/block';
+import { DocumentController } from '$app/stores/effects/document/document_controller';
+import { blockConfig } from '$app/constants/document/config';
+
+export function getCopyData(
+  node: NestedBlock,
+  range: {
+    index: number;
+    length: number;
+  }
+): BlockData<any> {
+  const nodeDeltaOps = node.data.delta;
+  if (!nodeDeltaOps) {
+    return {
+      ...node.data,
+    };
+  }
+  const delta = getDeltaByRange(new Delta(node.data.delta), range);
+  return {
+    ...node.data,
+    delta: delta.ops,
+  };
+}
+
+export function getCopyBlock(id: string, document: DocumentState, documentRange: RangeState): DocumentBlockJSON {
+  const node = document.nodes[id];
+  const range = documentRange.ranges[id] || { index: 0, length: 0 };
+  const copyData = getCopyData(node, range);
+  return {
+    type: node.type,
+    data: copyData,
+    children: [],
+  };
+}
+
+export function generateBlocks(data: DocumentBlockJSON[], parentId: string) {
+  const blocks: NestedBlock[] = [];
+  function dfs(data: DocumentBlockJSON[], parentId: string) {
+    data.forEach((item) => {
+      const block = {
+        id: generateId(),
+        type: item.type,
+        data: item.data,
+        parent: parentId,
+        children: generateId(),
+      };
+      blocks.push(block);
+      if (item.children) {
+        dfs(item.children, block.id);
+      }
+    });
+  }
+  dfs(data, parentId);
+  return blocks;
+}
+
+export function getInsertBlockActions(blocks: NestedBlock[], prevId: string, controller: DocumentController) {
+  return blocks.map((block, index) => {
+    const prevBlockId = index === 0 ? prevId : blocks[index - 1].id;
+    return controller.getInsertAction(block, prevBlockId);
+  });
+}
+
+export function getAppendBlockDeltaAction(
+  block: NestedBlock,
+  appendDelta: Delta,
+  isForward: boolean,
+  controller: DocumentController
+) {
+  const nodeDelta = new Delta(block.data.delta);
+  const mergeDelta = isForward ? appendDelta.concat(nodeDelta) : nodeDelta.concat(appendDelta);
+  return controller.getUpdateAction({
+    ...block,
+    data: {
+      ...block.data,
+      delta: mergeDelta.ops,
+    },
+  });
+}

+ 45 - 11
frontend/appflowy_tauri/src/appflowy_app/utils/document/delta.ts

@@ -1,10 +1,10 @@
-import Delta from "quill-delta";
+import Delta from 'quill-delta';
 
 export function getDeltaText(delta: Delta) {
   const text = delta
-    .filter((op) => typeof op.insert === "string")
+    .filter((op) => typeof op.insert === 'string')
     .map((op) => op.insert)
-    .join("");
+    .join('');
   return text;
 }
 
@@ -12,7 +12,7 @@ export function caretInTopEdgeByDelta(delta: Delta, index: number) {
   const text = getDeltaText(delta.slice(0, index));
   if (!text) return true;
 
-  const firstLine = text.split("\n")[0];
+  const firstLine = text.split('\n')[0];
   return index <= firstLine.length;
 }
 
@@ -20,14 +20,14 @@ export function caretInBottomEdgeByDelta(delta: Delta, index: number) {
   const text = getDeltaText(delta.slice(index));
 
   if (!text) return true;
-  return !text.includes("\n");
+  return !text.includes('\n');
 }
 
 export function getLineByIndex(delta: Delta, index: number) {
   const beforeText = getDeltaText(delta.slice(0, index));
   const afterText = getDeltaText(delta.slice(index));
-  const beforeLines = beforeText.split("\n");
-  const afterLines = afterText.split("\n");
+  const beforeLines = beforeText.split('\n');
+  const afterLines = afterText.split('\n');
 
   const startLineText = beforeLines[beforeLines.length - 1];
   const currentLineText = startLineText + afterLines[0];
@@ -39,7 +39,7 @@ export function getLineByIndex(delta: Delta, index: number) {
 
 export function transformIndexToPrevLine(delta: Delta, index: number) {
   const text = getDeltaText(delta.slice(0, index));
-  const lines = text.split("\n");
+  const lines = text.split('\n');
   if (lines.length < 2) return 0;
   const prevLineText = lines[lines.length - 2];
   const transformedIndex = index - prevLineText.length - 1;
@@ -59,13 +59,47 @@ export function transformIndexToNextLine(delta: Delta, index: number) {
 
 export function getIndexRelativeEnter(delta: Delta, index: number) {
   const text = getDeltaText(delta.slice(0, index));
-  const beforeLines = text.split("\n");
+  const beforeLines = text.split('\n');
   const beforeLineText = beforeLines[beforeLines.length - 1];
   return beforeLineText.length;
 }
 
 export function getLastLineIndex(delta: Delta) {
   const text = getDeltaText(delta);
-  const lastIndex = text.lastIndexOf("\n");
+  const lastIndex = text.lastIndexOf('\n');
   return lastIndex === -1 ? 0 : lastIndex + 1;
-}
+}
+
+export function getDeltaByRange(
+  delta: Delta,
+  range: {
+    index: number;
+    length: number;
+  }
+) {
+  const start = range.index;
+  const end = range.index + range.length;
+  return new Delta(delta.slice(start, end));
+}
+
+export function getBeofreExtentDeltaByRange(
+  delta: Delta,
+  range: {
+    index: number;
+    length: number;
+  }
+) {
+  const start = range.index;
+  return new Delta(delta.slice(0, start));
+}
+
+export function getAfterExtentDeltaByRange(
+  delta: Delta,
+  range: {
+    index: number;
+    length: number;
+  }
+) {
+  const start = range.index + range.length;
+  return new Delta(delta.slice(start));
+}

+ 28 - 0
frontend/appflowy_tauri/src/appflowy_app/utils/document/format.ts

@@ -0,0 +1,28 @@
+import isHotkey from 'is-hotkey';
+import { Keyboard } from '$app/constants/document/keyboard';
+import { TextAction } from '$app/interfaces/document';
+
+export function isFormatHotkey(e: KeyboardEvent | React.KeyboardEvent<HTMLDivElement>) {
+  return (
+    isHotkey(Keyboard.keys.FORMAT.BOLD, e) ||
+    isHotkey(Keyboard.keys.FORMAT.ITALIC, e) ||
+    isHotkey(Keyboard.keys.FORMAT.UNDERLINE, e) ||
+    isHotkey(Keyboard.keys.FORMAT.STRIKE, e) ||
+    isHotkey(Keyboard.keys.FORMAT.CODE, e)
+  );
+}
+
+export function parseFormat(e: KeyboardEvent | React.KeyboardEvent<HTMLDivElement>) {
+  if (isHotkey(Keyboard.keys.FORMAT.BOLD, e)) {
+    return TextAction.Bold;
+  } else if (isHotkey(Keyboard.keys.FORMAT.ITALIC, e)) {
+    return TextAction.Italic;
+  } else if (isHotkey(Keyboard.keys.FORMAT.UNDERLINE, e)) {
+    return TextAction.Underline;
+  } else if (isHotkey(Keyboard.keys.FORMAT.STRIKE, e)) {
+    return TextAction.Strikethrough;
+  } else if (isHotkey(Keyboard.keys.FORMAT.CODE, e)) {
+    return TextAction.Code;
+  }
+  return null;
+}

+ 2 - 3
frontend/appflowy_tauri/src/appflowy_app/utils/document/node.ts

@@ -145,7 +145,7 @@ export function isPointInBlock(target: HTMLElement | null) {
 
 export function findTextNode(
   node: Element,
-  index: number,
+  index: number
 ): {
   node?: Node;
   offset?: number;
@@ -191,7 +191,6 @@ export function focusNodeByIndex(node: Element, index: number, length: number) {
   selection?.addRange(range);
 }
 
-
 export function getNodeTextBoxByBlockId(blockId: string) {
   const node = getNode(blockId);
   return node?.querySelector(`[role="textbox"]`);
@@ -229,4 +228,4 @@ export function findParent(node: Element, parentSelector: string) {
     parentNode = parentNode.parentElement;
   }
   return null;
-}
+}