Przeglądaj źródła

feat: support to break wrap the text block when triggering shift+enter (#2360)

* fix: make TextBlock's keydown event code easier to maintain

* fix: support  to break wrap the text block
qinluhe 2 lat temu
rodzic
commit
243f062d4f

+ 108 - 97
frontend/appflowy_tauri/src/appflowy_app/components/document/TextBlock/TextBlock.hooks.ts

@@ -1,6 +1,5 @@
-import { triggerHotkey } from '@/appflowy_app/utils/slate/hotkey';
-import { useCallback, useContext } from 'react';
-import { Range, Editor, Element, Text, Location } from 'slate';
+import { useCallback, useContext, useMemo } from 'react';
+import { Editor } from 'slate';
 import { TextDelta, TextSelection } from '$app/interfaces/document';
 import { TextDelta, TextSelection } from '$app/interfaces/document';
 import { useTextInput } from '../_shared/TextInput.hooks';
 import { useTextInput } from '../_shared/TextInput.hooks';
 import { useAppDispatch } from '@/appflowy_app/stores/store';
 import { useAppDispatch } from '@/appflowy_app/stores/store';
@@ -11,74 +10,24 @@ import {
   splitNodeThunk,
   splitNodeThunk,
 } from '@/appflowy_app/stores/reducers/document/async_actions';
 } from '@/appflowy_app/stores/reducers/document/async_actions';
 import { documentActions } from '@/appflowy_app/stores/reducers/document/slice';
 import { documentActions } from '@/appflowy_app/stores/reducers/document/slice';
