|
@@ -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 { TextDelta, TextSelection } from '$app/interfaces/document';
|
|
import { useTextInput } from '../_shared/TextInput.hooks';
|
|
import { useTextInput } from '../_shared/TextInput.hooks';
|
|
import { useAppDispatch } from '@/appflowy_app/stores/store';
|
|
import { useAppDispatch } from '@/appflowy_app/stores/store';
|
|
@@ -11,74 +10,24 @@ import {
|
|
splitNodeThunk,
|
|
splitNodeThunk,
|
|
} from '@/appflowy_app/stores/reducers/document/async_actions';
|
|
} from '@/appflowy_app/stores/reducers/document/async_actions';
|
|
import { documentActions } from '@/appflowy_app/stores/reducers/document/slice';
|
|
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) {
|
|
export function useTextBlock(id: string) {
|
|
const { editor, onChange, value } = useTextInput(id);
|
|
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(
|
|
const onKeyDownCapture = useCallback(
|
|
(event: React.KeyboardEvent<HTMLDivElement>) => {
|
|
(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) => {
|
|
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) {
|
|
function useActions(id: string) {
|
|
const dispatch = useAppDispatch();
|
|
const dispatch = useAppDispatch();
|
|
const controller = useContext(DocumentControllerContext);
|
|
const controller = useContext(DocumentControllerContext);
|
|
|
|
|
|
- const onTab = useCallback(async () => {
|
|
|
|
|
|
+ const indentAction = useCallback(async () => {
|
|
if (!controller) return;
|
|
if (!controller) return;
|
|
await dispatch(
|
|
await dispatch(
|
|
indentNodeThunk({
|
|
indentNodeThunk({
|
|
@@ -113,12 +141,12 @@ function useActions(id: string) {
|
|
);
|
|
);
|
|
}, [id, controller]);
|
|
}, [id, controller]);
|
|
|
|
|
|
- const onBackSpace = useCallback(async () => {
|
|
|
|
|
|
+ const backSpaceAction = useCallback(async () => {
|
|
if (!controller) return;
|
|
if (!controller) return;
|
|
await dispatch(backspaceNodeThunk({ id, controller }));
|
|
await dispatch(backspaceNodeThunk({ id, controller }));
|
|
}, [controller, id]);
|
|
}, [controller, id]);
|
|
|
|
|
|
- const onEnter = useCallback(
|
|
|
|
|
|
+ const splitAction = useCallback(
|
|
async (retain: TextDelta[], insert: TextDelta[]) => {
|
|
async (retain: TextDelta[], insert: TextDelta[]) => {
|
|
if (!controller) return;
|
|
if (!controller) return;
|
|
await dispatch(splitNodeThunk({ id, retain, insert, controller }));
|
|
await dispatch(splitNodeThunk({ id, retain, insert, controller }));
|
|
@@ -126,37 +154,20 @@ function useActions(id: string) {
|
|
[controller, id]
|
|
[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,
|
|
|
|
- };
|
|
|
|
-}
|
|
|
|
|
|
+ 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]
|
|
|
|
+ );
|
|
|
|
|
|
-function getInsertRangeBy(editor: Editor) {
|
|
|
|
- const end = Editor.end(editor, editor.selection!);
|
|
|
|
- const fragment = (editor.children[0] as Element).children;
|
|
|
|
return {
|
|
return {
|
|
- anchor: end,
|
|
|
|
- focus: { path: [0, fragment.length - 1], offset: (fragment[fragment.length - 1] as Text).text.length },
|
|
|
|
|
|
+ indentAction,
|
|
|
|
+ backSpaceAction,
|
|
|
|
+ splitAction,
|
|
|
|
+ wrapAction,
|
|
};
|
|
};
|
|
}
|
|
}
|