mirror of
https://github.com/AppFlowy-IO/AppFlowy.git
synced 2024-08-30 18:12:39 +00:00
feat: support code block (#2464)
This commit is contained in:
parent
0b343f7ee1
commit
dad0419da0
@ -34,6 +34,7 @@
|
||||
"is-hotkey": "^0.2.0",
|
||||
"jest": "^29.5.0",
|
||||
"nanoid": "^4.0.0",
|
||||
"prismjs": "^1.29.0",
|
||||
"protoc-gen-ts": "^0.8.5",
|
||||
"react": "^18.2.0",
|
||||
"react-beautiful-dnd": "^13.1.1",
|
||||
@ -58,6 +59,7 @@
|
||||
"@types/google-protobuf": "^3.15.6",
|
||||
"@types/is-hotkey": "^0.1.7",
|
||||
"@types/node": "^18.7.10",
|
||||
"@types/prismjs": "^1.26.0",
|
||||
"@types/react": "^18.0.15",
|
||||
"@types/react-beautiful-dnd": "^13.1.3",
|
||||
"@types/react-dom": "^18.0.6",
|
||||
|
@ -15,6 +15,7 @@ specifiers:
|
||||
'@types/google-protobuf': ^3.15.6
|
||||
'@types/is-hotkey': ^0.1.7
|
||||
'@types/node': ^18.7.10
|
||||
'@types/prismjs': ^1.26.0
|
||||
'@types/react': ^18.0.15
|
||||
'@types/react-beautiful-dnd': ^13.1.3
|
||||
'@types/react-dom': ^18.0.6
|
||||
@ -39,6 +40,7 @@ specifiers:
|
||||
postcss: ^8.4.21
|
||||
prettier: 2.8.4
|
||||
prettier-plugin-tailwindcss: ^0.2.2
|
||||
prismjs: ^1.29.0
|
||||
protoc-gen-ts: ^0.8.5
|
||||
react: ^18.2.0
|
||||
react-beautiful-dnd: ^13.1.1
|
||||
@ -82,6 +84,7 @@ dependencies:
|
||||
is-hotkey: 0.2.0
|
||||
jest: 29.5.0_@types+node@18.14.6
|
||||
nanoid: 4.0.1
|
||||
prismjs: 1.29.0
|
||||
protoc-gen-ts: 0.8.6_ss7alqtodw6rv4lluxhr36xjoa
|
||||
react: 18.2.0
|
||||
react-beautiful-dnd: 13.1.1_biqbaboplfbrettd7655fr4n2y
|
||||
@ -106,6 +109,7 @@ devDependencies:
|
||||
'@types/google-protobuf': 3.15.6
|
||||
'@types/is-hotkey': 0.1.7
|
||||
'@types/node': 18.14.6
|
||||
'@types/prismjs': 1.26.0
|
||||
'@types/react': 18.0.28
|
||||
'@types/react-beautiful-dnd': 13.1.4
|
||||
'@types/react-dom': 18.0.11
|
||||
@ -1564,6 +1568,10 @@ packages:
|
||||
resolution: {integrity: sha512-KufADq8uQqo1pYKVIYzfKbJfBAc0sOeXqGbFaSpv8MRmC/zXgowNZmFcbngndGk922QDmOASEXUZCaY48gs4cg==}
|
||||
dev: false
|
||||
|
||||
/@types/prismjs/1.26.0:
|
||||
resolution: {integrity: sha512-ZTaqn/qSqUuAq1YwvOFQfVW1AR/oQJlLSZVustdjwI+GZ8kr0MSHBj0tsXPW1EqHubx50gtBEjbPGsdZwQwCjQ==}
|
||||
dev: true
|
||||
|
||||
/@types/prop-types/15.7.5:
|
||||
resolution: {integrity: sha512-JCB8C6SnDoQf0cNycqd/35A7MjcnK+ZTqE7judS6o7utxUCg6imJg3QK2qzHKszlTjcj2cn+NwMB2i96ubpj7w==}
|
||||
|
||||
@ -4170,6 +4178,11 @@ packages:
|
||||
react-is: 18.2.0
|
||||
dev: false
|
||||
|
||||
/prismjs/1.29.0:
|
||||
resolution: {integrity: sha512-Kx/1w86q/epKcmte75LNrEoT+lX8pBpavuAbvJWRXar7Hz8jrtF+e3vY751p0R8H9HdArwaCTNDDzHg/ScJK1Q==}
|
||||
engines: {node: '>=6'}
|
||||
dev: false
|
||||
|
||||
/prompts/2.4.2:
|
||||
resolution: {integrity: sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==}
|
||||
engines: {node: '>= 6'}
|
||||
|
@ -133,18 +133,16 @@ export function useBlockSelection({
|
||||
|
||||
useEffect(() => {
|
||||
if (!ref.current) return;
|
||||
const doc = document.getElementById('appflowy-block-doc');
|
||||
if (!doc) return;
|
||||
doc.addEventListener('mousedown', handleDragStart);
|
||||
doc.addEventListener('mousemove', handleDraging);
|
||||
doc.addEventListener('mouseup', handleDragEnd);
|
||||
container.addEventListener('mousedown', handleDragStart);
|
||||
container.addEventListener('mousemove', handleDraging);
|
||||
container.addEventListener('mouseup', handleDragEnd);
|
||||
|
||||
return () => {
|
||||
doc.removeEventListener('mousedown', handleDragStart);
|
||||
doc.removeEventListener('mousemove', handleDraging);
|
||||
doc.removeEventListener('mouseup', handleDragEnd);
|
||||
container.removeEventListener('mousedown', handleDragStart);
|
||||
container.removeEventListener('mousemove', handleDraging);
|
||||
container.removeEventListener('mouseup', handleDragEnd);
|
||||
};
|
||||
}, [handleDragStart, handleDragEnd, handleDraging]);
|
||||
}, [handleDragStart, handleDragEnd, handleDraging, container]);
|
||||
|
||||
return {
|
||||
isDragging,
|
||||
|
@ -22,7 +22,6 @@ export function useCalloutBlock(nodeId: string) {
|
||||
const onEmojiSelect = useCallback(
|
||||
(emoji: { native: string }) => {
|
||||
if (!controller) return;
|
||||
console.log('emoji', emoji.native);
|
||||
void dispatch(
|
||||
updateNodeDataThunk({
|
||||
id: nodeId,
|
||||
|
@ -0,0 +1,88 @@
|
||||
import { useTextInput } from '$app/components/document/_shared/Text/TextInput.hooks';
|
||||
import isHotkey from 'is-hotkey';
|
||||
import { useCallback, useContext, useMemo } from 'react';
|
||||
import { Editor } from 'slate';
|
||||
import { BlockType, NestedBlock, TextBlockKeyEventHandlerParams } from '$app/interfaces/document';
|
||||
import { keyBoardEventKeyMap } from '$app/constants/document/text_block';
|
||||
import { useAppDispatch } from '$app/stores/store';
|
||||
import { DocumentControllerContext } from '$app/stores/effects/document/document_controller';
|
||||
import { splitNodeThunk } from '$app_reducers/document/async-actions';
|
||||
import { useDefaultTextInputEvents } from '$app/components/document/_shared/Text/useTextEvents';
|
||||
import { indent, outdent } from '$app/utils/document/blocks/code';
|
||||
|
||||
export function useCodeBlock(node: NestedBlock<BlockType.CodeBlock>) {
|
||||
const id = node.id;
|
||||
const dispatch = useAppDispatch();
|
||||
const controller = useContext(DocumentControllerContext);
|
||||
const { editor, onChange, value, onDOMBeforeInput } = useTextInput(id);
|
||||
const defaultTextInputEvents = useDefaultTextInputEvents(id);
|
||||
|
||||
const customEvents = useMemo(() => {
|
||||
return [
|
||||
{
|
||||
// Here custom tab key event for TextBlock to insert 2 spaces
|
||||
triggerEventKey: keyBoardEventKeyMap.Tab,
|
||||
canHandle: (...args: TextBlockKeyEventHandlerParams) => isHotkey('tab', args[0]),
|
||||
handler: (...args: TextBlockKeyEventHandlerParams) => {
|
||||
const [e, editor] = args;
|
||||
e.preventDefault();
|
||||
indent(editor, 2);
|
||||
},
|
||||
},
|
||||
{
|
||||
// Here custom shift+tab key event for TextBlock to delete 2 spaces
|
||||
triggerEventKey: keyBoardEventKeyMap.Tab,
|
||||
canHandle: (...args: TextBlockKeyEventHandlerParams) => isHotkey('shift+tab', args[0]),
|
||||
handler: (...args: TextBlockKeyEventHandlerParams) => {
|
||||
const [e, editor] = args;
|
||||
e.preventDefault();
|
||||
outdent(editor, 2);
|
||||
},
|
||||
},
|
||||
{
|
||||
// Here custom enter key event for TextBlock
|
||||
triggerEventKey: keyBoardEventKeyMap.Enter,
|
||||
canHandle: (...args: TextBlockKeyEventHandlerParams) => isHotkey('enter', args[0]),
|
||||
handler: (...args: TextBlockKeyEventHandlerParams) => {
|
||||
const [e, editor] = args;
|
||||
e.preventDefault();
|
||||
Editor.insertText(editor, '\n');
|
||||
},
|
||||
},
|
||||
{
|
||||
// Here custom shift+enter key event for TextBlock
|
||||
triggerEventKey: keyBoardEventKeyMap.Enter,
|
||||
canHandle: (...args: TextBlockKeyEventHandlerParams) => isHotkey('shift+enter', args[0]),
|
||||
handler: (...args: TextBlockKeyEventHandlerParams) => {
|
||||
const [e, editor] = args;
|
||||
e.preventDefault();
|
||||
void (async () => {
|
||||
if (!controller) return;
|
||||
await dispatch(splitNodeThunk({ id, controller, editor }));
|
||||
})();
|
||||
},
|
||||
},
|
||||
];
|
||||
}, [controller, dispatch, id]);
|
||||
|
||||
const onKeyDown = useCallback<React.KeyboardEventHandler<HTMLDivElement>>(
|
||||
(e) => {
|
||||
const keyEvents = [...defaultTextInputEvents, ...customEvents];
|
||||
keyEvents.forEach((keyEvent) => {
|
||||
// Here we check if the key event can be handled by the current key event
|
||||
if (keyEvent.canHandle(e, editor)) {
|
||||
keyEvent.handler(e, editor);
|
||||
}
|
||||
});
|
||||
},
|
||||
[defaultTextInputEvents, customEvents, editor]
|
||||
);
|
||||
|
||||
return {
|
||||
editor,
|
||||
onKeyDown,
|
||||
onChange,
|
||||
value,
|
||||
onDOMBeforeInput,
|
||||
};
|
||||
}
|
@ -0,0 +1,44 @@
|
||||
import React, { useCallback, useContext } from 'react';
|
||||
import MenuItem from '@mui/material/MenuItem';
|
||||
import FormControl from '@mui/material/FormControl';
|
||||
import Select, { SelectChangeEvent } from '@mui/material/Select';
|
||||
import { updateNodeDataThunk } from '$app_reducers/document/async-actions';
|
||||
import { useAppDispatch } from '$app/stores/store';
|
||||
import { DocumentControllerContext } from '$app/stores/effects/document/document_controller';
|
||||
import { supportLanguage } from '$app/constants/document/code';
|
||||
|
||||
function SelectLanguage({ id, language }: { id: string; language: string }) {
|
||||
const dispatch = useAppDispatch();
|
||||
const controller = useContext(DocumentControllerContext);
|
||||
|
||||
const onLanguageSelect = useCallback(
|
||||
(event: SelectChangeEvent) => {
|
||||
if (!controller) return;
|
||||
const language = event.target.value;
|
||||
dispatch(
|
||||
updateNodeDataThunk({
|
||||
id,
|
||||
controller,
|
||||
data: {
|
||||
language,
|
||||
},
|
||||
})
|
||||
);
|
||||
},
|
||||
[controller, dispatch, id]
|
||||
);
|
||||
|
||||
return (
|
||||
<FormControl variant='standard'>
|
||||
<Select className={'h-[28px] w-[150px]'} value={language} onChange={onLanguageSelect} label='Language'>
|
||||
{supportLanguage.map((item) => (
|
||||
<MenuItem key={item.id} value={item.id}>
|
||||
{item.title}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
);
|
||||
}
|
||||
|
||||
export default SelectLanguage;
|
@ -0,0 +1,43 @@
|
||||
import { RenderLeafProps, RenderElementProps } from 'slate-react';
|
||||
import { BaseText } from 'slate';
|
||||
|
||||
interface CodeLeafProps extends RenderLeafProps {
|
||||
leaf: BaseText & {
|
||||
bold?: boolean;
|
||||
italic?: boolean;
|
||||
underlined?: boolean;
|
||||
strikethrough?: boolean;
|
||||
prism_token?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export const CodeLeaf = (props: CodeLeafProps) => {
|
||||
const { attributes, children, leaf } = props;
|
||||
|
||||
let newChildren = children;
|
||||
if (leaf.bold) {
|
||||
newChildren = <strong>{children}</strong>;
|
||||
}
|
||||
|
||||
if (leaf.italic) {
|
||||
newChildren = <em>{newChildren}</em>;
|
||||
}
|
||||
|
||||
if (leaf.underlined) {
|
||||
newChildren = <u>{newChildren}</u>;
|
||||
}
|
||||
|
||||
return (
|
||||
<span {...attributes} className={`token ${leaf.prism_token} ${leaf.strikethrough ? `line-through` : ''}`}>
|
||||
{newChildren}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
export const CodeBlockElement = (props: RenderElementProps) => {
|
||||
return (
|
||||
<pre className='code-block-element' {...props.attributes}>
|
||||
<code>{props.children}</code>
|
||||
</pre>
|
||||
);
|
||||
};
|
@ -1,3 +1,39 @@
|
||||
export default function CodeBlock({ id }: { id: string }) {
|
||||
return <div>{id}</div>;
|
||||
import { BlockType, NestedBlock } from '$app/interfaces/document';
|
||||
import { useCodeBlock } from './CodeBlock.hooks';
|
||||
import { Editable, Slate } from 'slate-react';
|
||||
import BlockHorizontalToolbar from '$app/components/document/BlockHorizontalToolbar';
|
||||
import React from 'react';
|
||||
import { CodeLeaf, CodeBlockElement } from './elements';
|
||||
import SelectLanguage from './SelectLanguage';
|
||||
import { decorateCodeFunc } from '$app/utils/document/blocks/code/decorate';
|
||||
|
||||
export default function CodeBlock({
|
||||
node,
|
||||
placeholder,
|
||||
...props
|
||||
}: { node: NestedBlock<BlockType.CodeBlock>; placeholder?: string } & React.HTMLAttributes<HTMLDivElement>) {
|
||||
const { editor, value, onChange, onKeyDown, onDOMBeforeInput } = useCodeBlock(node);
|
||||
|
||||
const className = props.className ? ` ${props.className}` : '';
|
||||
const id = node.id;
|
||||
const language = node.data.language;
|
||||
return (
|
||||
<div {...props} className={`rounded bg-shade-6 p-6 ${className}`}>
|
||||
<div className={'mb-2 w-[100%]'}>
|
||||
<SelectLanguage id={id} language={language} />
|
||||
</div>
|
||||
<Slate editor={editor} onChange={onChange} value={value}>
|
||||
<BlockHorizontalToolbar id={id} />
|
||||
|
||||
<Editable
|
||||
onKeyDown={onKeyDown}
|
||||
decorate={(entry) => decorateCodeFunc(entry, language)}
|
||||
onDOMBeforeInput={onDOMBeforeInput}
|
||||
renderLeaf={CodeLeaf}
|
||||
renderElement={CodeBlockElement}
|
||||
placeholder={placeholder || 'Please enter some text...'}
|
||||
/>
|
||||
</Slate>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -8,7 +8,7 @@ export default function DocumentTitle({ id }: { id: string }) {
|
||||
if (!node) return null;
|
||||
return (
|
||||
<NodeContext.Provider value={node}>
|
||||
<div data-block-id={node.id} className='doc-title relative mb-2 px-1 pt-[50px] text-4xl font-bold'>
|
||||
<div data-block-id={node.id} className='doc-title relative mb-2 pt-[50px] text-4xl font-bold'>
|
||||
<TextBlock placeholder='Untitled' childIds={[]} node={node} />
|
||||
</div>
|
||||
</NodeContext.Provider>
|
||||
|
@ -15,6 +15,7 @@ import NumberedListBlock from '$app/components/document/NumberedListBlock';
|
||||
import ToggleListBlock from '$app/components/document/ToggleListBlock';
|
||||
import DividerBlock from '$app/components/document/DividerBlock';
|
||||
import CalloutBlock from '$app/components/document/CalloutBlock';
|
||||
import CodeBlock from '$app/components/document/CodeBlock';
|
||||
|
||||
function NodeComponent({ id, ...props }: { id: string } & React.HTMLAttributes<HTMLDivElement>) {
|
||||
const { node, childIds, isSelected, ref } = useNode(id);
|
||||
@ -48,12 +49,10 @@ function NodeComponent({ id, ...props }: { id: string } & React.HTMLAttributes<H
|
||||
case BlockType.CalloutBlock: {
|
||||
return <CalloutBlock node={node} childIds={childIds} />;
|
||||
}
|
||||
case BlockType.CodeBlock:
|
||||
return <CodeBlock node={node} />;
|
||||
default:
|
||||
return (
|
||||
<Alert severity='info' className='mb-2'>
|
||||
<p>The current version does not support this Block.</p>
|
||||
</Alert>
|
||||
);
|
||||
return <UnSupportedBlock />;
|
||||
}
|
||||
}, [node, childIds]);
|
||||
|
||||
@ -76,4 +75,12 @@ const NodeWithErrorBoundary = withErrorBoundary(NodeComponent, {
|
||||
FallbackComponent: ErrorBoundaryFallbackComponent,
|
||||
});
|
||||
|
||||
const UnSupportedBlock = () => {
|
||||
return (
|
||||
<Alert severity='info' className='mb-2'>
|
||||
<p>The current version does not support this Block.</p>
|
||||
</Alert>
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(NodeWithErrorBoundary);
|
||||
|
@ -1,30 +1,13 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useTextInput } from '../_shared/TextInput.hooks';
|
||||
import { useTextInput } from '../_shared/Text/TextInput.hooks';
|
||||
import { useTextBlockKeyEvent } from '$app/components/document/TextBlock/events/Events.hooks';
|
||||
|
||||
export function useTextBlock(id: string) {
|
||||
const { editor, onChange, value } = useTextInput(id);
|
||||
const { editor, onChange, value, onDOMBeforeInput } = useTextInput(id);
|
||||
const { onKeyDown } = useTextBlockKeyEvent(id, editor);
|
||||
|
||||
const onKeyDownCapture = useCallback(
|
||||
(event: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
onKeyDown(event);
|
||||
},
|
||||
[onKeyDown]
|
||||
);
|
||||
|
||||
const onDOMBeforeInput = useCallback((e: InputEvent) => {
|
||||
// COMPAT: in Apple, `compositionend` is dispatched after the `beforeinput` for "insertFromComposition".
|
||||
// It will cause repeated characters when inputting Chinese.
|
||||
// Here, prevent the beforeInput event and wait for the compositionend event to take effect.
|
||||
if (e.inputType === 'insertFromComposition') {
|
||||
e.preventDefault();
|
||||
}
|
||||
}, []);
|
||||
|
||||
return {
|
||||
onChange,
|
||||
onKeyDownCapture,
|
||||
onKeyDown,
|
||||
onDOMBeforeInput,
|
||||
editor,
|
||||
value,
|
||||
|
@ -1,75 +0,0 @@
|
||||
import { useAppDispatch } from '$app/stores/store';
|
||||
import { useCallback, useContext } from 'react';
|
||||
import { DocumentControllerContext } from '$app/stores/effects/document/document_controller';
|
||||
import {
|
||||
backspaceNodeThunk,
|
||||
indentNodeThunk,
|
||||
setCursorNextLineThunk,
|
||||
setCursorPreLineThunk,
|
||||
splitNodeThunk,
|
||||
updateNodeDeltaThunk,
|
||||
} from '$app_reducers/document/async-actions';
|
||||
import { TextDelta, TextSelection } from '$app/interfaces/document';
|
||||
import { documentActions } from '$app_reducers/document/slice';
|
||||
import { Editor } from 'slate';
|
||||
|
||||
export function useActions(id: string) {
|
||||
const dispatch = useAppDispatch();
|
||||
const controller = useContext(DocumentControllerContext);
|
||||
|
||||
const indentAction = useCallback(async () => {
|
||||
if (!controller) return;
|
||||
await dispatch(
|
||||
indentNodeThunk({
|
||||
id,
|
||||
controller,
|
||||
})
|
||||
);
|
||||
}, [id, controller]);
|
||||
|
||||
const backSpaceAction = useCallback(async () => {
|
||||
if (!controller) return;
|
||||
await dispatch(backspaceNodeThunk({ id, controller }));
|
||||
}, [controller, id]);
|
||||
|
||||
const splitAction = useCallback(
|
||||
async (retain: TextDelta[], insert: TextDelta[]) => {
|
||||
if (!controller) return;
|
||||
await dispatch(splitNodeThunk({ id, retain, insert, controller }));
|
||||
},
|
||||
[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]
|
||||
);
|
||||
|
||||
const focusPreLineAction = useCallback(
|
||||
async (params: { editor: Editor; focusEnd?: boolean }) => {
|
||||
await dispatch(setCursorPreLineThunk({ id, ...params }));
|
||||
},
|
||||
[id]
|
||||
);
|
||||
|
||||
const focusNextLineAction = useCallback(
|
||||
async (params: { editor: Editor; focusStart?: boolean }) => {
|
||||
await dispatch(setCursorNextLineThunk({ id, ...params }));
|
||||
},
|
||||
[id]
|
||||
);
|
||||
|
||||
return {
|
||||
indentAction,
|
||||
backSpaceAction,
|
||||
splitAction,
|
||||
wrapAction,
|
||||
focusPreLineAction,
|
||||
focusNextLineAction,
|
||||
};
|
||||
}
|
@ -1,93 +1,85 @@
|
||||
import { Editor } from 'slate';
|
||||
import { useTurnIntoBlock } from './TurnIntoEvents.hooks';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { useCallback, useContext, useMemo } from 'react';
|
||||
import { keyBoardEventKeyMap } from '$app/constants/document/text_block';
|
||||
import {
|
||||
canHandleBackspaceKey,
|
||||
canHandleDownKey,
|
||||
canHandleEnterKey,
|
||||
canHandleLeftKey,
|
||||
canHandleRightKey,
|
||||
canHandleTabKey,
|
||||
canHandleUpKey,
|
||||
onHandleEnterKey,
|
||||
triggerHotkey,
|
||||
} from '$app/utils/document/blocks/text/hotkey';
|
||||
import { triggerHotkey } from '$app/utils/document/blocks/text/hotkey';
|
||||
import { TextBlockKeyEventHandlerParams } from '$app/interfaces/document';
|
||||
import { useActions } from './Actions.hooks';
|
||||
import isHotkey from 'is-hotkey';
|
||||
import { indentNodeThunk, outdentNodeThunk, splitNodeThunk } from '$app_reducers/document/async-actions';
|
||||
import { DocumentControllerContext } from '$app/stores/effects/document/document_controller';
|
||||
import { useAppDispatch } from '$app/stores/store';
|
||||
import { useDefaultTextInputEvents } from '$app/components/document/_shared/Text/useTextEvents';
|
||||
|
||||
export function useTextBlockKeyEvent(id: string, editor: Editor) {
|
||||
const { indentAction, backSpaceAction, splitAction, wrapAction, focusPreLineAction, focusNextLineAction } =
|
||||
useActions(id);
|
||||
const controller = useContext(DocumentControllerContext);
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const defaultTextInputEvents = useDefaultTextInputEvents(id);
|
||||
|
||||
const { turnIntoBlockEvents } = useTurnIntoBlock(id);
|
||||
|
||||
const events = useMemo(() => {
|
||||
return [
|
||||
// Here custom key events for TextBlock
|
||||
const events = useMemo(
|
||||
() => [
|
||||
...defaultTextInputEvents,
|
||||
{
|
||||
// Here custom enter key event for TextBlock
|
||||
triggerEventKey: keyBoardEventKeyMap.Enter,
|
||||
canHandle: canHandleEnterKey,
|
||||
canHandle: (...args: TextBlockKeyEventHandlerParams) => isHotkey('enter', args[0]),
|
||||
handler: (...args: TextBlockKeyEventHandlerParams) => {
|
||||
onHandleEnterKey(...args, {
|
||||
onSplit: splitAction,
|
||||
onWrap: wrapAction,
|
||||
});
|
||||
const [e, editor] = args;
|
||||
e.preventDefault();
|
||||
void (async () => {
|
||||
if (!controller) return;
|
||||
await dispatch(splitNodeThunk({ id, controller, editor }));
|
||||
})();
|
||||
},
|
||||
},
|
||||
{
|
||||
// Here custom shift+enter key event for TextBlock
|
||||
triggerEventKey: keyBoardEventKeyMap.Enter,
|
||||
canHandle: (...args: TextBlockKeyEventHandlerParams) => isHotkey('shift+enter', args[0]),
|
||||
handler: (...args: TextBlockKeyEventHandlerParams) => {
|
||||
const [e, editor] = args;
|
||||
e.preventDefault();
|
||||
Editor.insertText(editor, '\n');
|
||||
},
|
||||
},
|
||||
{
|
||||
// Here custom tab key event for TextBlock
|
||||
triggerEventKey: keyBoardEventKeyMap.Tab,
|
||||
canHandle: canHandleTabKey,
|
||||
handler: (..._args: TextBlockKeyEventHandlerParams) => {
|
||||
void indentAction();
|
||||
},
|
||||
},
|
||||
{
|
||||
triggerEventKey: keyBoardEventKeyMap.Backspace,
|
||||
canHandle: canHandleBackspaceKey,
|
||||
handler: (..._args: TextBlockKeyEventHandlerParams) => {
|
||||
void backSpaceAction();
|
||||
},
|
||||
},
|
||||
{
|
||||
triggerEventKey: keyBoardEventKeyMap.Up,
|
||||
canHandle: canHandleUpKey,
|
||||
canHandle: (...args: TextBlockKeyEventHandlerParams) => isHotkey('tab', args[0]),
|
||||
handler: (...args: TextBlockKeyEventHandlerParams) => {
|
||||
void focusPreLineAction({
|
||||
editor: args[1],
|
||||
});
|
||||
const [e, _] = args;
|
||||
e.preventDefault();
|
||||
if (!controller) return;
|
||||
dispatch(
|
||||
indentNodeThunk({
|
||||
id,
|
||||
controller,
|
||||
})
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
triggerEventKey: keyBoardEventKeyMap.Down,
|
||||
canHandle: canHandleDownKey,
|
||||
// Here custom shift+tab key event for TextBlock
|
||||
triggerEventKey: keyBoardEventKeyMap.Tab,
|
||||
canHandle: (...args: TextBlockKeyEventHandlerParams) => isHotkey('shift+tab', args[0]),
|
||||
handler: (...args: TextBlockKeyEventHandlerParams) => {
|
||||
void focusNextLineAction({
|
||||
editor: args[1],
|
||||
});
|
||||
const [e, _] = args;
|
||||
e.preventDefault();
|
||||
if (!controller) return;
|
||||
dispatch(
|
||||
outdentNodeThunk({
|
||||
id,
|
||||
controller,
|
||||
})
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
triggerEventKey: keyBoardEventKeyMap.Left,
|
||||
canHandle: canHandleLeftKey,
|
||||
handler: (...args: TextBlockKeyEventHandlerParams) => {
|
||||
void focusPreLineAction({
|
||||
editor: args[1],
|
||||
focusEnd: true,
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
triggerEventKey: keyBoardEventKeyMap.Right,
|
||||
canHandle: canHandleRightKey,
|
||||
handler: (...args: TextBlockKeyEventHandlerParams) => {
|
||||
void focusNextLineAction({
|
||||
editor: args[1],
|
||||
focusStart: true,
|
||||
});
|
||||
},
|
||||
},
|
||||
];
|
||||
}, [splitAction, wrapAction, indentAction, backSpaceAction, focusPreLineAction, focusNextLineAction]);
|
||||
],
|
||||
[defaultTextInputEvents, controller, dispatch, id]
|
||||
);
|
||||
|
||||
const onKeyDown = useCallback(
|
||||
(event: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
@ -100,8 +92,6 @@ export function useTextBlockKeyEvent(id: string, editor: Editor) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
matchKeys.forEach((matchKey) => matchKey.handler(event, editor));
|
||||
},
|
||||
[editor, events, turnIntoBlockEvents]
|
||||
|
@ -1,12 +1,12 @@
|
||||
import { useContext, useMemo } from 'react';
|
||||
import { BlockData, BlockType, TextBlockKeyEventHandlerParams } from '$app/interfaces/document';
|
||||
import { BlockType, TextBlockKeyEventHandlerParams } from '$app/interfaces/document';
|
||||
import { keyBoardEventKeyMap } from '$app/constants/document/text_block';
|
||||
import { useAppDispatch } from '$app/stores/store';
|
||||
import { DocumentControllerContext } from '$app/stores/effects/document/document_controller';
|
||||
import { turnToBlockThunk, turnToDividerBlockThunk } from '$app_reducers/document/async-actions';
|
||||
import { blockConfig } from '$app/constants/document/config';
|
||||
import { Editor } from 'slate';
|
||||
import { getBeforeRangeAt } from '$app/utils/document/blocks/text/delta';
|
||||
import { getBeforeRangeAt, getDeltaAfterSelection } from '$app/utils/document/blocks/text/delta';
|
||||
import {
|
||||
getHeadingDataFromEditor,
|
||||
getQuoteDataFromEditor,
|
||||
@ -15,8 +15,8 @@ import {
|
||||
getNumberedListDataFromEditor,
|
||||
getToggleListDataFromEditor,
|
||||
getCalloutDataFromEditor,
|
||||
getCodeBlockDataFromEditor,
|
||||
} from '$app/utils/document/blocks';
|
||||
import { getDeltaAfterSelection } from '$app/utils/document/blocks/common';
|
||||
|
||||
export function useTurnIntoBlock(id: string) {
|
||||
const controller = useContext(DocumentControllerContext);
|
||||
@ -58,6 +58,16 @@ export function useTurnIntoBlock(id: string) {
|
||||
dispatch(turnToDividerBlockThunk({ id, controller, delta }));
|
||||
},
|
||||
},
|
||||
{
|
||||
triggerEventKey: keyBoardEventKeyMap.Backquote,
|
||||
canHandle: canHandle(BlockType.CodeBlock, keyBoardEventKeyMap.Backquote),
|
||||
handler: (...args: TextBlockKeyEventHandlerParams) => {
|
||||
if (!controller) return;
|
||||
const [_event, editor] = args;
|
||||
const data = getCodeBlockDataFromEditor(editor);
|
||||
dispatch(turnToBlockThunk({ id, data, type: BlockType.CodeBlock, controller }));
|
||||
},
|
||||
},
|
||||
];
|
||||
}, [controller, dispatch, id]);
|
||||
|
||||
|
@ -16,7 +16,7 @@ function TextBlock({
|
||||
childIds?: string[];
|
||||
placeholder?: string;
|
||||
} & React.HTMLAttributes<HTMLDivElement>) {
|
||||
const { editor, value, onChange, onKeyDownCapture, onDOMBeforeInput } = useTextBlock(node.id);
|
||||
const { editor, value, onChange, onKeyDown, onDOMBeforeInput } = useTextBlock(node.id);
|
||||
const className = props.className !== undefined ? ` ${props.className}` : '';
|
||||
|
||||
return (
|
||||
@ -25,7 +25,7 @@ function TextBlock({
|
||||
<Slate editor={editor} onChange={onChange} value={value}>
|
||||
<BlockHorizontalToolbar id={node.id} />
|
||||
<Editable
|
||||
onKeyDownCapture={onKeyDownCapture}
|
||||
onKeyDown={onKeyDown}
|
||||
onDOMBeforeInput={onDOMBeforeInput}
|
||||
renderLeaf={(leafProps) => <Leaf {...leafProps} />}
|
||||
placeholder={placeholder || 'Please enter some text...'}
|
||||
|
@ -1,46 +1,61 @@
|
||||
import { createEditor, Descendant, Transforms } from 'slate';
|
||||
import { withReact, ReactEditor } from 'slate-react';
|
||||
import { ReactEditor, withReact } from 'slate-react';
|
||||
import * as Y from 'yjs';
|
||||
import { withYjs, YjsEditor, slateNodesToInsertDelta } from '@slate-yjs/core';
|
||||
import { useCallback, useContext, useMemo, useRef, useEffect, useState } from 'react';
|
||||
import { slateNodesToInsertDelta, withYjs, YjsEditor } from '@slate-yjs/core';
|
||||
import { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { DocumentControllerContext } from '$app/stores/effects/document/document_controller';
|
||||
import { TextDelta, TextSelection } from '$app/interfaces/document';
|
||||
import { NodeContext } from './SubscribeNode.hooks';
|
||||
import { useAppDispatch, useAppSelector } from '@/appflowy_app/stores/store';
|
||||
import { NodeContext } from '../SubscribeNode.hooks';
|
||||
import { useAppDispatch, useAppSelector } from '$app/stores/store';
|
||||
import { updateNodeDeltaThunk } from '$app_reducers/document/async-actions/blocks/text/update';
|
||||
import { deltaToSlateValue, getDeltaFromSlateNodes } from '$app/utils/document/blocks/common';
|
||||
import { documentActions } from '@/appflowy_app/stores/reducers/document/slice';
|
||||
import { deltaToSlateValue } from '$app/utils/document/blocks/common';
|
||||
import { documentActions } from '$app_reducers/document/slice';
|
||||
|
||||
import { isSameDelta } from '$app/utils/document/blocks/text/delta';
|
||||
|
||||
export function useTextInput(id: string) {
|
||||
const dispatch = useAppDispatch();
|
||||
const node = useContext(NodeContext);
|
||||
const selectionRef = useRef<TextSelection | null>(null);
|
||||
|
||||
const delta = useMemo(() => {
|
||||
if (!node || !('delta' in node.data)) {
|
||||
return [];
|
||||
}
|
||||
return node.data.delta;
|
||||
}, [node?.data]);
|
||||
}, [node]);
|
||||
|
||||
const { editor, yText } = useBindYjs(id, delta);
|
||||
|
||||
const [value, setValue] = useState<Descendant[]>([]);
|
||||
|
||||
const storeSelection = 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 (!ReactEditor.isFocused(editor)) return;
|
||||
if (!ReactEditor.isFocused(editor)) {
|
||||
selectionRef.current = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const selection = editor.selection as TextSelection;
|
||||
if (selectionRef.current && JSON.stringify(selection) !== JSON.stringify(selectionRef.current)) {
|
||||
Transforms.select(editor, selectionRef.current);
|
||||
selectionRef.current = null;
|
||||
}
|
||||
|
||||
dispatch(documentActions.setTextSelection({ blockId: id, selection }));
|
||||
}, [editor]);
|
||||
}, [dispatch, editor, id]);
|
||||
|
||||
const currentSelection = useAppSelector((state) => state.document.textSelections[id]);
|
||||
const restoreSelection = useCallback(() => {
|
||||
setSelection(editor, currentSelection);
|
||||
}, [editor, currentSelection]);
|
||||
if (!currentSelection) return;
|
||||
if (ReactEditor.isFocused(editor)) {
|
||||
Transforms.select(editor, currentSelection);
|
||||
} else {
|
||||
selectionRef.current = currentSelection;
|
||||
Transforms.select(editor, currentSelection);
|
||||
ReactEditor.focus(editor);
|
||||
}
|
||||
}, [currentSelection, editor]);
|
||||
|
||||
const onChange = useCallback(
|
||||
(e: Descendant[]) => {
|
||||
@ -55,7 +70,7 @@ export function useTextInput(id: string) {
|
||||
return () => {
|
||||
dispatch(documentActions.removeTextSelection(id));
|
||||
};
|
||||
}, [id, restoreSelection]);
|
||||
}, [dispatch, id, restoreSelection]);
|
||||
|
||||
if (editor.selection && ReactEditor.isFocused(editor)) {
|
||||
const domSelection = window.getSelection();
|
||||
@ -66,11 +81,21 @@ export function useTextInput(id: string) {
|
||||
}
|
||||
}
|
||||
|
||||
const onDOMBeforeInput = useCallback((e: InputEvent) => {
|
||||
// COMPAT: in Apple, `compositionend` is dispatched after the `beforeinput` for "insertFromComposition".
|
||||
// It will cause repeated characters when inputting Chinese.
|
||||
// Here, prevent the beforeInput event and wait for the compositionend event to take effect.
|
||||
if (e.inputType === 'insertFromComposition') {
|
||||
e.preventDefault();
|
||||
}
|
||||
}, []);
|
||||
|
||||
return {
|
||||
editor,
|
||||
yText,
|
||||
onChange,
|
||||
value,
|
||||
onDOMBeforeInput,
|
||||
};
|
||||
}
|
||||
function useBindYjs(id: string, delta: TextDelta[]) {
|
||||
@ -90,6 +115,8 @@ function useBindYjs(id: string, delta: TextDelta[]) {
|
||||
yTextRef.current = yText;
|
||||
|
||||
return _sharedType;
|
||||
// Here we only want to create the sharedType once
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const editor = useMemo(() => withYjs(withReact(createEditor()), sharedType), []);
|
||||
@ -116,8 +143,6 @@ function useBindYjs(id: string, delta: TextDelta[]) {
|
||||
};
|
||||
}, [sendDelta]);
|
||||
|
||||
const currentSelection = useAppSelector((state) => state.document.textSelections[id]);
|
||||
|
||||
useEffect(() => {
|
||||
const yText = yTextRef.current;
|
||||
if (!yText) return;
|
||||
@ -128,9 +153,7 @@ function useBindYjs(id: string, delta: TextDelta[]) {
|
||||
|
||||
yText.delete(0, yText.length);
|
||||
yText.applyDelta(delta);
|
||||
// It should be noted that the selection will be lost after the yText is updated
|
||||
setSelection(editor, currentSelection);
|
||||
}, [delta, currentSelection, editor]);
|
||||
}, [delta, editor]);
|
||||
|
||||
return { editor, yText: yTextRef.current };
|
||||
}
|
||||
@ -150,41 +173,10 @@ function useController(id: string) {
|
||||
})
|
||||
);
|
||||
},
|
||||
[docController, id]
|
||||
[dispatch, docController, id]
|
||||
);
|
||||
|
||||
return {
|
||||
sendDelta,
|
||||
};
|
||||
}
|
||||
|
||||
function setSelection(editor: ReactEditor, currentSelection: TextSelection) {
|
||||
// If the current selection is empty, blur the editor and deselect the selection
|
||||
if (!currentSelection || !currentSelection.anchor || !currentSelection.focus) {
|
||||
if (ReactEditor.isFocused(editor)) {
|
||||
ReactEditor.blur(editor);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// If the editor is focused and the current selection is the same as the editor's selection, no need to set the selection
|
||||
if (ReactEditor.isFocused(editor) && JSON.stringify(currentSelection) === JSON.stringify(editor.selection)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { path, offset } = currentSelection.focus;
|
||||
const children = getDeltaFromSlateNodes(editor.children);
|
||||
|
||||
// the path always has 2 elements,
|
||||
// because the text node is a two-dimensional array
|
||||
const index = path[1];
|
||||
// It is possible that the current selection is out of range
|
||||
if (children[index].insert.length < offset) {
|
||||
return;
|
||||
}
|
||||
|
||||
// the order of the following two lines is important
|
||||
// if we reverse the order, the selection will be lost or always at the start
|
||||
Transforms.select(editor, currentSelection);
|
||||
ReactEditor.focus(editor);
|
||||
}
|
@ -0,0 +1,103 @@
|
||||
import { useAppDispatch } from '$app/stores/store';
|
||||
import { useCallback, useContext } from 'react';
|
||||
import { DocumentControllerContext } from '$app/stores/effects/document/document_controller';
|
||||
import { Editor } from 'slate';
|
||||
import { backspaceNodeThunk, setCursorNextLineThunk, setCursorPreLineThunk } from '$app_reducers/document/async-actions';
|
||||
import { keyBoardEventKeyMap } from '$app/constants/document/text_block';
|
||||
import {
|
||||
canHandleBackspaceKey,
|
||||
canHandleDownKey,
|
||||
canHandleLeftKey,
|
||||
canHandleRightKey,
|
||||
canHandleUpKey,
|
||||
} from '$app/utils/document/blocks/text/hotkey';
|
||||
import { TextBlockKeyEventHandlerParams } from '$app/interfaces/document';
|
||||
|
||||
export function useDefaultTextInputEvents(id: string) {
|
||||
const dispatch = useAppDispatch();
|
||||
const controller = useContext(DocumentControllerContext);
|
||||
|
||||
const focusPreLineAction = useCallback(
|
||||
async (params: { editor: Editor; focusEnd?: boolean }) => {
|
||||
await dispatch(setCursorPreLineThunk({ id, ...params }));
|
||||
},
|
||||
[dispatch, id]
|
||||
);
|
||||
|
||||
const focusNextLineAction = useCallback(
|
||||
async (params: { editor: Editor; focusStart?: boolean }) => {
|
||||
await dispatch(setCursorNextLineThunk({ id, ...params }));
|
||||
},
|
||||
[dispatch, id]
|
||||
);
|
||||
return [
|
||||
{
|
||||
triggerEventKey: keyBoardEventKeyMap.Up,
|
||||
canHandle: canHandleUpKey,
|
||||
handler: (...args: TextBlockKeyEventHandlerParams) => {
|
||||
void focusPreLineAction({
|
||||
editor: args[1],
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
triggerEventKey: keyBoardEventKeyMap.Down,
|
||||
canHandle: canHandleDownKey,
|
||||
handler: (...args: TextBlockKeyEventHandlerParams) => {
|
||||
void focusNextLineAction({
|
||||
editor: args[1],
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
triggerEventKey: keyBoardEventKeyMap.Left,
|
||||
canHandle: canHandleLeftKey,
|
||||
handler: (...args: TextBlockKeyEventHandlerParams) => {
|
||||
void focusPreLineAction({
|
||||
editor: args[1],
|
||||
focusEnd: true,
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
triggerEventKey: keyBoardEventKeyMap.Right,
|
||||
canHandle: canHandleRightKey,
|
||||
handler: (...args: TextBlockKeyEventHandlerParams) => {
|
||||
void focusNextLineAction({
|
||||
editor: args[1],
|
||||
focusStart: true,
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
triggerEventKey: keyBoardEventKeyMap.Backspace,
|
||||
canHandle: canHandleBackspaceKey,
|
||||
handler: (...args: TextBlockKeyEventHandlerParams) => {
|
||||
const [e, _] = args;
|
||||
e.preventDefault();
|
||||
void (async () => {
|
||||
if (!controller) return;
|
||||
await dispatch(backspaceNodeThunk({ id, controller }));
|
||||
})();
|
||||
},
|
||||
},
|
||||
// Here prevent the default behavior of the enter key
|
||||
{
|
||||
triggerEventKey: keyBoardEventKeyMap.Enter,
|
||||
canHandle: (...args: TextBlockKeyEventHandlerParams) => args[0].key === 'Enter',
|
||||
handler: (...args: TextBlockKeyEventHandlerParams) => {
|
||||
const [e] = args;
|
||||
e.preventDefault();
|
||||
},
|
||||
},
|
||||
// Here prevent the default behavior of the tab key
|
||||
{
|
||||
triggerEventKey: keyBoardEventKeyMap.Tab,
|
||||
canHandle: (...args: TextBlockKeyEventHandlerParams) => args[0].key === 'Tab',
|
||||
handler: (...args: TextBlockKeyEventHandlerParams) => {
|
||||
const [e] = args;
|
||||
e.preventDefault();
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
@ -0,0 +1,92 @@
|
||||
export const supportLanguage = [
|
||||
{
|
||||
id: 'css',
|
||||
title: 'CSS',
|
||||
},
|
||||
{
|
||||
id: 'html',
|
||||
title: 'HTML',
|
||||
},
|
||||
{
|
||||
id: 'javascript',
|
||||
title: 'JavaScript',
|
||||
},
|
||||
{
|
||||
id: 'json',
|
||||
title: 'JSON',
|
||||
},
|
||||
{
|
||||
id: 'markdown',
|
||||
title: 'Markdown',
|
||||
},
|
||||
{
|
||||
id: 'python',
|
||||
title: 'Python',
|
||||
},
|
||||
{
|
||||
id: 'typescript',
|
||||
title: 'TypeScript',
|
||||
},
|
||||
{
|
||||
id: 'xml',
|
||||
title: 'XML',
|
||||
},
|
||||
{
|
||||
id: 'yaml',
|
||||
title: 'YAML',
|
||||
},
|
||||
{
|
||||
id: 'bash',
|
||||
title: 'Bash',
|
||||
},
|
||||
{
|
||||
id: 'c',
|
||||
title: 'C',
|
||||
},
|
||||
{
|
||||
id: 'cpp',
|
||||
title: 'C++',
|
||||
},
|
||||
{
|
||||
id: 'csharp',
|
||||
title: 'C#',
|
||||
},
|
||||
{
|
||||
id: 'go',
|
||||
title: 'Go',
|
||||
},
|
||||
{
|
||||
id: 'java',
|
||||
title: 'Java',
|
||||
},
|
||||
|
||||
{
|
||||
id: 'php',
|
||||
title: 'PHP',
|
||||
},
|
||||
{
|
||||
id: 'ruby',
|
||||
title: 'Ruby',
|
||||
},
|
||||
{
|
||||
id: 'rust',
|
||||
title: 'Rust',
|
||||
},
|
||||
|
||||
{
|
||||
id: 'swift',
|
||||
title: 'Swift',
|
||||
},
|
||||
{
|
||||
id: 'sql',
|
||||
title: 'SQL',
|
||||
},
|
||||
{
|
||||
id: 'vb',
|
||||
title: 'Visual Basic',
|
||||
},
|
||||
{
|
||||
id: 'dart',
|
||||
title: 'Dart',
|
||||
},
|
||||
];
|
@ -161,6 +161,10 @@ export const blockConfig: Record<
|
||||
|
||||
[BlockType.CodeBlock]: {
|
||||
canAddChild: false,
|
||||
defaultData: {
|
||||
delta: [],
|
||||
language: 'javascript',
|
||||
},
|
||||
/**
|
||||
* ```
|
||||
*/
|
||||
|
@ -8,4 +8,5 @@ export const keyBoardEventKeyMap = {
|
||||
Right: 'ArrowRight',
|
||||
Space: ' ',
|
||||
Reduce: '-',
|
||||
Backquote: '`',
|
||||
};
|
||||
|
@ -1,75 +1,17 @@
|
||||
import { BlockType, DocumentState } from '$app/interfaces/document';
|
||||
import { DocumentController } from '$app/stores/effects/document/document_controller';
|
||||
import { createAsyncThunk } from '@reduxjs/toolkit';
|
||||
import { documentActions } from '$app_reducers/document/slice';
|
||||
import { outdentNodeThunk } from './outdent';
|
||||
import { setCursorAfterThunk } from '../../cursor';
|
||||
import { getPrevLineId } from '$app/utils/document/blocks/common';
|
||||
import { turnToTextBlockThunk } from '$app_reducers/document/async-actions/blocks/text/turn_to';
|
||||
import { mergeToPrevLineThunk } from '$app_reducers/document/async-actions/blocks/text/merge';
|
||||
|
||||
const composeNodeThunk = createAsyncThunk(
|
||||
'document/composeNode',
|
||||
async (payload: { id: string; composeId: string; controller: DocumentController }, thunkAPI) => {
|
||||
const { id, composeId, controller } = payload;
|
||||
const { dispatch, getState } = thunkAPI;
|
||||
const state = (getState() as { document: DocumentState }).document;
|
||||
const node = state.nodes[id];
|
||||
|
||||
const composeNode = state.nodes[composeId];
|
||||
// set cursor in compose node end
|
||||
// It must be stored before update, for the cursor can be restored after update
|
||||
await dispatch(setCursorAfterThunk({ id: composeId }));
|
||||
|
||||
// merge delta and update
|
||||
const nodeDelta = node.data?.delta || [];
|
||||
const composeDelta = composeNode.data?.delta || [];
|
||||
const newNode = {
|
||||
...composeNode,
|
||||
data: {
|
||||
...composeNode.data,
|
||||
delta: [...composeDelta, ...nodeDelta],
|
||||
},
|
||||
};
|
||||
const updateAction = controller.getUpdateAction(newNode);
|
||||
|
||||
// move children
|
||||
const children = state.children[node.children].map((id) => state.nodes[id]);
|
||||
const moveActions = controller.getMoveChildrenAction(children, newNode.id, '');
|
||||
|
||||
// delete node
|
||||
const deleteAction = controller.getDeleteAction(node);
|
||||
|
||||
// move must be before delete
|
||||
await controller.applyActions([...moveActions, deleteAction, updateAction]);
|
||||
// update local node data
|
||||
dispatch(documentActions.updateNodeData({ id: newNode.id, data: { delta: newNode.data.delta } }));
|
||||
}
|
||||
);
|
||||
|
||||
const composeParentThunk = createAsyncThunk(
|
||||
'document/composeParent',
|
||||
async (payload: { id: string; controller: DocumentController }, thunkAPI) => {
|
||||
const { id, controller } = payload;
|
||||
const { dispatch, getState } = thunkAPI;
|
||||
const state = (getState() as { document: DocumentState }).document;
|
||||
const node = state.nodes[id];
|
||||
if (!node.parent) return;
|
||||
await dispatch(composeNodeThunk({ id: id, composeId: node.parent, controller }));
|
||||
}
|
||||
);
|
||||
|
||||
const composePrevNodeThunk = createAsyncThunk(
|
||||
'document/composePrevNode',
|
||||
async (payload: { prevNodeId: string; id: string; controller: DocumentController }, thunkAPI) => {
|
||||
const { id, prevNodeId, controller } = payload;
|
||||
const { dispatch, getState } = thunkAPI;
|
||||
const state = (getState() as { document: DocumentState }).document;
|
||||
const prevLineId = getPrevLineId(state, id);
|
||||
if (!prevLineId) return;
|
||||
await dispatch(composeNodeThunk({ id: id, composeId: prevLineId, controller }));
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* 1. If current node is not text block, turn it to text block
|
||||
* 2. If current node is text block
|
||||
* 2.1 If the current node has next node, merge it to the previous line
|
||||
* 2.2 If the parent is root, merge it to the previous line
|
||||
* 2.3 If the parent is not root and has no next node, outdent it
|
||||
*/
|
||||
export const backspaceNodeThunk = createAsyncThunk(
|
||||
'document/backspaceNode',
|
||||
async (payload: { id: string; controller: DocumentController }, thunkAPI) => {
|
||||
@ -79,31 +21,22 @@ export const backspaceNodeThunk = createAsyncThunk(
|
||||
const node = state.nodes[id];
|
||||
if (!node.parent) return;
|
||||
const parent = state.nodes[node.parent];
|
||||
const ancestorId = parent.parent;
|
||||
const children = state.children[parent.children];
|
||||
const index = children.indexOf(id);
|
||||
const prevNodeId = children[index - 1];
|
||||
const nextNodeId = children[index + 1];
|
||||
// turn to text block
|
||||
if (node.type !== BlockType.TextBlock) {
|
||||
await dispatch(turnToTextBlockThunk({ id, controller }));
|
||||
return;
|
||||
}
|
||||
// compose to previous line when it has next sibling or no ancestor
|
||||
if (nextNodeId || !ancestorId) {
|
||||
// do nothing when it is the first line
|
||||
if (!prevNodeId && !ancestorId) return;
|
||||
// compose to parent when it has no previous sibling
|
||||
if (!prevNodeId) {
|
||||
await dispatch(composeParentThunk({ id, controller }));
|
||||
return;
|
||||
}
|
||||
await dispatch(composePrevNodeThunk({ prevNodeId, id, controller }));
|
||||
return;
|
||||
} else {
|
||||
// outdent when it has no next sibling
|
||||
await dispatch(outdentNodeThunk({ id, controller }));
|
||||
const parentIsRoot = !parent.parent;
|
||||
// merge to previous line when parent is root
|
||||
if (parentIsRoot || nextNodeId) {
|
||||
// merge to previous line
|
||||
await dispatch(mergeToPrevLineThunk({ id, controller, deleteCurrentNode: true }));
|
||||
return;
|
||||
}
|
||||
// outdent
|
||||
await dispatch(outdentNodeThunk({ id, controller }));
|
||||
}
|
||||
);
|
||||
|
@ -0,0 +1,85 @@
|
||||
import { createAsyncThunk } from '@reduxjs/toolkit';
|
||||
import { DocumentController } from '$app/stores/effects/document/document_controller';
|
||||
import { DocumentState } from '$app/interfaces/document';
|
||||
import { getPrevLineId } from '$app/utils/document/blocks/common';
|
||||
import { setCursorAfterThunk } from '$app_reducers/document/async-actions';
|
||||
import { documentActions } from '$app_reducers/document/slice';
|
||||
import { blockConfig } from '$app/constants/document/config';
|
||||
import { getNodeEndSelection } from '$app/utils/document/blocks/text/delta';
|
||||
|
||||
/**
|
||||
* It will merge delta to the prev line
|
||||
* 1. find the prev line and has delta
|
||||
* 1.1 Set cursor after the prev line
|
||||
* 1.2 merge delta
|
||||
* 2. If deleteCurrentNode is true, delete the current node and move children
|
||||
* 2.2.1 if the prev line can add children, move children to the prev line.
|
||||
* 2.2.2 Otherwise, move children to the parent and below the prev line
|
||||
* 3. If deleteCurrentNode is false, clear the current node delta
|
||||
*/
|
||||
export const mergeToPrevLineThunk = createAsyncThunk(
|
||||
'document/codeBlockBackspace',
|
||||
async (payload: { id: string; controller: DocumentController; deleteCurrentNode?: boolean }, thunkAPI) => {
|
||||
const { id, controller, deleteCurrentNode = false } = payload;
|
||||
const { dispatch, getState } = thunkAPI;
|
||||
const state = (getState() as { document: DocumentState }).document;
|
||||
const node = state.nodes[id];
|
||||
const prevLineId = getPrevLineId(state, id);
|
||||
if (!prevLineId) return;
|
||||
let prevLine = state.nodes[prevLineId];
|
||||
// Find the prev line that has delta
|
||||
while (prevLine && !prevLine.data.delta) {
|
||||
const id = getPrevLineId(state, prevLine.id);
|
||||
if (!id) return;
|
||||
prevLine = state.nodes[id];
|
||||
}
|
||||
if (!prevLine) return;
|
||||
|
||||
const prevLineDelta = prevLine.data.delta;
|
||||
|
||||
const selection = getNodeEndSelection(prevLineDelta);
|
||||
|
||||
const mergeDelta = [...prevLineDelta, ...node.data.delta];
|
||||
|
||||
dispatch(documentActions.updateNodeData({ id: prevLine.id, data: { delta: mergeDelta } }));
|
||||
|
||||
const updateAction = controller.getUpdateAction({
|
||||
...prevLine,
|
||||
data: {
|
||||
...prevLine.data,
|
||||
delta: mergeDelta,
|
||||
},
|
||||
});
|
||||
|
||||
const actions = [updateAction];
|
||||
|
||||
if (deleteCurrentNode) {
|
||||
// move children
|
||||
const config = blockConfig[prevLine.type];
|
||||
const children = state.children[node.children].map((id) => state.nodes[id]);
|
||||
const targetParentId = config.canAddChild ? prevLine.id : prevLine.parent;
|
||||
if (!targetParentId) return;
|
||||
const targetPrevId = targetParentId === prevLine.id ? '' : prevLine.id;
|
||||
const moveActions = controller.getMoveChildrenAction(children, targetParentId, targetPrevId);
|
||||
actions.push(...moveActions);
|
||||
// delete current block
|
||||
const deleteAction = controller.getDeleteAction(node);
|
||||
actions.push(deleteAction);
|
||||
} else {
|
||||
// clear current block delta
|
||||
dispatch(documentActions.updateNodeData({ id: node.id, data: { delta: [] } }));
|
||||
const updateAction = controller.getUpdateAction({
|
||||
...node,
|
||||
data: {
|
||||
...node.data,
|
||||
delta: [],
|
||||
},
|
||||
});
|
||||
actions.push(updateAction);
|
||||
}
|
||||
await controller.applyActions(actions);
|
||||
|
||||
// set cursor after the prev line
|
||||
dispatch(documentActions.setTextSelection({ blockId: prevLine.id, selection }));
|
||||
}
|
||||
);
|
@ -5,14 +5,16 @@ import { documentActions } from '$app_reducers/document/slice';
|
||||
import { setCursorBeforeThunk } from '../../cursor';
|
||||
import { newBlock } from '$app/utils/document/blocks/common';
|
||||
import { blockConfig, SplitRelationship } from '$app/constants/document/config';
|
||||
import { Editor } from 'slate';
|
||||
import { getSplitDelta } from '@/appflowy_app/utils/document/blocks/text/delta';
|
||||
|
||||
export const splitNodeThunk = createAsyncThunk(
|
||||
'document/splitNode',
|
||||
async (
|
||||
payload: { id: string; retain: TextDelta[]; insert: TextDelta[]; controller: DocumentController },
|
||||
thunkAPI
|
||||
) => {
|
||||
const { id, controller, retain, insert } = payload;
|
||||
async (payload: { id: string; editor: Editor; controller: DocumentController }, thunkAPI) => {
|
||||
const { id, controller, editor } = payload;
|
||||
// get the split content
|
||||
const { retain, insert } = getSplitDelta(editor);
|
||||
|
||||
const { dispatch, getState } = thunkAPI;
|
||||
const state = (getState() as { document: DocumentState }).document;
|
||||
const node = state.nodes[id];
|
||||
|
@ -0,0 +1,95 @@
|
||||
import Prism from 'prismjs';
|
||||
import 'prismjs/themes/prism.css';
|
||||
import 'prismjs/components/prism-bash';
|
||||
import 'prismjs/components/prism-c';
|
||||
import 'prismjs/components/prism-cpp';
|
||||
import 'prismjs/components/prism-csharp';
|
||||
import 'prismjs/components/prism-css';
|
||||
import 'prismjs/components/prism-dart';
|
||||
import 'prismjs/components/prism-docker';
|
||||
import 'prismjs/components/prism-go';
|
||||
import 'prismjs/components/prism-graphql';
|
||||
import 'prismjs/components/prism-groovy';
|
||||
import 'prismjs/components/prism-http';
|
||||
import 'prismjs/components/prism-java';
|
||||
import 'prismjs/components/prism-javascript';
|
||||
import 'prismjs/components/prism-json';
|
||||
import 'prismjs/components/prism-less';
|
||||
import 'prismjs/components/prism-typescript';
|
||||
import 'prismjs/components/prism-markdown';
|
||||
import 'prismjs/components/prism-python';
|
||||
import 'prismjs/components/prism-yaml';
|
||||
import 'prismjs/components/prism-regex';
|
||||
import 'prismjs/components/prism-ruby';
|
||||
import 'prismjs/components/prism-rust';
|
||||
import 'prismjs/components/prism-sass';
|
||||
import 'prismjs/components/prism-swift';
|
||||
import 'prismjs/components/prism-php';
|
||||
import 'prismjs/components/prism-sql';
|
||||
import 'prismjs/components/prism-visual-basic';
|
||||
|
||||
import { BaseRange, NodeEntry, Text, Path } from 'slate';
|
||||
|
||||
const push_string = (
|
||||
token: string | Prism.Token,
|
||||
path: Path,
|
||||
start: number,
|
||||
ranges: BaseRange[],
|
||||
token_type = 'text'
|
||||
) => {
|
||||
let newStart = start;
|
||||
ranges.push({
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
prism_token: token_type,
|
||||
anchor: { path, offset: newStart },
|
||||
focus: { path, offset: newStart + token.length },
|
||||
});
|
||||
newStart += token.length;
|
||||
return newStart;
|
||||
};
|
||||
|
||||
// This recurses through the Prism.tokenizes result and creates stylized ranges based on the token type
|
||||
const recurseTokenize = (
|
||||
token: string | Prism.Token,
|
||||
path: Path,
|
||||
ranges: BaseRange[],
|
||||
start: number,
|
||||
parent_tag?: string
|
||||
) => {
|
||||
// Uses the parent's token type if a Token only has a string as its content
|
||||
if (typeof token === 'string') {
|
||||
return push_string(token, path, start, ranges, parent_tag);
|
||||
}
|
||||
if ('content' in token) {
|
||||
if (token.content instanceof Array) {
|
||||
// Calls recurseTokenize on nested Tokens in content
|
||||
let newStart = start;
|
||||
for (const subToken of token.content) {
|
||||
newStart = recurseTokenize(subToken, path, ranges, newStart, token.type) || 0;
|
||||
}
|
||||
return newStart;
|
||||
}
|
||||
|
||||
return push_string(token.content, path, start, ranges, token.type);
|
||||
}
|
||||
};
|
||||
|
||||
export const decorateCodeFunc = ([node, path]: NodeEntry, language: string) => {
|
||||
const ranges: BaseRange[] = [];
|
||||
if (!Text.isText(node)) {
|
||||
return ranges;
|
||||
}
|
||||
|
||||
try {
|
||||
const tokens = Prism.tokenize(node.text, Prism.languages[language]);
|
||||
|
||||
let start = 0;
|
||||
for (const token of tokens) {
|
||||
start = recurseTokenize(token, path, ranges, start) || 0;
|
||||
}
|
||||
return ranges;
|
||||
} catch {
|
||||
return ranges;
|
||||
}
|
||||
};
|
@ -0,0 +1,34 @@
|
||||
import { getPointOfCurrentLineBeginning } from '$app/utils/document/blocks/text/delta';
|
||||
import { Editor, Transforms } from 'slate';
|
||||
|
||||
export function indent(editor: Editor, distance: number) {
|
||||
const beginPoint = getPointOfCurrentLineBeginning(editor);
|
||||
const emptyStr = ''.padStart(distance);
|
||||
|
||||
Transforms.insertText(editor, emptyStr, {
|
||||
at: beginPoint,
|
||||
});
|
||||
}
|
||||
export function outdent(editor: Editor, distance: number) {
|
||||
const beginPoint = getPointOfCurrentLineBeginning(editor);
|
||||
if (!beginPoint) return;
|
||||
const afterBeginPoint = Editor.after(editor, beginPoint, {
|
||||
distance,
|
||||
});
|
||||
if (!afterBeginPoint) return;
|
||||
const deleteChar = Editor.string(editor, {
|
||||
anchor: beginPoint,
|
||||
focus: afterBeginPoint,
|
||||
});
|
||||
const emptyStr = ''.padStart(distance);
|
||||
if (deleteChar !== emptyStr) {
|
||||
if (distance > 1) {
|
||||
outdent(editor, distance - 1);
|
||||
}
|
||||
return;
|
||||
}
|
||||
Transforms.delete(editor, {
|
||||
at: beginPoint,
|
||||
distance,
|
||||
});
|
||||
}
|
@ -1,9 +1,8 @@
|
||||
import { BlockData, BlockType, DocumentState, NestedBlock, TextDelta } from '$app/interfaces/document';
|
||||
import { Descendant, Editor, Element, Text } from 'slate';
|
||||
import { Descendant, Element, Text } from 'slate';
|
||||
import { BlockPB } from '@/services/backend';
|
||||
import { Log } from '$app/utils/log';
|
||||
import { nanoid } from 'nanoid';
|
||||
import { getAfterRangeAt } from '$app/utils/document/blocks/text/delta';
|
||||
|
||||
export function deltaToSlateValue(delta: TextDelta[]) {
|
||||
const slateNode = {
|
||||
@ -22,14 +21,6 @@ export function deltaToSlateValue(delta: TextDelta[]) {
|
||||
return slateNodes;
|
||||
}
|
||||
|
||||
export function getDeltaAfterSelection(editor: Editor): TextDelta[] | undefined {
|
||||
const selection = editor.selection;
|
||||
if (!selection) return;
|
||||
const slateNodes = Editor.fragment(editor, getAfterRangeAt(editor, selection));
|
||||
const delta = getDeltaFromSlateNodes(slateNodes);
|
||||
return delta;
|
||||
}
|
||||
|
||||
export function getDeltaFromSlateNodes(slateNodes: Descendant[]) {
|
||||
const element = slateNodes[0] as Element;
|
||||
const children = element.children as Text[];
|
||||
|
@ -7,8 +7,7 @@ import {
|
||||
TodoListBlockData,
|
||||
ToggleListBlockData,
|
||||
} from '$app/interfaces/document';
|
||||
import { getBeforeRangeAt } from '$app/utils/document/blocks/text/delta';
|
||||
import { getDeltaAfterSelection } from '$app/utils/document/blocks/common';
|
||||
import { getAfterRangeAt, getBeforeRangeAt, getDeltaAfterSelection } from '$app/utils/document/blocks/text/delta';
|
||||
|
||||
/**
|
||||
* get heading data from editor, only support markdown
|
||||
@ -118,3 +117,16 @@ export function getCalloutDataFromEditor(editor: Editor): CalloutBlockData | und
|
||||
icon: iconMap[tag],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* get code block data from editor, only support markdown
|
||||
*/
|
||||
export function getCodeBlockDataFromEditor(editor: Editor) {
|
||||
const delta = getDeltaAfterSelection(editor);
|
||||
if (!delta) return;
|
||||
return {
|
||||
delta,
|
||||
language: 'javascript',
|
||||
wrap: true,
|
||||
};
|
||||
}
|
||||
|
@ -1,6 +1,7 @@
|
||||
import { Editor, Element, Location, Text } from 'slate';
|
||||
import { SelectionPoint, TextDelta, TextSelection } from '$app/interfaces/document';
|
||||
import * as Y from 'yjs';
|
||||
import { getDeltaFromSlateNodes } from '$app/utils/document/blocks/common';
|
||||
|
||||
export function getDelta(editor: Editor, at: Location): TextDelta[] {
|
||||
const baseElement = Editor.fragment(editor, at)[0] as Element;
|
||||
@ -118,6 +119,23 @@ export function getLastLineOffsetByDelta(delta: TextDelta[]): number {
|
||||
return index === -1 ? 0 : index + 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* get the offset of per line beginning
|
||||
* @param editor
|
||||
*/
|
||||
export function getOffsetOfPerLineBeginning(editor: Editor): number[] {
|
||||
const delta = getDeltaFromSlateNodes(editor.children);
|
||||
const lines = getLinesByDelta(delta);
|
||||
const offsets: number[] = [];
|
||||
let offset = 0;
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const lineText = lines[i] + '\n';
|
||||
offsets.push(offset);
|
||||
offset += lineText.length;
|
||||
}
|
||||
return offsets;
|
||||
}
|
||||
|
||||
/**
|
||||
* get the selection of the end line by offset
|
||||
* @param delta
|
||||
@ -168,6 +186,21 @@ export function getSelectionByTextOffset(delta: TextDelta[], offset: number) {
|
||||
return selection;
|
||||
}
|
||||
|
||||
/**
|
||||
* get the text offset by selection
|
||||
* @param delta
|
||||
* @param point
|
||||
*/
|
||||
export function getTextOffsetBySelection(delta: TextDelta[], point: SelectionPoint) {
|
||||
let textOffset = 0;
|
||||
for (let i = 0; i < point.path[1]; i++) {
|
||||
const item = delta[i];
|
||||
textOffset += item.insert.length;
|
||||
}
|
||||
textOffset += point.offset;
|
||||
return textOffset;
|
||||
}
|
||||
|
||||
/**
|
||||
* get the point by text offset
|
||||
* @param delta
|
||||
@ -208,3 +241,44 @@ export function isSameDelta(referDelta: TextDelta[], delta: TextDelta[]) {
|
||||
yTextRefer.applyDelta(referDelta);
|
||||
return JSON.stringify(yText.toDelta()) === JSON.stringify(yTextRefer.toDelta());
|
||||
}
|
||||
|
||||
export function getDeltaBeforeSelection(editor: Editor) {
|
||||
const selection = editor.selection;
|
||||
if (!selection) return;
|
||||
const beforeRange = getBeforeRangeAt(editor, selection);
|
||||
return getDelta(editor, beforeRange);
|
||||
}
|
||||
|
||||
export function getDeltaAfterSelection(editor: Editor): TextDelta[] | undefined {
|
||||
const selection = editor.selection;
|
||||
if (!selection) return;
|
||||
const afterRange = getAfterRangeAt(editor, selection);
|
||||
return getDelta(editor, afterRange);
|
||||
}
|
||||
|
||||
export function getSplitDelta(editor: Editor) {
|
||||
// get the retain content
|
||||
const retain = getDeltaBeforeSelection(editor) || [];
|
||||
// get the insert content
|
||||
const insert = getDeltaAfterSelection(editor) || [];
|
||||
return { retain, insert };
|
||||
}
|
||||
|
||||
export function getPointOfCurrentLineBeginning(editor: Editor) {
|
||||
const { selection } = editor;
|
||||
if (!selection) return;
|
||||
const delta = getDeltaFromSlateNodes(editor.children);
|
||||
const textOffset = getTextOffsetBySelection(delta, selection.anchor as SelectionPoint);
|
||||
const offsets = getOffsetOfPerLineBeginning(editor);
|
||||
let lineNumber = offsets.findIndex((item) => item > textOffset);
|
||||
if (lineNumber === -1) {
|
||||
lineNumber = offsets.length - 1;
|
||||
} else {
|
||||
lineNumber -= 1;
|
||||
}
|
||||
|
||||
const lineBeginOffset = offsets[lineNumber];
|
||||
|
||||
const beginPoint = getPointByTextOffset(delta, lineBeginOffset);
|
||||
return beginPoint;
|
||||
}
|
||||
|
@ -1,8 +1,7 @@
|
||||
import isHotkey from 'is-hotkey';
|
||||
import { toggleFormat } from './format';
|
||||
import { Editor, Range } from 'slate';
|
||||
import { clonePoint, getAfterRangeAt, getBeforeRangeAt, getDelta, pointInBegin, pointInEnd } from './delta';
|
||||
import { SelectionPoint, TextDelta, TextSelection } from '$app/interfaces/document';
|
||||
import { getAfterRangeAt, getBeforeRangeAt, pointInBegin, pointInEnd } from './delta';
|
||||
import { keyBoardEventKeyMap } from '$app/constants/document/text_block';
|
||||
|
||||
const HOTKEYS: Record<string, string> = {
|
||||
@ -24,11 +23,6 @@ export function triggerHotkey(event: React.KeyboardEvent<HTMLDivElement>, editor
|
||||
}
|
||||
}
|
||||
|
||||
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 = isHotkey('backspace', event);
|
||||
const selection = editor.selection;
|
||||
@ -41,10 +35,6 @@ export function canHandleBackspaceKey(event: React.KeyboardEvent<HTMLDivElement>
|
||||
return isCollapsed && pointInBegin(editor, selection);
|
||||
}
|
||||
|
||||
export function canHandleTabKey(event: React.KeyboardEvent<HTMLDivElement>, _: Editor) {
|
||||
return isHotkey('tab', event);
|
||||
}
|
||||
|
||||
export function canHandleUpKey(event: React.KeyboardEvent<HTMLDivElement>, editor: Editor) {
|
||||
const isUpKey = event.key === keyBoardEventKeyMap.Up;
|
||||
const selection = editor.selection;
|
||||
@ -98,56 +88,3 @@ export function canHandleRightKey(event: React.KeyboardEvent<HTMLDivElement>, ed
|
||||
const isCollapsed = Range.isCollapsed(selection);
|
||||
return isCollapsed && pointInEnd(editor, selection);
|
||||
}
|
||||
|
||||
export function onHandleEnterKey(
|
||||
event: React.KeyboardEvent<HTMLDivElement>,
|
||||
editor: Editor,
|
||||
{
|
||||
onSplit,
|
||||
onWrap,
|
||||
}: {
|
||||
onSplit: (...args: [TextDelta[], TextDelta[]]) => Promise<void>;
|
||||
onWrap: (newDelta: TextDelta[], _selection: TextSelection) => Promise<void>;
|
||||
}
|
||||
) {
|
||||
const selection = editor.selection;
|
||||
if (!selection) return;
|
||||
// get the retain content
|
||||
const retainRange = getBeforeRangeAt(editor, selection);
|
||||
const retain = getDelta(editor, retainRange);
|
||||
// get the insert content
|
||||
const insertRange = getAfterRangeAt(editor, selection);
|
||||
const insert = getDelta(editor, insertRange);
|
||||
|
||||
// if the shift key is pressed, break wrap the current node
|
||||
if (isHotkey('shift+enter', event)) {
|
||||
const newSelection = getSelectionAfterBreakWrap(editor);
|
||||
if (!newSelection) return;
|
||||
|
||||
// insert `\n` after the retain content
|
||||
void onWrap([...retain, { insert: '\n' }, ...insert], newSelection);
|
||||
return;
|
||||
}
|
||||
|
||||
// if the enter key is pressed, split the current node
|
||||
if (isHotkey('enter', event)) {
|
||||
// retain this node and insert a new node
|
||||
void onSplit(retain, insert);
|
||||
return;
|
||||
}
|
||||
|
||||
// other cases, do nothing
|
||||
return;
|
||||
}
|
||||
|
||||
function getSelectionAfterBreakWrap(editor: Editor) {
|
||||
const selection = editor.selection;
|
||||
if (!selection) return;
|
||||
const start = Range.start(selection);
|
||||
const cursor = { path: start.path, offset: start.offset + 1 } as SelectionPoint;
|
||||
const newSelection = {
|
||||
anchor: clonePoint(cursor),
|
||||
focus: clonePoint(cursor),
|
||||
} as TextSelection;
|
||||
return newSelection;
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user