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

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

* fix: support  to break wrap the text block
This commit is contained in:
qinluhe 2023-04-27 15:39:16 +08:00 committed by GitHub
parent a0efd206a9
commit 243f062d4f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
3 changed files with 212 additions and 103 deletions

View File

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

View File

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

View File

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