mirror of
https://github.com/AppFlowy-IO/AppFlowy.git
synced 2024-08-30 18:12:39 +00:00
fix: support up/down/left/right keyboard event to move cursor (#2365)
This commit is contained in:
parent
32bd0ffca2
commit
070ac051b1
@ -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,
|
||||
};
|
||||
}
|
||||
|
@ -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);
|
||||
}
|
||||
|
@ -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 }));
|
||||
}
|
||||
);
|
||||
|
@ -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 }));
|
||||
}
|
||||
);
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user