diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/TextBlock/TextBlock.hooks.ts b/frontend/appflowy_tauri/src/appflowy_app/components/document/TextBlock/TextBlock.hooks.ts index 9a3a0a7341..63cbc58ce0 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/TextBlock/TextBlock.hooks.ts +++ b/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, 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) => { // 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, }; } diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/TextInput.hooks.ts b/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/TextInput.hooks.ts index 0d3d32d535..fba074b561 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/TextInput.hooks.ts +++ b/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); } diff --git a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async_actions/backspace.ts b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async_actions/backspace.ts index 9445a4da86..b9ee8403e5 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async_actions/backspace.ts +++ b/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 })); } ); diff --git a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async_actions/set_cursor.ts b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async_actions/set_cursor.ts index b222decfb9..98e4b293d3 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async_actions/set_cursor.ts +++ b/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 })); + } +); diff --git a/frontend/appflowy_tauri/src/appflowy_app/utils/block.ts b/frontend/appflowy_tauri/src/appflowy_app/utils/block.ts index 639428dbda..557959d974 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/utils/block.ts +++ b/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; +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/utils/slate/hotkey.ts b/frontend/appflowy_tauri/src/appflowy_app/utils/slate/hotkey.ts index 2b635e7f81..bf96418c0b 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/utils/slate/hotkey.ts +++ b/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 = { 'mod+b': 'bold', @@ -13,6 +13,16 @@ const HOTKEYS: Record = { '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, editor: Editor) { for (const hotkey in HOTKEYS) { if (isHotkey(hotkey, event)) { @@ -29,19 +39,73 @@ export function canHandleEnterKey(event: React.KeyboardEvent, ed } export function canHandleBackspaceKey(event: React.KeyboardEvent, 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, _: Editor) { - return event.key === 'Tab'; + return isHotkey('tab', event); +} + +export function canHandleUpKey(event: React.KeyboardEvent, 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, 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, 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, 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; - onWrap: (newDelta: TextDelta[], selection: TextSelection) => Promise; + onWrap: (newDelta: TextDelta[], _selection: TextSelection) => Promise; } ) { + 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; } - // retain this node and insert a new node - void onSplit(retain, insert); + // 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 = { ...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; } diff --git a/frontend/appflowy_tauri/src/appflowy_app/utils/slate/text.ts b/frontend/appflowy_tauri/src/appflowy_app/utils/slate/text.ts index 060f82e2dc..a643cfc6b0 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/utils/slate/text.ts +++ b/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, + }; +}