+import {
+  triggerHotkey,
+  canHandleEnterKey,
+  canHandleBackspaceKey,
+  canHandleTabKey,
+  onHandleEnterKey,
+} from '@/appflowy_app/utils/slate/hotkey';
+import { updateNodeDeltaThunk } from '$app/stores/reducers/document/async_actions/update';
 
 
 export function useTextBlock(id: string) {
 export function useTextBlock(id: string) {
   const { editor, onChange, value } = useTextInput(id);
   const { editor, onChange, value } = useTextInput(id);
-  const { onTab, onBackSpace, onEnter } = useActions(id);
-  const dispatch = useAppDispatch();
-
-  const keepSelection = 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 (!editor.selection || !editor.selection.anchor || !editor.selection.focus) return;
-    const { anchor, focus } = editor.selection;
-    const selection = { anchor, focus } as TextSelection;
-    dispatch(documentActions.setTextSelection({ blockId: id, selection }));
-  }, [editor]);
+  const { onKeyDown } = useTextBlockKeyEvent(id, editor);
 
 
   const onKeyDownCapture = useCallback(
   const onKeyDownCapture = useCallback(
     (event: React.KeyboardEvent<HTMLDivElement>) => {
     (event: React.KeyboardEvent<HTMLDivElement>) => {
-      switch (event.key) {
-        // It should be handled when `Enter` is pressed
-        case 'Enter': {
-          if (!editor.selection) return;
-          event.stopPropagation();
-          event.preventDefault();
-          // get the retain content
-          const retainRange = getRetainRangeBy(editor);
-          const retain = getDelta(editor, retainRange);
-          // get the insert content
-          const insertRange = getInsertRangeBy(editor);
-          const insert = getDelta(editor, insertRange);
-          void (async () => {
-            // retain this node and insert a new node
-            await onEnter(retain, insert);
-          })();
-          return;
-        }
-        // It should be handled when `Backspace` is pressed
-        case 'Backspace': {
-          if (!editor.selection) {
-            return;
-          }
-          // It should be handled if the selection is collapsed and the cursor is at the beginning of the block
-          const { anchor } = editor.selection;
-          const isCollapsed = Range.isCollapsed(editor.selection);
-          if (isCollapsed && anchor.offset === 0 && anchor.path.toString() === '0,0') {
-            event.stopPropagation();
-            event.preventDefault();
-            keepSelection();
-            void (async () => {
-              await onBackSpace();
-            })();
-          }
-          return;
-        }
-        // It should be handled when `Tab` is pressed
-        case 'Tab': {
-          event.stopPropagation();
-          event.preventDefault();
-          keepSelection();
-          void (async () => {
-            await onTab();
-          })();
-
-          return;
-        }
-      }
-      triggerHotkey(event, editor);
+      onKeyDown(event);
     },
     },
-    [editor, keepSelection, onEnter, onBackSpace, onTab]
+    [onKeyDown]
   );
   );
 
 
   const onDOMBeforeInput = useCallback((e: InputEvent) => {
   const onDOMBeforeInput = useCallback((e: InputEvent) => {
@@ -99,11 +48,90 @@ export function useTextBlock(id: string) {
   };
   };
 }
 }
 
 
+// eslint-disable-next-line no-shadow
+enum TextBlockKeyEvent {
+  Enter,
+  BackSpace,
+  Tab,
+}
+
+type TextBlockKeyEventHandlerParams = [React.KeyboardEvent<HTMLDivElement>, Editor];
+
+function useTextBlockKeyEvent(id: string, editor: Editor) {
+  const { indentAction, backSpaceAction, splitAction, wrapAction } = useActions(id);
+
+  const dispatch = useAppDispatch();
+  const keepSelection = 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 (!editor.selection || !editor.selection.anchor || !editor.selection.focus) return;
+    const { anchor, focus } = editor.selection;
+    const selection = { anchor, focus } as TextSelection;
+    dispatch(documentActions.setTextSelection({ blockId: id, selection }));
+  }, [editor]);
+
+  const enterEvent = useMemo(() => {
+    return {
+      key: TextBlockKeyEvent.Enter,
+      canHandle: canHandleEnterKey,
+      handler: (...args: TextBlockKeyEventHandlerParams) => {
+        onHandleEnterKey(...args, {
+          onSplit: splitAction,
+          onWrap: wrapAction,
+        });
+      },
+    };
+  }, [splitAction, wrapAction]);
+
+  const tabEvent = useMemo(() => {
+    return {
+      key: TextBlockKeyEvent.Tab,
+      canHandle: canHandleTabKey,
+      handler: (..._args: TextBlockKeyEventHandlerParams) => {
+        keepSelection();
+        void indentAction();
+      },
+    };
+  }, [keepSelection, indentAction]);
+
+  const backSpaceEvent = useMemo(() => {
+    return {
+      key: TextBlockKeyEvent.BackSpace,
+      canHandle: canHandleBackspaceKey,
+      handler: (..._args: TextBlockKeyEventHandlerParams) => {
+        keepSelection();
+        void backSpaceAction();
+      },
+    };
+  }, [keepSelection, backSpaceAction]);
+
+  const onKeyDown = useCallback(
+    (event: React.KeyboardEvent<HTMLDivElement>) => {
+      // This is list of key events that can be handled by TextBlock
+      const keyEvents = [enterEvent, backSpaceEvent, tabEvent];
+      const matchKey = keyEvents.find((keyEvent) => keyEvent.canHandle(event, editor));
+      if (!matchKey) {
+        triggerHotkey(event, editor);
+        return;
+      }
+
+      event.stopPropagation();
+      event.preventDefault();
+      matchKey.handler(event, editor);
+    },
+    [editor, enterEvent, backSpaceEvent, tabEvent]
+  );
+
+  return {
+    onKeyDown,
+  };
+}
+
 function useActions(id: string) {
 function useActions(id: string) {
   const dispatch = useAppDispatch();
   const dispatch = useAppDispatch();
   const controller = useContext(DocumentControllerContext);
   const controller = useContext(DocumentControllerContext);
 
 
-  const onTab = useCallback(async () => {
+  const indentAction = useCallback(async () => {
     if (!controller) return;
     if (!controller) return;
     await dispatch(
     await dispatch(
       indentNodeThunk({
       indentNodeThunk({
@@ -113,12 +141,12 @@ function useActions(id: string) {
     );
     );
   }, [id, controller]);
   }, [id, controller]);
 
 
-  const onBackSpace = useCallback(async () => {
+  const backSpaceAction = useCallback(async () => {
     if (!controller) return;
     if (!controller) return;
     await dispatch(backspaceNodeThunk({ id, controller }));
     await dispatch(backspaceNodeThunk({ id, controller }));
   }, [controller, id]);
   }, [controller, id]);
 
 
-  const onEnter = useCallback(
+  const splitAction = useCallback(
     async (retain: TextDelta[], insert: TextDelta[]) => {
     async (retain: TextDelta[], insert: TextDelta[]) => {
       if (!controller) return;
       if (!controller) return;
       await dispatch(splitNodeThunk({ id, retain, insert, controller }));
       await dispatch(splitNodeThunk({ id, retain, insert, controller }));
@@ -126,37 +154,20 @@ function useActions(id: string) {
     [controller, id]
     [controller, id]
   );
   );
 
 
-  return {
-    onTab,
-    onBackSpace,
-    onEnter,
-  };
-}
-
-function getDelta(editor: Editor, at: Location): TextDelta[] {
-  const baseElement = Editor.fragment(editor, at)[0] as Element;
-  return baseElement.children.map((item) => {
-    const { text, ...attributes } = item as Text;
-    return {
-      insert: text,
-      attributes,
-    };
-  });
-}
-
-function getRetainRangeBy(editor: Editor) {
-  const start = Editor.start(editor, editor.selection!);
-  return {
-    anchor: { path: [0, 0], offset: 0 },
-    focus: start,
-  };
-}
+  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]
+  );
 
 
-function getInsertRangeBy(editor: Editor) {
-  const end = Editor.end(editor, editor.selection!);
-  const fragment = (editor.children[0] as Element).children;
   return {
   return {
-    anchor: end,
-    focus: { path: [0, fragment.length - 1], offset: (fragment[fragment.length - 1] as Text).text.length },
+    indentAction,
+    backSpaceAction,
+    splitAction,
+    wrapAction,
   };
   };
 }
 }

+ 71 - 5
frontend/appflowy_tauri/src/appflowy_app/utils/slate/hotkey.ts

@@ -1,6 +1,8 @@
 import isHotkey from 'is-hotkey';
 import isHotkey from 'is-hotkey';
 import { toggleFormat } from './format';
 import { toggleFormat } from './format';
-import { Editor } from 'slate';
+import { Editor, Range } from 'slate';
+import { getRetainRangeBy, getDelta, getInsertRangeBy } from './text';
+import { TextDelta, TextSelection } from '$app/interfaces/document';
 
 
 const HOTKEYS: Record<string, string> = {
 const HOTKEYS: Record<string, string> = {
   'mod+b': 'bold',
   'mod+b': 'bold',
@@ -14,9 +16,73 @@ const HOTKEYS: Record<string, string> = {
 export function triggerHotkey(event: React.KeyboardEvent<HTMLDivElement>, editor: Editor) {
 export function triggerHotkey(event: React.KeyboardEvent<HTMLDivElement>, editor: Editor) {
   for (const hotkey in HOTKEYS) {
   for (const hotkey in HOTKEYS) {
     if (isHotkey(hotkey, event)) {
     if (isHotkey(hotkey, event)) {
-      event.preventDefault()
-      const format = HOTKEYS[hotkey]
-      toggleFormat(editor, format)
+      event.preventDefault();
+      const format = HOTKEYS[hotkey];
+      toggleFormat(editor, format);
     }
     }
   }
   }
-}
+}
+
+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 = event.key === 'Backspace';
+  const selection = editor.selection;
+  if (!isBackspaceKey || !selection) {
+    return false;
+  }
+  // It should be handled if the selection is collapsed and the cursor is at the beginning of the block
+  const { anchor } = selection;
+  const isCollapsed = Range.isCollapsed(selection);
+  return isCollapsed && anchor.offset === 0 && anchor.path.toString() === '0,0';
+}
+
+export function canHandleTabKey(event: React.KeyboardEvent<HTMLDivElement>, _: Editor) {
+  return event.key === 'Tab';
+}
+
+export function onHandleEnterKey(
+  event: React.KeyboardEvent<HTMLDivElement>,
+  editor: Editor,
+  {
+    onSplit,
+    onWrap,
+  }: {
+    onSplit: (...args: [TextDelta[], TextDelta[]]) => Promise<void>;
+    onWrap: (newDelta: TextDelta[], selection: TextSelection) => Promise<void>;
+  }
+) {
+  // get the retain content
+  const retainRange = getRetainRangeBy(editor);
+  const retain = getDelta(editor, retainRange);
+  // get the insert content
+  const insertRange = getInsertRangeBy(editor);
+  const insert = getDelta(editor, insertRange);
+
+  // if the shift key is pressed, break wrap the current node
+  if (event.shiftKey || event.ctrlKey || event.altKey) {
+    const selection = getSelectionAfterBreakWrap(editor);
+    if (!selection) return;
+    // insert `\n` after the retain content
+    void onWrap([...retain, { insert: '\n' }, ...insert], selection);
+    return;
+  }
+
+  // retain this node and insert a new node
+  void onSplit(retain, insert);
+}
+
+function getSelectionAfterBreakWrap(editor: Editor) {
+  const selection = editor.selection;
+  if (!selection) return;
+  const start = Range.start(selection);
+  const cursor = { ...start, offset: start.offset + 1 };
+  const newSelection = {
+    anchor: Object.create(cursor),
+    focus: Object.create(cursor),
+  } as TextSelection;
+  return newSelection;
+}

+ 32 - 0
frontend/appflowy_tauri/src/appflowy_app/utils/slate/text.ts

@@ -0,0 +1,32 @@
+import { Editor, Element, Text, Location } from 'slate';
+import { TextDelta } from '$app/interfaces/document';
+
+export function getDelta(editor: Editor, at: Location): TextDelta[] {
+  const baseElement = Editor.fragment(editor, at)[0] as Element;
+  return baseElement.children.map((item) => {
+    const { text, ...attributes } = item as Text;
+    return {
+      insert: text,
+      attributes,
+    };
+  });
+}
+
+export function getRetainRangeBy(editor: Editor) {
+  const start = Editor.start(editor, editor.selection!);
+  return {
+    anchor: { path: [0, 0], offset: 0 },
+    focus: start,
+  };
+}
+
+export function getInsertRangeBy(editor: Editor) {
+  const end = Editor.end(editor, editor.selection!);
+  const fragment = (editor.children[0] as Element).children;
+  const lastIndex = fragment.length - 1;
+  const lastNode = fragment[lastIndex] as Text;
+  return {
+    anchor: end,
+    focus: { path: [0, lastIndex], offset: lastNode.text.length },
+  };
+}