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, useMemo } from 'react';
|
||||||
import { useCallback, useContext } from 'react';
|
import { Editor } from 'slate';
|
||||||
import { Range, Editor, Element, Text, Location } 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 { onKeyDown } = useTextBlockKeyEvent(id, editor);
|
||||||
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 onKeyDownCapture = useCallback(
|
const onKeyDownCapture = useCallback(
|
||||||
(event: React.KeyboardEvent<HTMLDivElement>) => {
|
(event: React.KeyboardEvent<HTMLDivElement>) => {
|
||||||
switch (event.key) {
|
onKeyDown(event);
|
||||||
// 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);
|
|
||||||
},
|
},
|
||||||
[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]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
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 {
|
return {
|
||||||
onTab,
|
indentAction,
|
||||||
onBackSpace,
|
backSpaceAction,
|
||||||
onEnter,
|
splitAction,
|
||||||
};
|
wrapAction,
|
||||||
}
|
|
||||||
|
|
||||||
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 },
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
@ -1,6 +1,8 @@
|
|||||||
import isHotkey from 'is-hotkey';
|
import isHotkey from 'is-hotkey';
|
||||||
import { toggleFormat } from './format';
|
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> = {
|
const HOTKEYS: Record<string, string> = {
|
||||||
'mod+b': 'bold',
|
'mod+b': 'bold',
|
||||||
@ -14,9 +16,73 @@ const HOTKEYS: Record<string, string> = {
|
|||||||
export function triggerHotkey(event: React.KeyboardEvent<HTMLDivElement>, editor: Editor) {
|
export function triggerHotkey(event: React.KeyboardEvent<HTMLDivElement>, editor: Editor) {
|
||||||
for (const hotkey in HOTKEYS) {
|
for (const hotkey in HOTKEYS) {
|
||||||
if (isHotkey(hotkey, event)) {
|
if (isHotkey(hotkey, event)) {
|
||||||
event.preventDefault()
|
event.preventDefault();
|
||||||
const format = HOTKEYS[hotkey]
|
const format = HOTKEYS[hotkey];
|
||||||
toggleFormat(editor, format)
|
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