Browse Source

fix: support up/down/left/right keyboard event to move cursor (#2365)

qinluhe 2 years ago
parent
commit
070ac051b1

+ 79 - 13
frontend/appflowy_tauri/src/appflowy_app/components/document/TextBlock/TextBlock.hooks.ts

@@ -16,8 +16,14 @@ import {
   canHandleBackspaceKey,
   canHandleTabKey,
   onHandleEnterKey,
+  keyBoardEventKeyMap,
+  canHandleUpKey,
+  canHandleDownKey,
+  canHandleLeftKey,
+  canHandleRightKey,
 } from '@/appflowy_app/utils/slate/hotkey';
 import { updateNodeDeltaThunk } from '$app/stores/reducers/document/async_actions/update';
+import { setCursorPreLineThunk, setCursorNextLineThunk } from '$app/stores/reducers/document/async_actions/set_cursor';
 
 export function useTextBlock(id: string) {
   const { editor, onChange, value } = useTextInput(id);
@@ -48,17 +54,11 @@ 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 { indentAction, backSpaceAction, splitAction, wrapAction, focusPreLineAction, focusNextLineAction } =
+    useActions(id);
 
   const dispatch = useAppDispatch();
   const keepSelection = useCallback(() => {
@@ -72,7 +72,7 @@ function useTextBlockKeyEvent(id: string, editor: Editor) {
 
   const enterEvent = useMemo(() => {
     return {
-      key: TextBlockKeyEvent.Enter,
+      key: keyBoardEventKeyMap.Enter,
       canHandle: canHandleEnterKey,
       handler: (...args: TextBlockKeyEventHandlerParams) => {
         onHandleEnterKey(...args, {
@@ -85,7 +85,7 @@ function useTextBlockKeyEvent(id: string, editor: Editor) {
 
   const tabEvent = useMemo(() => {
     return {
-      key: TextBlockKeyEvent.Tab,
+      key: keyBoardEventKeyMap.Tab,
       canHandle: canHandleTabKey,
       handler: (..._args: TextBlockKeyEventHandlerParams) => {
         keepSelection();
@@ -96,7 +96,7 @@ function useTextBlockKeyEvent(id: string, editor: Editor) {
 
   const backSpaceEvent = useMemo(() => {
     return {
-      key: TextBlockKeyEvent.BackSpace,
+      key: keyBoardEventKeyMap.Backspace,
       canHandle: canHandleBackspaceKey,
       handler: (..._args: TextBlockKeyEventHandlerParams) => {
         keepSelection();
@@ -105,10 +105,60 @@ function useTextBlockKeyEvent(id: string, editor: Editor) {
     };
   }, [keepSelection, backSpaceAction]);
 
+  const upEvent = useMemo(() => {
+    return {
+      key: keyBoardEventKeyMap.Up,
+      canHandle: canHandleUpKey,
+      handler: (...args: TextBlockKeyEventHandlerParams) => {
+        void focusPreLineAction({
+          editor: args[1],
+        });
+      },
+    };
+  }, [focusPreLineAction]);
+
+  const downEvent = useMemo(() => {
+    return {
+      key: keyBoardEventKeyMap.Down,
+      canHandle: canHandleDownKey,
+      handler: (...args: TextBlockKeyEventHandlerParams) => {
+        void focusNextLineAction({
+          editor: args[1],
+        });
+      },
+    };
+  }, [focusNextLineAction]);
+
+  const leftEvent = useMemo(() => {
+    return {
+      key: keyBoardEventKeyMap.Left,
+      canHandle: canHandleLeftKey,
+      handler: (...args: TextBlockKeyEventHandlerParams) => {
+        void focusPreLineAction({
+          editor: args[1],
+          focusEnd: true,
+        });
+      },
+    };
+  }, [focusPreLineAction]);
+
+  const rightEvent = useMemo(() => {
+    return {
+      key: keyBoardEventKeyMap.Right,
+      canHandle: canHandleRightKey,
+      handler: (...args: TextBlockKeyEventHandlerParams) => {
+        void focusNextLineAction({
+          editor: args[1],
+          focusStart: true,
+        });
+      },
+    };
+  }, [focusNextLineAction]);
+
   const onKeyDown = useCallback(
     (event: React.KeyboardEvent<HTMLDivElement>) => {
       // This is list of key events that can be handled by TextBlock
-      const keyEvents = [enterEvent, backSpaceEvent, tabEvent];
+      const keyEvents = [enterEvent, backSpaceEvent, tabEvent, upEvent, downEvent, leftEvent, rightEvent];
       const matchKey = keyEvents.find((keyEvent) => keyEvent.canHandle(event, editor));
       if (!matchKey) {
         triggerHotkey(event, editor);
@@ -119,7 +169,7 @@ function useTextBlockKeyEvent(id: string, editor: Editor) {
       event.preventDefault();
       matchKey.handler(event, editor);
     },
-    [editor, enterEvent, backSpaceEvent, tabEvent]
+    [editor, enterEvent, backSpaceEvent, tabEvent, upEvent, downEvent, leftEvent, rightEvent]
   );
 
   return {
@@ -164,10 +214,26 @@ function useActions(id: string) {
     [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,
   };
 }

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

@@ -161,13 +161,16 @@ function setSelection(editor: ReactEditor, currentSelection: TextSelection) {
   const { path, offset } = currentSelection.focus;
   // It is possible that the current selection is out of range
   const children = getDeltaFromSlateNodes(editor.children);
-  if (children[path[1]].insert.length < offset) {
+
+  // the path always has 2 elements,
+  // because the slate node is a two-dimensional array
+  const index = path[1];
+  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);
-  editor.selection = currentSelection;
   ReactEditor.focus(editor);
 }

+ 3 - 9
frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async_actions/backspace.ts

@@ -4,6 +4,7 @@ import { createAsyncThunk } from '@reduxjs/toolkit';
 import { documentActions } from '../slice';
 import { outdentNodeThunk } from './outdent';
 import { setCursorAfterThunk } from './set_cursor';
+import { getPrevLineId } from '$app/utils/block';
 
 const composeNodeThunk = createAsyncThunk(
   'document/composeNode',
@@ -65,15 +66,8 @@ const composePrevNodeThunk = createAsyncThunk(
     const { id, prevNodeId, controller } = payload;
     const { dispatch, getState } = thunkAPI;
     const state = (getState() as { document: DocumentState }).document;
-    const prevNode = state.nodes[prevNodeId];
-    if (!prevNode) return;
-    // find prev line
-    let prevLineId = prevNode.id;
-    while (prevLineId) {
-      const prevLineChildren = state.children[state.nodes[prevLineId].children];
-      if (prevLineChildren.length === 0) break;
-      prevLineId = prevLineChildren[prevLineChildren.length - 1];
-    }
+    const prevLineId = getPrevLineId(state, id);
+    if (!prevLineId) return;
     await dispatch(composeNodeThunk({ id: id, composeId: prevLineId, controller }));
   }
 );

+ 78 - 25
frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async_actions/set_cursor.ts

@@ -1,22 +1,23 @@
 import { createAsyncThunk } from '@reduxjs/toolkit';
 import { documentActions } from '../slice';
-import { DocumentState, SelectionPoint, TextSelection } from '$app/interfaces/document';
+import { DocumentState, TextSelection } from '$app/interfaces/document';
+import { getNextLineId, getPrevLineId } from '$app/utils/block';
+import { Editor } from 'slate';
+import {
+  getBeforeRangeAt,
+  getEndLineSelectionByOffset,
+  getLastLineOffsetByDelta,
+  getNodeBeginSelection,
+  getNodeEndSelection,
+  getStartLineSelectionByOffset,
+} from '$app/utils/slate/text';
 
 export const setCursorBeforeThunk = createAsyncThunk(
   'document/setCursorBefore',
   async (payload: { id: string }, thunkAPI) => {
     const { id } = payload;
     const { dispatch } = thunkAPI;
-    const selection: TextSelection = {
-      anchor: {
-        path: [0, 0],
-        offset: 0,
-      },
-      focus: {
-        path: [0, 0],
-        offset: 0,
-      },
-    };
+    const selection = getNodeBeginSelection();
     dispatch(documentActions.setTextSelection({ blockId: id, selection }));
   }
 );
@@ -28,20 +29,72 @@ export const setCursorAfterThunk = createAsyncThunk(
     const { dispatch, getState } = thunkAPI;
     const state = (getState() as { document: DocumentState }).document;
     const node = state.nodes[id];
-    const len = node.data.delta?.length || 0;
-    const offset = len > 0 ? node.data.delta[len - 1].insert.length : 0;
-    const cursorPoint: SelectionPoint = {
-      path: [0, len > 0 ? len - 1 : 0],
-      offset,
-    };
-    const selection: TextSelection = {
-      anchor: {
-        ...cursorPoint,
-      },
-      focus: {
-        ...cursorPoint,
-      },
-    };
+    const selection = getNodeEndSelection(node.data.delta);
     dispatch(documentActions.setTextSelection({ blockId: node.id, selection }));
   }
 );
+
+export const setCursorPreLineThunk = createAsyncThunk(
+  'document/setCursorPreLine',
+  async (payload: { id: string; editor: Editor; focusEnd?: boolean }, thunkAPI) => {
+    const { id, editor, focusEnd } = payload;
+    const selection = editor.selection as TextSelection;
+    const { dispatch, getState } = thunkAPI;
+    const state = (getState() as { document: DocumentState }).document;
+    const prevId = getPrevLineId(state, id);
+    if (!prevId) return;
+    const prevLineNode = state.nodes[prevId];
+
+    // if prev line have no delta, just set block is selected
+    if (!prevLineNode.data.delta) {
+      dispatch(documentActions.setSelectionById(prevId));
+      return;
+    }
+
+    // whatever the selection is, set cursor to the end of prev line when focusEnd is true
+    if (focusEnd) {
+      await dispatch(setCursorAfterThunk({ id: prevLineNode.id }));
+      return;
+    }
+
+    const range = getBeforeRangeAt(editor, selection);
+    const textOffset = Editor.string(editor, range).length;
+
+    // set the cursor to prev line with the relative offset
+    const newSelection = getEndLineSelectionByOffset(prevLineNode.data.delta, textOffset);
+    dispatch(documentActions.setTextSelection({ blockId: prevLineNode.id, selection: newSelection }));
+  }
+);
+
+export const setCursorNextLineThunk = createAsyncThunk(
+  'document/setCursorNextLine',
+  async (payload: { id: string; editor: Editor; focusStart?: boolean }, thunkAPI) => {
+    const { id, editor, focusStart } = payload;
+    const selection = editor.selection as TextSelection;
+    const { dispatch, getState } = thunkAPI;
+    const state = (getState() as { document: DocumentState }).document;
+    const node = state.nodes[id];
+    const nextId = getNextLineId(state, id);
+    if (!nextId) return;
+    const nextLineNode = state.nodes[nextId];
+    const delta = nextLineNode.data.delta;
+    // if next line have no delta, just set block is selected
+    if (!delta) {
+      dispatch(documentActions.setSelectionById(nextId));
+      return;
+    }
+
+    // whatever the selection is, set cursor to the start of next line when focusStart is true
+    if (focusStart) {
+      await dispatch(setCursorBeforeThunk({ id: nextLineNode.id }));
+      return;
+    }
+
+    const range = getBeforeRangeAt(editor, selection);
+    const textOffset = Editor.string(editor, range).length - getLastLineOffsetByDelta(node.data.delta);
+
+    // set the cursor to next line with the relative offset
+    const newSelection = getStartLineSelectionByOffset(delta, textOffset);
+    dispatch(documentActions.setTextSelection({ blockId: nextLineNode.id, selection: newSelection }));
+  }
+);

+ 48 - 1
frontend/appflowy_tauri/src/appflowy_app/utils/block.ts

@@ -1,7 +1,7 @@
 import { BlockPB } from '@/services/backend/models/flowy-document2';
 import { nanoid } from 'nanoid';
 import { Descendant, Element, Text } from 'slate';
-import { BlockType, TextDelta } from '../interfaces/document';
+import { BlockType, DocumentState, NestedBlock, TextDelta } from '../interfaces/document';
 import { Log } from './log';
 export function generateId() {
   return nanoid(10);
@@ -52,3 +52,50 @@ export function blockPB2Node(block: BlockPB) {
   };
   return node;
 }
+
+export function getPrevLineId(state: DocumentState, id: string) {
+  const node = state.nodes[id];
+  if (!node.parent) return;
+  const parent = state.nodes[node.parent];
+  const children = state.children[parent.children];
+  const index = children.indexOf(id);
+  const prevNodeId = children[index - 1];
+  const prevNode = state.nodes[prevNodeId];
+  if (!prevNode) {
+    return parent.id;
+  }
+  // find prev line
+  let prevLineId = prevNode.id;
+  while (prevLineId) {
+    const prevLineChildren = state.children[state.nodes[prevLineId].children];
+    if (prevLineChildren.length === 0) break;
+    prevLineId = prevLineChildren[prevLineChildren.length - 1];
+  }
+  return prevLineId || parent.id;
+}
+
+export function getNextLineId(state: DocumentState, id: string) {
+  const node = state.nodes[id];
+  if (!node.parent) return;
+
+  const firstChild = state.children[node.children][0];
+  if (firstChild) return firstChild;
+
+  let nextNodeId = getNextNodeId(state, id);
+  let parent: NestedBlock | null = state.nodes[node.parent];
+  while (!nextNodeId && parent) {
+    nextNodeId = getNextNodeId(state, parent.id);
+    parent = parent.parent ? state.nodes[parent.parent] : null;
+  }
+  return nextNodeId;
+}
+
+export function getNextNodeId(state: DocumentState, id: string) {
+  const node = state.nodes[id];
+  if (!node.parent) return;
+  const parent = state.nodes[node.parent];
+  const children = state.children[parent.children];
+  const index = children.indexOf(id);
+  const nextNodeId = children[index + 1];
+  return nextNodeId;
+}

+ 92 - 18
frontend/appflowy_tauri/src/appflowy_app/utils/slate/hotkey.ts

@@ -1,8 +1,8 @@
 import isHotkey from 'is-hotkey';
 import { toggleFormat } from './format';
 import { Editor, Range } from 'slate';
-import { getRetainRangeBy, getDelta, getInsertRangeBy } from './text';
-import { TextDelta, TextSelection } from '$app/interfaces/document';
+import { getBeforeRangeAt, getDelta, getAfterRangeAt, pointInEnd, pointInBegin, clonePoint } from './text';
+import { SelectionPoint, TextDelta, TextSelection } from '$app/interfaces/document';
 
 const HOTKEYS: Record<string, string> = {
   'mod+b': 'bold',
@@ -13,6 +13,16 @@ const HOTKEYS: Record<string, string> = {
   'mod+shift+S': 'strikethrough',
 };
 
+export const keyBoardEventKeyMap = {
+  Enter: 'Enter',
+  Backspace: 'Backspace',
+  Tab: 'Tab',
+  Up: 'ArrowUp',
+  Down: 'ArrowDown',
+  Left: 'ArrowLeft',
+  Right: 'ArrowRight',
+};
+
 export function triggerHotkey(event: React.KeyboardEvent<HTMLDivElement>, editor: Editor) {
   for (const hotkey in HOTKEYS) {
     if (isHotkey(hotkey, event)) {
@@ -29,19 +39,73 @@ export function canHandleEnterKey(event: React.KeyboardEvent<HTMLDivElement>, ed
 }
 
 export function canHandleBackspaceKey(event: React.KeyboardEvent<HTMLDivElement>, editor: Editor) {
-  const isBackspaceKey = event.key === 'Backspace';
+  const isBackspaceKey = isHotkey('backspace', event);
   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';
+  return isCollapsed && pointInBegin(editor, selection);
 }
 
 export function canHandleTabKey(event: React.KeyboardEvent<HTMLDivElement>, _: Editor) {
-  return event.key === 'Tab';
+  return isHotkey('tab', event);
+}
+
+export function canHandleUpKey(event: React.KeyboardEvent<HTMLDivElement>, editor: Editor) {
+  const isUpKey = event.key === keyBoardEventKeyMap.Up;
+  const selection = editor.selection;
+  if (!isUpKey || !selection) {
+    return false;
+  }
+  // It should be handled if the selection is collapsed and the cursor is at the first line of the block
+  const isCollapsed = Range.isCollapsed(selection);
+
+  const beforeString = Editor.string(editor, getBeforeRangeAt(editor, selection));
+  const isTopEdge = !beforeString.includes('\n');
+
+  return isCollapsed && isTopEdge;
+}
+
+export function canHandleDownKey(event: React.KeyboardEvent<HTMLDivElement>, editor: Editor) {
+  const isDownKey = event.key === keyBoardEventKeyMap.Down;
+  const selection = editor.selection;
+  if (!isDownKey || !selection) {
+    return false;
+  }
+  // It should be handled if the selection is collapsed and the cursor is at the last line of the block
+  const isCollapsed = Range.isCollapsed(selection);
+
+  const afterString = Editor.string(editor, getAfterRangeAt(editor, selection));
+  const isBottomEdge = !afterString.includes('\n');
+
+  return isCollapsed && isBottomEdge;
+}
+
+export function canHandleLeftKey(event: React.KeyboardEvent<HTMLDivElement>, editor: Editor) {
+  const isLeftKey = event.key === keyBoardEventKeyMap.Left;
+  const selection = editor.selection;
+  if (!isLeftKey || !selection) {
+    return false;
+  }
+
+  // It should be handled if the selection is collapsed and the cursor is at the beginning of the block
+  const isCollapsed = Range.isCollapsed(selection);
+
+  return isCollapsed && pointInBegin(editor, selection);
+}
+
+export function canHandleRightKey(event: React.KeyboardEvent<HTMLDivElement>, editor: Editor) {
+  const isRightKey = event.key === keyBoardEventKeyMap.Right;
+  const selection = editor.selection;
+  if (!isRightKey || !selection) {
+    return false;
+  }
+  // It should be handled if the selection is collapsed and the cursor is at the end of the block
+  const isCollapsed = Range.isCollapsed(selection);
+  return isCollapsed && pointInEnd(editor, selection);
 }
 
 export function onHandleEnterKey(
@@ -52,37 +116,47 @@ export function onHandleEnterKey(
     onWrap,
   }: {
     onSplit: (...args: [TextDelta[], TextDelta[]]) => Promise<void>;
-    onWrap: (newDelta: TextDelta[], selection: TextSelection) => Promise<void>;
+    onWrap: (newDelta: TextDelta[], _selection: TextSelection) => Promise<void>;
   }
 ) {
+  const selection = editor.selection;
+  if (!selection) return;
   // get the retain content
-  const retainRange = getRetainRangeBy(editor);
+  const retainRange = getBeforeRangeAt(editor, selection);
   const retain = getDelta(editor, retainRange);
   // get the insert content
-  const insertRange = getInsertRangeBy(editor);
+  const insertRange = getAfterRangeAt(editor, selection);
   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;
+  if (isHotkey('shift+enter', event)) {
+    const newSelection = getSelectionAfterBreakWrap(editor);
+    if (!newSelection) return;
+
     // insert `\n` after the retain content
-    void onWrap([...retain, { insert: '\n' }, ...insert], selection);
+    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;
   }
 
-  // retain this node and insert a new node
-  void onSplit(retain, insert);
+  // other cases, do nothing
+  return;
 }
 
 function getSelectionAfterBreakWrap(editor: Editor) {
   const selection = editor.selection;
   if (!selection) return;
   const start = Range.start(selection);
-  const cursor = { ...start, offset: start.offset + 1 };
+  const cursor = { path: start.path, offset: start.offset + 1 } as SelectionPoint;
   const newSelection = {
-    anchor: Object.create(cursor),
-    focus: Object.create(cursor),
+    anchor: clonePoint(cursor),
+    focus: clonePoint(cursor),
   } as TextSelection;
   return newSelection;
 }

+ 173 - 5
frontend/appflowy_tauri/src/appflowy_app/utils/slate/text.ts

@@ -1,5 +1,5 @@
 import { Editor, Element, Text, Location } from 'slate';
-import { TextDelta } from '$app/interfaces/document';
+import { SelectionPoint, TextDelta, TextSelection } from '$app/interfaces/document';
 
 export function getDelta(editor: Editor, at: Location): TextDelta[] {
   const baseElement = Editor.fragment(editor, at)[0] as Element;
@@ -12,16 +12,28 @@ export function getDelta(editor: Editor, at: Location): TextDelta[] {
   });
 }
 
-export function getRetainRangeBy(editor: Editor) {
-  const start = Editor.start(editor, editor.selection!);
+/**
+ * get the selection between the beginning of the editor and the point
+ * form 0 to point
+ * @param editor
+ * @param at
+ */
+export function getBeforeRangeAt(editor: Editor, at: Location) {
+  const start = Editor.start(editor, at);
   return {
     anchor: { path: [0, 0], offset: 0 },
     focus: start,
   };
 }
 
-export function getInsertRangeBy(editor: Editor) {
-  const end = Editor.end(editor, editor.selection!);
+/**
+ * get the selection between the point and the end of the editor
+ * from point to end
+ * @param editor
+ * @param at
+ */
+export function getAfterRangeAt(editor: Editor, at: Location) {
+  const end = Editor.end(editor, at);
   const fragment = (editor.children[0] as Element).children;
   const lastIndex = fragment.length - 1;
   const lastNode = fragment[lastIndex] as Text;
@@ -30,3 +42,159 @@ export function getInsertRangeBy(editor: Editor) {
     focus: { path: [0, lastIndex], offset: lastNode.text.length },
   };
 }
+
+/**
+ * check if the point is in the beginning of the editor
+ * @param editor
+ * @param at
+ */
+export function pointInBegin(editor: Editor, at: Location) {
+  const start = Editor.start(editor, at);
+  return Editor.before(editor, start) === undefined;
+}
+
+/**
+ * check if the point is in the end of the editor
+ * @param editor
+ * @param at
+ */
+export function pointInEnd(editor: Editor, at: Location) {
+  const end = Editor.end(editor, at);
+  return Editor.after(editor, end) === undefined;
+}
+
+/**
+ * get the selection of the beginning of the node
+ */
+export function getNodeBeginSelection(): TextSelection {
+  const point: SelectionPoint = {
+    path: [0, 0],
+    offset: 0,
+  };
+  const selection: TextSelection = {
+    anchor: clonePoint(point),
+    focus: clonePoint(point),
+  };
+  return selection;
+}
+
+/**
+ * get the selection of the end of the node
+ * @param delta
+ */
+export function getNodeEndSelection(delta: TextDelta[]) {
+  const len = delta.length;
+  const offset = len > 0 ? delta[len - 1].insert.length : 0;
+
+  const cursorPoint: SelectionPoint = {
+    path: [0, Math.max(len - 1, 0)],
+    offset,
+  };
+
+  const selection: TextSelection = {
+    anchor: clonePoint(cursorPoint),
+    focus: clonePoint(cursorPoint),
+  };
+  return selection;
+}
+
+/**
+ * get lines by delta
+ * @param delta
+ */
+export function getLinesByDelta(delta: TextDelta[]): string[] {
+  const text = delta.map((item) => item.insert).join('');
+  return text.split('\n');
+}
+
+/**
+ * get the offset of the last line
+ * @param delta
+ */
+export function getLastLineOffsetByDelta(delta: TextDelta[]): number {
+  const text = delta.map((item) => item.insert).join('');
+  const index = text.lastIndexOf('\n');
+  return index === -1 ? 0 : index + 1;
+}
+
+/**
+ * get the selection of the end line by offset
+ * @param delta
+ * @param offset relative offset of the end line
+ */
+export function getEndLineSelectionByOffset(delta: TextDelta[], offset: number) {
+  const lines = getLinesByDelta(delta);
+  const endLine = lines[lines.length - 1];
+  // if the offset is greater than the length of the end line, set cursor to the end of prev line
+  if (offset >= endLine.length) {
+    return getNodeEndSelection(delta);
+  }
+
+  const textOffset = getLastLineOffsetByDelta(delta) + offset;
+  return getSelectionByTextOffset(delta, textOffset);
+}
+
+/**
+ * get the selection of the start line by offset
+ * @param delta
+ * @param offset relative offset of the start line
+ */
+export function getStartLineSelectionByOffset(delta: TextDelta[], offset: number) {
+  const lines = getLinesByDelta(delta);
+  if (lines.length === 0) {
+    return getNodeBeginSelection();
+  }
+  const startLine = lines[0];
+  // if the offset is greater than the length of the end line, set cursor to the end of prev line
+  if (offset >= startLine.length) {
+    return getSelectionByTextOffset(delta, startLine.length);
+  }
+
+  return getSelectionByTextOffset(delta, offset);
+}
+
+/**
+ * get the selection by text offset
+ * @param delta
+ * @param offset absolute offset
+ */
+export function getSelectionByTextOffset(delta: TextDelta[], offset: number) {
+  const point = getPointByTextOffset(delta, offset);
+  const selection: TextSelection = {
+    anchor: clonePoint(point),
+    focus: clonePoint(point),
+  };
+  return selection;
+}
+
+/**
+ * get the point by text offset
+ * @param delta
+ * @param offset absolute offset
+ */
+export function getPointByTextOffset(delta: TextDelta[], offset: number): SelectionPoint {
+  let textOffset = 0;
+  let path: [number, number] = [0, 0];
+  let textLength = 0;
+  for (let i = 0; i < delta.length; i++) {
+    const item = delta[i];
+    if (textOffset + item.insert.length >= offset) {
+      path = [0, i];
+      textLength = offset - textOffset;
+      break;
+    }
+    textOffset += item.insert.length;
+  }
+
+  return {
+    path,
+    offset: textLength,
+  };
+}
+
+export function clonePoint(point: SelectionPoint): SelectionPoint {
+  return {
+    path: [...point.path],
+    offset: point.offset,
+  };
+}