mirror of
https://github.com/AppFlowy-IO/AppFlowy.git
synced 2024-08-30 18:12:39 +00:00
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:
parent
a0efd206a9
commit
243f062d4f
@ -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,
|
||||
};
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
|
32
frontend/appflowy_tauri/src/appflowy_app/utils/slate/text.ts
Normal file
32
frontend/appflowy_tauri/src/appflowy_app/utils/slate/text.ts
Normal 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 },
|
||||
};
|
||||
}
|
Loading…
Reference in New Issue
Block a user