From 243f062d4f66568047839b5f56886bd71419851d Mon Sep 17 00:00:00 2001 From: qinluhe <108015703+qinluhe@users.noreply.github.com> Date: Thu, 27 Apr 2023 15:39:16 +0800 Subject: [PATCH] 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 --- .../document/TextBlock/TextBlock.hooks.ts | 207 +++++++++--------- .../src/appflowy_app/utils/slate/hotkey.ts | 76 ++++++- .../src/appflowy_app/utils/slate/text.ts | 32 +++ 3 files changed, 212 insertions(+), 103 deletions(-) create mode 100644 frontend/appflowy_tauri/src/appflowy_app/utils/slate/text.ts 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 87c70139e6..9a3a0a7341 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 @@ -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) => { - 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, 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) => { + // 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, }; } 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 fad418086d..2b635e7f81 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/utils/slate/hotkey.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/utils/slate/hotkey.ts @@ -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 = { 'mod+b': 'bold', @@ -14,9 +16,73 @@ const HOTKEYS: Record = { export function triggerHotkey(event: React.KeyboardEvent, 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); } } -} \ No newline at end of file +} + +export function canHandleEnterKey(event: React.KeyboardEvent, editor: Editor) { + const isEnter = event.key === 'Enter'; + return isEnter && editor.selection; +} + +export function canHandleBackspaceKey(event: React.KeyboardEvent, 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, _: Editor) { + return event.key === 'Tab'; +} + +export function onHandleEnterKey( + event: React.KeyboardEvent, + editor: Editor, + { + onSplit, + onWrap, + }: { + onSplit: (...args: [TextDelta[], TextDelta[]]) => Promise; + onWrap: (newDelta: TextDelta[], selection: TextSelection) => Promise; + } +) { + // 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; +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/utils/slate/text.ts b/frontend/appflowy_tauri/src/appflowy_app/utils/slate/text.ts new file mode 100644 index 0000000000..060f82e2dc --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/utils/slate/text.ts @@ -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 }, + }; +}