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

This commit is contained in:
qinluhe 2023-04-28 17:08:58 +08:00 committed by GitHub
parent 32bd0ffca2
commit 070ac051b1
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 478 additions and 73 deletions

View File

@ -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,
};
}

View File

@ -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);
}

View File

@ -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 }));
}
);

View File

@ -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 }));
}
);

View File

@ -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;
}

View File

@ -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;
}
// 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;
}

View File

@ -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,
};
}