feat: support code block (#2464)

This commit is contained in:
Kilu.He 2023-05-10 15:53:30 +08:00 committed by GitHub
parent 0b343f7ee1
commit dad0419da0
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
29 changed files with 895 additions and 402 deletions

View File

@ -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",

View File

@ -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'}

View File

@ -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,

View File

@ -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,

View File

@ -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,
};
}

View File

@ -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;

View File

@ -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>
);
};

View File

@ -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>
);
}

View File

@ -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>

View File

@ -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);

View File

@ -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,

View File

@ -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,
};
}

View File

@ -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]

View File

@ -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]);

View File

@ -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...'}

View File

@ -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);
}

View File

@ -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();
},
},
];
}

View File

@ -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',
},
];

View File

@ -161,6 +161,10 @@ export const blockConfig: Record<
[BlockType.CodeBlock]: {
canAddChild: false,
defaultData: {
delta: [],
language: 'javascript',
},
/**
* ```
*/

View File

@ -8,4 +8,5 @@ export const keyBoardEventKeyMap = {
Right: 'ArrowRight',
Space: ' ',
Reduce: '-',
Backquote: '`',
};

View File

@ -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 }));
}
);

View File

@ -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 }));
}
);

View File

@ -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];

View File

@ -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;
}
};

View File

@ -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,
});
}

View File

@ -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[];

View File

@ -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,
};
}

View File

@ -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;
}

View File

@ -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;
}