Support block config (#2419)

* fix: refactor block config
This commit is contained in:
Kilu.He 2023-05-04 11:24:35 +08:00 committed by GitHub
parent 1f187a3917
commit cf97c8ba9c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
35 changed files with 513 additions and 581 deletions

View File

@ -12,8 +12,10 @@ module.exports = {
sourceType: 'module',
tsconfigRootDir: __dirname,
},
plugins: ['@typescript-eslint'],
plugins: ['@typescript-eslint', "react-hooks"],
rules: {
"react-hooks/rules-of-hooks": "error",
"react-hooks/exhaustive-deps": "warn",
'@typescript-eslint/adjacent-overload-signatures': 'error',
'@typescript-eslint/no-empty-function': 'error',
'@typescript-eslint/no-empty-interface': 'warn',

View File

@ -66,6 +66,7 @@
"autoprefixer": "^10.4.13",
"eslint": "^8.34.0",
"eslint-plugin-react": "^7.32.2",
"eslint-plugin-react-hooks": "^4.6.0",
"postcss": "^8.4.21",
"prettier": "2.8.4",
"prettier-plugin-tailwindcss": "^0.2.2",

View File

@ -25,6 +25,7 @@ specifiers:
dayjs: ^1.11.7
eslint: ^8.34.0
eslint-plugin-react: ^7.32.2
eslint-plugin-react-hooks: ^4.6.0
events: ^3.3.0
google-protobuf: ^3.21.2
i18next: ^22.4.10
@ -110,6 +111,7 @@ devDependencies:
autoprefixer: 10.4.13_postcss@8.4.21
eslint: 8.35.0
eslint-plugin-react: 7.32.2_eslint@8.35.0
eslint-plugin-react-hooks: 4.6.0_eslint@8.35.0
postcss: 8.4.21
prettier: 2.8.4
prettier-plugin-tailwindcss: 0.2.4_prettier@2.8.4
@ -2426,6 +2428,15 @@ packages:
resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==}
engines: {node: '>=10'}
/eslint-plugin-react-hooks/4.6.0_eslint@8.35.0:
resolution: {integrity: sha512-oFc7Itz9Qxh2x4gNHStv3BqJq54ExXmfC+a1NjAta66IAN87Wu0R/QArgIS9qKzX3dXKPI9H5crl9QchNMY9+g==}
engines: {node: '>=10'}
peerDependencies:
eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0
dependencies:
eslint: 8.35.0
dev: true
/eslint-plugin-react/7.32.2_eslint@8.35.0:
resolution: {integrity: sha512-t2fBMa+XzonrrNkyVirzKlvn5RXzzPwRHtMvLAtVZrt8oxgnTQaYbU6SXTOO1mwQgp1y5+toMSKInnzGr0Knqg==}
engines: {node: '>=4'}

View File

@ -1,31 +1,6 @@
import { useCallback, useContext, useMemo } from 'react';
import { Editor } from 'slate';
import { TextBlockKeyEventHandlerParams, TextDelta, TextSelection } from '$app/interfaces/document';
import { useCallback } from 'react';
import { useTextInput } from '../_shared/TextInput.hooks';
import { useAppDispatch } from '@/appflowy_app/stores/store';
import { DocumentControllerContext } from '@/appflowy_app/stores/effects/document/document_controller';
import {
backspaceNodeThunk,
indentNodeThunk,
splitNodeThunk,
setCursorNextLineThunk,
setCursorPreLineThunk,
} from '@/appflowy_app/stores/reducers/document/async-actions';
import { documentActions } from '@/appflowy_app/stores/reducers/document/slice';
import {
canHandleBackspaceKey,
canHandleDownKey,
canHandleEnterKey,
canHandleLeftKey,
canHandleRightKey,
canHandleTabKey,
canHandleUpKey,
onHandleEnterKey,
triggerHotkey,
} from '$app/utils/document/slate/hotkey';
import { updateNodeDeltaThunk } from '$app_reducers/document/async-actions/blocks/text/update';
import { useMarkDown } from './useMarkDown.hooks';
import { keyBoardEventKeyMap } from '$app/constants/document/text_block';
import { useTextBlockKeyEvent } from '$app/components/document/TextBlock/events/Events.hooks';
export function useTextBlock(id: string) {
const { editor, onChange, value } = useTextInput(id);
@ -55,177 +30,3 @@ export function useTextBlock(id: string) {
value,
};
}
function useTextBlockKeyEvent(id: string, editor: Editor) {
const { indentAction, backSpaceAction, splitAction, wrapAction, focusPreLineAction, focusNextLineAction } =
useActions(id);
const { markdownEvents } = useMarkDown(id);
const enterEvent = useMemo(() => {
return {
triggerEventKey: keyBoardEventKeyMap.Enter,
canHandle: canHandleEnterKey,
handler: (...args: TextBlockKeyEventHandlerParams) => {
onHandleEnterKey(...args, {
onSplit: splitAction,
onWrap: wrapAction,
});
},
};
}, [splitAction, wrapAction]);
const tabEvent = useMemo(() => {
return {
triggerEventKey: keyBoardEventKeyMap.Tab,
canHandle: canHandleTabKey,
handler: (..._args: TextBlockKeyEventHandlerParams) => {
void indentAction();
},
};
}, [indentAction]);
const backSpaceEvent = useMemo(() => {
return {
triggerEventKey: keyBoardEventKeyMap.Backspace,
canHandle: canHandleBackspaceKey,
handler: (..._args: TextBlockKeyEventHandlerParams) => {
void backSpaceAction();
},
};
}, [backSpaceAction]);
const upEvent = useMemo(() => {
return {
triggerEventKey: keyBoardEventKeyMap.Up,
canHandle: canHandleUpKey,
handler: (...args: TextBlockKeyEventHandlerParams) => {
void focusPreLineAction({
editor: args[1],
});
},
};
}, [focusPreLineAction]);
const downEvent = useMemo(() => {
return {
triggerEventKey: keyBoardEventKeyMap.Down,
canHandle: canHandleDownKey,
handler: (...args: TextBlockKeyEventHandlerParams) => {
void focusNextLineAction({
editor: args[1],
});
},
};
}, [focusNextLineAction]);
const leftEvent = useMemo(() => {
return {
triggerEventKey: keyBoardEventKeyMap.Left,
canHandle: canHandleLeftKey,
handler: (...args: TextBlockKeyEventHandlerParams) => {
void focusPreLineAction({
editor: args[1],
focusEnd: true,
});
},
};
}, [focusPreLineAction]);
const rightEvent = useMemo(() => {
return {
triggerEventKey: keyBoardEventKeyMap.Right,
canHandle: canHandleRightKey,
handler: (...args: TextBlockKeyEventHandlerParams) => {
void focusNextLineAction({
editor: args[1],
focusStart: true,
});
},
};
}, [focusNextLineAction]);
const onKeyDown = useCallback(
(event: React.KeyboardEvent<HTMLDivElement>) => {
// This is list of key events that can be handled by TextBlock
const keyEvents = [enterEvent, backSpaceEvent, tabEvent, upEvent, downEvent, leftEvent, rightEvent];
keyEvents.push(...markdownEvents);
const matchKeys = keyEvents.filter((keyEvent) => keyEvent.canHandle(event, editor));
if (matchKeys.length === 0) {
triggerHotkey(event, editor);
return;
}
event.stopPropagation();
event.preventDefault();
matchKeys.forEach((matchKey) => matchKey.handler(event, editor));
},
[editor, enterEvent, backSpaceEvent, tabEvent, upEvent, downEvent, leftEvent, rightEvent, markdownEvents]
);
return {
onKeyDown,
};
}
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

@ -0,0 +1,75 @@
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

@ -0,0 +1,113 @@
import { Editor } from 'slate';
import { useTurnIntoBlock } from './TurnIntoEvents.hooks';
import { useCallback, 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/slate/hotkey';
import { TextBlockKeyEventHandlerParams } from '$app/interfaces/document';
import { useActions } from './Actions.hooks';
export function useTextBlockKeyEvent(id: string, editor: Editor) {
const { indentAction, backSpaceAction, splitAction, wrapAction, focusPreLineAction, focusNextLineAction } =
useActions(id);
const { turnIntoBlockEvents } = useTurnIntoBlock(id);
const events = useMemo(() => {
return [
{
triggerEventKey: keyBoardEventKeyMap.Enter,
canHandle: canHandleEnterKey,
handler: (...args: TextBlockKeyEventHandlerParams) => {
onHandleEnterKey(...args, {
onSplit: splitAction,
onWrap: wrapAction,
});
},
},
{
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,
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,
});
},
},
];
}, [splitAction, wrapAction, indentAction, backSpaceAction, focusPreLineAction, focusNextLineAction]);
const onKeyDown = useCallback(
(event: React.KeyboardEvent<HTMLDivElement>) => {
// This is list of key events that can be handled by TextBlock
const keyEvents = [...events, ...turnIntoBlockEvents];
const matchKeys = keyEvents.filter((keyEvent) => keyEvent.canHandle(event, editor));
if (matchKeys.length === 0) {
triggerHotkey(event, editor);
return;
}
event.stopPropagation();
event.preventDefault();
matchKeys.forEach((matchKey) => matchKey.handler(event, editor));
},
[editor, events, turnIntoBlockEvents]
);
return {
onKeyDown,
};
}

View File

@ -0,0 +1,67 @@
import { useContext, useMemo } from 'react';
import { BlockData, 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 } from '$app_reducers/document/async-actions';
import { blockConfig } from '$app/constants/document/config';
import { Editor } from 'slate';
import { getBeforeRangeAt } from '$app/utils/document/slate/text';
import { getHeadingDataFromEditor, getQuoteDataFromEditor, getTodoListDataFromEditor } from '$app/utils/document/blocks';
const blockDataFactoryMap: Record<string, (editor: Editor) => BlockData<any> | undefined> = {
[BlockType.HeadingBlock]: getHeadingDataFromEditor,
[BlockType.TodoListBlock]: getTodoListDataFromEditor,
[BlockType.QuoteBlock]: getQuoteDataFromEditor,
};
export function useTurnIntoBlock(id: string) {
const controller = useContext(DocumentControllerContext);
const dispatch = useAppDispatch();
const turnIntoBlockEvents = useMemo(() => {
return Object.entries(blockDataFactoryMap).map(([type, getData]) => {
const blockType = type as BlockType;
return {
triggerEventKey: keyBoardEventKeyMap.Space,
canHandle: canHandle(blockType),
handler: (...args: TextBlockKeyEventHandlerParams) => {
if (!controller) return;
const [_event, editor] = args;
const data = getData(editor);
if (!data) return;
dispatch(turnToBlockThunk({ id, data, type: blockType, controller }));
},
};
}, []);
}, [controller, dispatch, id]);
return {
turnIntoBlockEvents,
};
}
function canHandle(type: BlockType) {
const config = blockConfig[type];
const regex = config.markdownRegexps;
// This error will be thrown if the block type is not in the config, and it will happen in development environment
if (!regex) {
throw new Error(`canHandle: block type ${type} is not supported`);
}
return (...args: TextBlockKeyEventHandlerParams) => {
const [event, editor] = args;
const isSpaceKey = event.key === keyBoardEventKeyMap.Space;
const selection = editor.selection;
if (!isSpaceKey || !selection) {
return false;
}
const flag = Editor.string(editor, getBeforeRangeAt(editor, selection)).trim();
if (flag === null) return false;
return regex.some((r) => r.test(flag));
};
}

View File

@ -1,7 +1,6 @@
import { Slate, Editable } from 'slate-react';
import Leaf from './Leaf';
import { useTextBlock } from './TextBlock.hooks';
import NodeComponent from '../Node';
import BlockHorizontalToolbar from '../BlockHorizontalToolbar';
import React from 'react';
import { NestedBlock } from '$app/interfaces/document';

View File

@ -1,86 +0,0 @@
import { useCallback, useContext, useMemo } from 'react';
import { TextBlockKeyEventHandlerParams } from '$app/interfaces/document';
import { keyBoardEventKeyMap } from '$app/constants/document/text_block';
import {
canHandleToHeadingBlock,
canHandleToCheckboxBlock,
canHandleToQuoteBlock,
} from '$app/utils/document/slate/markdown';
import { useAppDispatch } from '$app/stores/store';
import { DocumentControllerContext } from '$app/stores/effects/document/document_controller';
import { turnToHeadingBlockThunk } from '$app_reducers/document/async-actions/blocks/heading';
import { turnToTodoListBlockThunk } from '$app_reducers/document/async-actions/blocks/todo_list';
import { turnToQuoteBlockThunk } from '$app_reducers/document/async-actions/blocks/quote';
export function useMarkDown(id: string) {
const { toHeadingBlockAction, toCheckboxBlockAction, toQuoteBlockAction } = useActions(id);
const toHeadingBlockEvent = useMemo(() => {
return {
triggerEventKey: keyBoardEventKeyMap.Space,
canHandle: canHandleToHeadingBlock,
handler: toHeadingBlockAction,
};
}, [toHeadingBlockAction]);
const toCheckboxBlockEvent = useMemo(() => {
return {
triggerEventKey: keyBoardEventKeyMap.Space,
canHandle: canHandleToCheckboxBlock,
handler: toCheckboxBlockAction,
};
}, [toCheckboxBlockAction]);
const toQuoteBlockEvent = useMemo(() => {
return {
triggerEventKey: keyBoardEventKeyMap.Space,
canHandle: canHandleToQuoteBlock,
handler: toQuoteBlockAction,
};
}, [toQuoteBlockAction]);
const markdownEvents = useMemo(
() => [toHeadingBlockEvent, toCheckboxBlockEvent, toQuoteBlockEvent],
[toHeadingBlockEvent, toCheckboxBlockEvent, toQuoteBlockEvent]
);
return {
markdownEvents,
};
}
function useActions(id: string) {
const controller = useContext(DocumentControllerContext);
const dispatch = useAppDispatch();
const toHeadingBlockAction = useCallback(
(...args: TextBlockKeyEventHandlerParams) => {
if (!controller) return;
const [_event, editor] = args;
dispatch(turnToHeadingBlockThunk({ id, editor, controller }));
},
[controller, dispatch, id]
);
const toCheckboxBlockAction = useCallback(
(...args: TextBlockKeyEventHandlerParams) => {
if (!controller) return;
const [_event, editor] = args;
dispatch(turnToTodoListBlockThunk({ id, controller, editor }));
},
[controller, dispatch, id]
);
const toQuoteBlockAction = useCallback(
(...args: TextBlockKeyEventHandlerParams) => {
if (!controller) return;
const [_event, editor] = args;
dispatch(turnToQuoteBlockThunk({ id, controller, editor }));
},
[controller, dispatch, id]
);
return {
toHeadingBlockAction,
toCheckboxBlockAction,
toQuoteBlockAction,
};
}

View File

@ -11,6 +11,7 @@ import { useAppDispatch, useAppSelector } from '@/appflowy_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 { isSameDelta } from '$app/utils/document/blocks/text';
export function useTextInput(id: string) {
const dispatch = useAppDispatch();
@ -25,27 +26,18 @@ export function useTextInput(id: string) {
const { editor, yText } = useBindYjs(id, delta);
useEffect(() => {
return () => {
dispatch(documentActions.removeTextSelection(id));
};
}, [id]);
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) || !editor.selection || !editor.selection.anchor || !editor.selection.focus)
return;
const { anchor, focus } = editor.selection;
const selection = { anchor, focus } as TextSelection;
if (!ReactEditor.isFocused(editor)) return;
const selection = editor.selection as TextSelection;
dispatch(documentActions.setTextSelection({ blockId: id, selection }));
}, [editor]);
const currentSelection = useAppSelector((state) => state.document.textSelections[id]);
const restoreSelection = useCallback(() => {
if (editor.selection && JSON.stringify(currentSelection) === JSON.stringify(editor.selection)) return;
setSelection(editor, currentSelection);
}, [editor, currentSelection]);
@ -54,13 +46,15 @@ export function useTextInput(id: string) {
setValue(e);
storeSelection();
},
[storeSelection]
);
useEffect(() => {
restoreSelection();
}, [restoreSelection]);
return () => {
dispatch(documentActions.removeTextSelection(id));
};
}, [id, restoreSelection]);
if (editor.selection && ReactEditor.isFocused(editor)) {
const domSelection = window.getSelection();
@ -128,12 +122,13 @@ function useBindYjs(id: string, delta: TextDelta[]) {
if (!yText) return;
// If the delta is not equal to the current yText, then we need to update the yText
if (JSON.stringify(yText.toDelta()) !== JSON.stringify(delta)) {
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);
}
const isSame = isSameDelta(delta, yText.toDelta());
if (isSame) return;
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]);
return { editor, yText: yTextRef.current };
@ -167,7 +162,6 @@ function setSelection(editor: ReactEditor, currentSelection: TextSelection) {
if (!currentSelection || !currentSelection.anchor || !currentSelection.focus) {
if (ReactEditor.isFocused(editor)) {
ReactEditor.blur(editor);
ReactEditor.deselect(editor);
}
return;
}
@ -178,12 +172,12 @@ function setSelection(editor: ReactEditor, currentSelection: TextSelection) {
}
const { path, offset } = currentSelection.focus;
// It is possible that the current selection is out of range
const children = getDeltaFromSlateNodes(editor.children);
// the path always has 2 elements,
// because the slate 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;
}

View File

@ -1,17 +1,75 @@
import { BlockType } from '$app/interfaces/document';
/**
* Block types that are allowed to have children
* If the block type is not in the config, it will be thrown an error in development env
*/
export const allowedChildrenBlockTypes = [
BlockType.TextBlock,
BlockType.PageBlock,
BlockType.TodoListBlock,
BlockType.QuoteBlock,
BlockType.CalloutBlock,
];
/**
* Block types that split node can extend to the next line
*/
export const splitableBlockTypes = [BlockType.TextBlock, BlockType.TodoListBlock];
export const blockConfig: Record<
string,
{
/**
* Whether the block can have children
*/
canAddChild: boolean;
/**
* the type of the block that will be split from the current block
*/
splitType: BlockType;
/**
* The regexps that will be used to match the markdown flag
*/
markdownRegexps?: RegExp[];
}
> = {
[BlockType.TextBlock]: {
canAddChild: true,
splitType: BlockType.TextBlock,
},
[BlockType.HeadingBlock]: {
canAddChild: false,
splitType: BlockType.TextBlock,
/**
* # or ## or ###
*/
markdownRegexps: [/^(#{1,3})$/],
},
[BlockType.TodoListBlock]: {
canAddChild: true,
splitType: BlockType.TodoListBlock,
/**
* -[] or -[x] or -[ ] or [] or [x] or [ ]
*/
markdownRegexps: [/^((-)?\[(x|\s)?\])$/],
},
[BlockType.BulletedListBlock]: {
canAddChild: true,
splitType: BlockType.BulletedListBlock,
/**
* - or + or *
*/
markdownRegexps: [/^(\s*[-+*])$/],
},
[BlockType.NumberedListBlock]: {
canAddChild: true,
splitType: BlockType.NumberedListBlock,
/**
* 1. or 2. or 3.
*/
markdownRegexps: [/^(\s*\d+\.)$/],
},
[BlockType.QuoteBlock]: {
canAddChild: true,
splitType: BlockType.TextBlock,
/**
* " or or
*/
markdownRegexps: [/^("|“|”)$/],
},
[BlockType.CodeBlock]: {
canAddChild: false,
splitType: BlockType.TextBlock,
/**
* ```
*/
markdownRegexps: [/^(```)$/],
},
};

View File

@ -5,6 +5,8 @@ export enum BlockType {
HeadingBlock = 'heading',
TextBlock = 'text',
TodoListBlock = 'todo_list',
BulletedListBlock = 'bulleted_list',
NumberedListBlock = 'numbered_list',
CodeBlock = 'code',
EmbedBlock = 'embed',
QuoteBlock = 'quote',

View File

@ -15,6 +15,7 @@ import * as Y from 'yjs';
import { BLOCK_MAP_NAME, CHILDREN_MAP_NAME, META_NAME } from '$app/constants/document/block';
import { get } from '@/appflowy_app/utils/tool';
import { blockPB2Node } from '$app/utils/document/blocks/common';
import { Log } from '$app/utils/log';
export const DocumentControllerContext = createContext<DocumentController | null>(null);
@ -65,6 +66,7 @@ export class DocumentController {
};
applyActions = async (actions: ReturnType<typeof BlockActionPB.prototype.toObject>[]) => {
Log.debug('applyActions', actions);
await this.backendService.applyActions(actions);
};
@ -153,6 +155,7 @@ export class DocumentController {
if (!this.onDocChange) return;
const { events, is_remote } = DocEventPB.deserializeBinary(payload);
Log.debug('DocumentController', 'updated', { events, is_remote });
events.forEach((blockEvent) => {
blockEvent.event.forEach((_payload) => {
this.onDocChange?.({

View File

@ -1,6 +1,3 @@
import { Ok, Result } from 'ts-results';
import { ChangeNotifier } from '$app/utils/change_notifier';
import { FolderNotificationObserver } from '../folder/notifications/observer';
import { DocumentNotification } from '@/services/backend';
import { DocumentNotificationObserver } from './notifications/observer';

View File

@ -1,31 +0,0 @@
import { createAsyncThunk } from '@reduxjs/toolkit';
import { Editor } from 'slate';
import { DocumentController } from '$app/stores/effects/document/document_controller';
import { BlockType } from '$app/interfaces/document';
import { turnToBlockThunk } from '$app_reducers/document/async-actions/turn_to';
import { getHeadingDataFromEditor } from '$app/utils/document/blocks/heading';
/**
* transform to heading block
* 1. insert heading block after current block
* 2. move all children to parent after heading block, because heading block can't have children
* 3. delete current block
*/
export const turnToHeadingBlockThunk = createAsyncThunk(
'document/turnToHeadingBlock',
async (payload: { id: string; editor: Editor; controller: DocumentController }, thunkAPI) => {
const { id, editor, controller } = payload;
const { dispatch } = thunkAPI;
const data = getHeadingDataFromEditor(editor);
if (!data) return;
await dispatch(
turnToBlockThunk({
id,
controller,
type: BlockType.HeadingBlock,
data,
})
);
}
);

View File

@ -0,0 +1 @@
export * from './text';

View File

@ -1,31 +0,0 @@
import { createAsyncThunk } from '@reduxjs/toolkit';
import { DocumentController } from '$app/stores/effects/document/document_controller';
import { BlockType } from '$app/interfaces/document';
import { turnToBlockThunk } from '$app_reducers/document/async-actions/turn_to';
import { Editor } from 'slate';
import { getQuoteDataFromEditor } from '$app/utils/document/blocks/quote';
/**
* transform to quote block
* 1. insert quote block after current block
* 2. move children to quote block
* 3. delete current block
*/
export const turnToQuoteBlockThunk = createAsyncThunk(
'document/turnToQuoteBlock',
async (payload: { id: string; editor: Editor; controller: DocumentController }, thunkAPI) => {
const { id, controller, editor } = payload;
const { dispatch } = thunkAPI;
const data = getQuoteDataFromEditor(editor);
if (!data) return;
await dispatch(
turnToBlockThunk({
id,
controller,
type: BlockType.QuoteBlock,
data,
})
);
}
);

View File

@ -4,8 +4,8 @@ import { createAsyncThunk } from '@reduxjs/toolkit';
import { documentActions } from '$app_reducers/document/slice';
import { outdentNodeThunk } from './outdent';
import { setCursorAfterThunk } from '../../cursor';
import { turnToTextBlockThunk } from '$app_reducers/document/async-actions/blocks/text/index';
import { getPrevLineId } from '$app/utils/document/blocks/common';
import { turnToTextBlockThunk } from '$app_reducers/document/async-actions/blocks/text/turn_to';
const composeNodeThunk = createAsyncThunk(
'document/composeNode',

View File

@ -1,7 +1,7 @@
import { BlockType, DocumentState } from '$app/interfaces/document';
import { DocumentState } from '$app/interfaces/document';
import { DocumentController } from '$app/stores/effects/document/document_controller';
import { createAsyncThunk } from '@reduxjs/toolkit';
import { allowedChildrenBlockTypes } from '$app/constants/document/config';
import { blockConfig } from '$app/constants/document/config';
export const indentNodeThunk = createAsyncThunk(
'document/indentNode',
@ -20,7 +20,8 @@ export const indentNodeThunk = createAsyncThunk(
const newParentId = children[index - 1];
const prevNode = state.nodes[newParentId];
// check if prev node is allowed to have children
if (!allowedChildrenBlockTypes.includes(prevNode.type)) return;
const config = blockConfig[prevNode.type];
if (!config.canAddChild) return;
// check if prev node has children and get last child for new prev node
const prevNodeChildren = state.children[prevNode.children];
const newPrevId = prevNodeChildren[prevNodeChildren.length - 1];

View File

@ -1,32 +1,8 @@
import { createAsyncThunk } from '@reduxjs/toolkit';
import { DocumentController } from '$app/stores/effects/document/document_controller';
import { BlockType, DocumentState } from '$app/interfaces/document';
import { turnToBlockThunk } from '$app_reducers/document/async-actions/turn_to';
/**
* transform to text block
* 1. insert text block after current block
* 2. move children to text block
* 3. delete current block
*/
export const turnToTextBlockThunk = createAsyncThunk(
'document/turnToTextBlock',
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];
const data = {
delta: node.data.delta,
};
await dispatch(
turnToBlockThunk({
id,
controller,
type: BlockType.TextBlock,
data,
})
);
}
);
export * from './delete';
export * from './indent';
export * from './insert';
export * from './backspace';
export * from './outdent';
export * from './split';
export * from './turn_to';
export * from './update';

View File

@ -1,10 +1,10 @@
import { BlockType, DocumentState, TextDelta } from '$app/interfaces/document';
import { DocumentState, TextDelta } 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 { setCursorBeforeThunk } from '../../cursor';
import { getDefaultBlockData, newBlock } from '$app/utils/document/blocks/common';
import { splitableBlockTypes } from '$app/constants/document/config';
import { blockConfig } from '$app/constants/document/config';
export const splitNodeThunk = createAsyncThunk(
'document/splitNode',
@ -18,10 +18,11 @@ export const splitNodeThunk = createAsyncThunk(
const node = state.nodes[id];
if (!node.parent) return;
const children = state.children[node.children];
const prevId = children.length > 0 ? null : node.id;
const parent = children.length > 0 ? node : state.nodes[node.parent];
const prevId = node.id;
const parent = state.nodes[node.parent];
const newNodeType = splitableBlockTypes.includes(node.type) ? node.type : BlockType.TextBlock;
const config = blockConfig[node.type];
const newNodeType = config.splitType;
const defaultData = getDefaultBlockData(newNodeType);
const newNode = newBlock<any>(newNodeType, parent.id, {
...defaultData,
@ -34,7 +35,14 @@ export const splitNodeThunk = createAsyncThunk(
delta: retain,
},
};
await controller.applyActions([controller.getInsertAction(newNode, prevId), controller.getUpdateAction(retainNode)]);
const insertAction = controller.getInsertAction(newNode, prevId);
const updateAction = controller.getUpdateAction(retainNode);
const moveChildrenAction = controller.getMoveChildrenAction(
children.map((id) => state.nodes[id]),
newNode.id,
''
);
await controller.applyActions([insertAction, ...moveChildrenAction, updateAction]);
// update local node data
dispatch(documentActions.updateNodeData({ id: retainNode.id, data: { delta: retain } }));
// set cursor

View File

@ -0,0 +1,32 @@
import { createAsyncThunk } from '@reduxjs/toolkit';
import { DocumentController } from '$app/stores/effects/document/document_controller';
import { BlockType, DocumentState } from '$app/interfaces/document';
import { turnToBlockThunk } from '$app_reducers/document/async-actions/turn_to';
/**
* transform to text block
* 1. insert text block after current block
* 2. move children to text block
* 3. delete current block
*/
export const turnToTextBlockThunk = createAsyncThunk(
'document/turnToTextBlock',
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];
const data = {
delta: node.data.delta,
};
await dispatch(
turnToBlockThunk({
id,
controller,
type: BlockType.TextBlock,
data,
})
);
}
);

View File

@ -9,11 +9,11 @@ export const updateNodeDeltaThunk = createAsyncThunk(
const { id, delta, controller } = payload;
const { dispatch, getState } = thunkAPI;
const state = (getState() as { document: DocumentState }).document;
const node = state.nodes[id];
// The block map should be updated immediately
// or the component will use the old data to update the editor
dispatch(documentActions.updateNodeData({ id, data: { delta } }));
const node = state.nodes[id];
// the transaction is delayed to avoid too many updates
debounceApplyUpdate(controller, {
...node,
@ -47,17 +47,16 @@ export const updateNodeDataThunk = createAsyncThunk<
const { id, data, controller } = payload;
const { dispatch, getState } = thunkAPI;
const state = (getState() as { document: DocumentState }).document;
dispatch(documentActions.updateNodeData({ id, data: { ...data } }));
const node = state.nodes[id];
const newData = { ...node.data, ...data };
dispatch(documentActions.updateNodeData({ id, data: newData }));
await controller.applyActions([
controller.getUpdateAction({
...node,
data: {
...node.data,
...data,
},
data: newData,
}),
]);
});

View File

@ -1,31 +0,0 @@
import { createAsyncThunk } from '@reduxjs/toolkit';
import { DocumentController } from '$app/stores/effects/document/document_controller';
import { BlockType } from '$app/interfaces/document';
import { turnToBlockThunk } from '$app_reducers/document/async-actions/turn_to';
import { Editor } from 'slate';
import { getTodoListDataFromEditor } from '$app/utils/document/blocks/todo_list';
/**
* transform to todolist block
* 1. insert todolist block after current block
* 2. move children to todolist block
* 3. delete current block
*/
export const turnToTodoListBlockThunk = createAsyncThunk(
'document/turnToTodoListBlock',
async (payload: { id: string; editor: Editor; controller: DocumentController }, thunkAPI) => {
const { id, controller, editor } = payload;
const { dispatch } = thunkAPI;
const data = getTodoListDataFromEditor(editor);
if (!data) return;
await dispatch(
turnToBlockThunk({
id,
controller,
type: BlockType.TodoListBlock,
data,
})
);
}
);

View File

@ -95,6 +95,7 @@ export const setCursorNextLineThunk = createAsyncThunk(
// set the cursor to next line with the relative offset
const newSelection = getStartLineSelectionByOffset(delta, textOffset);
dispatch(documentActions.setTextSelection({ blockId: nextLineNode.id, selection: newSelection }));
}
);

View File

@ -1,7 +1,3 @@
export * from './blocks/text/delete';
export * from './blocks/text/indent';
export * from './blocks/text/insert';
export * from './blocks/text/backspace';
export * from './blocks/text/outdent';
export * from './blocks/text/split';
export * from './cursor';
export * from './blocks';
export * from './turn_to';

View File

@ -2,9 +2,17 @@ import { createAsyncThunk } from '@reduxjs/toolkit';
import { DocumentController } from '$app/stores/effects/document/document_controller';
import { BlockData, BlockType, DocumentState, NestedBlock } from '$app/interfaces/document';
import { setCursorBeforeThunk } from '$app_reducers/document/async-actions/cursor';
import { allowedChildrenBlockTypes } from '$app/constants/document/config';
import { blockConfig } from '$app/constants/document/config';
import { newBlock } from '$app/utils/document/blocks/common';
/**
* transform to block
* 1. insert block after current block
* 2. move all children
* - if new block is not allowed to have children, move children to parent
* - otherwise, move children to new block
* 3. delete current block
*/
export const turnToBlockThunk = createAsyncThunk(
'document/turnToBlock',
async (payload: { id: string; controller: DocumentController; type: BlockType; data: BlockData<any> }, thunkAPI) => {
@ -18,19 +26,14 @@ export const turnToBlockThunk = createAsyncThunk(
const parent = state.nodes[node.parent];
const children = state.children[node.children].map((id) => state.nodes[id]);
/**
* transform to block
* 1. insert block after current block
* 2. move all children
* 3. delete current block
*/
const block = newBlock<any>(type, parent.id, data);
// insert new block after current block
const insertHeadingAction = controller.getInsertAction(block, node.id);
// check if prev node is allowed to have children
const config = blockConfig[block.type];
// if new block is not allowed to have children, move children to parent
const newParent = allowedChildrenBlockTypes.includes(block.type) ? block : parent;
const newParent = config.canAddChild ? block : parent;
// if move children to parent, set prev to current block, otherwise the prev is empty
const newPrev = newParent.id === parent.id ? block.id : '';
const moveChildrenActions = controller.getMoveChildrenAction(children, newParent.id, newPrev);

View File

@ -1,22 +0,0 @@
import { Editor } from 'slate';
import { getAfterRangeAt, getBeforeRangeAt } from '$app/utils/document/slate/text';
import { HeadingBlockData } from '$app/interfaces/document';
import { getDeltaAfterSelection, getDeltaFromSlateNodes } from '$app/utils/document/blocks/common';
/**
* get heading data from editor, only support markdown
* @param editor
*/
export function getHeadingDataFromEditor(editor: Editor): HeadingBlockData | undefined {
const selection = editor.selection;
if (!selection) return;
const hashTags = Editor.string(editor, getBeforeRangeAt(editor, selection));
const level = hashTags.match(/#/g)?.length;
if (!level) return;
const delta = getDeltaAfterSelection(editor);
if (!delta) return;
return {
level,
delta,
};
}

View File

@ -0,0 +1,52 @@
import { Editor } from 'slate';
import { HeadingBlockData, TodoListBlockData } from '$app/interfaces/document';
import { getAfterRangeAt, getBeforeRangeAt } from '$app/utils/document/slate/text';
import { getDeltaAfterSelection, getDeltaFromSlateNodes } from '$app/utils/document/blocks/common';
/**
* get heading data from editor, only support markdown
* @param editor
*/
export function getHeadingDataFromEditor(editor: Editor): HeadingBlockData | undefined {
const selection = editor.selection;
if (!selection) return;
const hashTags = Editor.string(editor, getBeforeRangeAt(editor, selection));
const level = hashTags.match(/#/g)?.length;
if (!level) return;
const delta = getDeltaAfterSelection(editor);
if (!delta) return;
return {
level,
delta,
};
}
/**
* get quote data from editor, only support markdown
* @param editor
*/
export function getQuoteDataFromEditor(editor: Editor) {
const delta = getDeltaAfterSelection(editor);
if (!delta) return;
return {
delta,
size: 'default',
};
}
/**
* get todo_list data from editor, only support markdown
* @param editor
*/
export function getTodoListDataFromEditor(editor: Editor): TodoListBlockData | undefined {
const selection = editor.selection;
if (!selection) return;
const hashTags = Editor.string(editor, getBeforeRangeAt(editor, selection));
const checked = hashTags.match(/x/g)?.length;
const slateNodes = Editor.fragment(editor, getAfterRangeAt(editor, selection));
const delta = getDeltaFromSlateNodes(slateNodes);
return {
delta,
checked: !!checked,
};
}

View File

@ -1,11 +0,0 @@
import { Editor } from 'slate';
import { getDeltaAfterSelection } from '$app/utils/document/blocks/common';
export function getQuoteDataFromEditor(editor: Editor) {
const delta = getDeltaAfterSelection(editor);
if (!delta) return;
return {
delta,
size: 'default',
};
}

View File

@ -1,6 +1,16 @@
import { BlockType, NestedBlock, TextBlockData } from '$app/interfaces/document';
import { BlockType, NestedBlock, TextBlockData, TextDelta } from '$app/interfaces/document';
import { newBlock } from '$app/utils/document/blocks/common';
import * as Y from 'yjs';
export function newTextBlock(parentId: string, data: TextBlockData): NestedBlock {
return newBlock<BlockType.TextBlock>(BlockType.TextBlock, parentId, data);
}
export function isSameDelta(referDelta: TextDelta[], delta: TextDelta[]) {
const ydoc = new Y.Doc();
const yText = ydoc.getText('1');
const yTextRefer = ydoc.getText('2');
yText.applyDelta(delta);
yTextRefer.applyDelta(referDelta);
return JSON.stringify(yText.toDelta()) === JSON.stringify(yTextRefer.toDelta());
}

View File

@ -1,21 +0,0 @@
import { Editor } from 'slate';
import { TodoListBlockData } from '$app/interfaces/document';
import { getAfterRangeAt, getBeforeRangeAt } from '$app/utils/document/slate/text';
import { getDeltaFromSlateNodes } from '$app/utils/document/blocks/common';
/**
* get todo_list data from editor, only support markdown
* @param editor
*/
export function getTodoListDataFromEditor(editor: Editor): TodoListBlockData | undefined {
const selection = editor.selection;
if (!selection) return;
const hashTags = Editor.string(editor, getBeforeRangeAt(editor, selection));
const checked = hashTags.match(/x/g)?.length;
const slateNodes = Editor.fragment(editor, getAfterRangeAt(editor, selection));
const delta = getDeltaFromSlateNodes(slateNodes);
return {
delta,
checked: !!checked,
};
}

View File

@ -1,39 +0,0 @@
import { keyBoardEventKeyMap } from '$app/constants/document/text_block';
import { getBeforeRangeAt } from '$app/utils/document/slate/text';
import { Editor } from 'slate';
export function canHandleToHeadingBlock(event: React.KeyboardEvent<HTMLDivElement>, editor: Editor): boolean {
const flag = getMarkdownFlag(event, editor);
if (!flag) return false;
const isHeadingMarkdown = /^(#{1,3})$/.test(flag);
return isHeadingMarkdown;
}
export function canHandleToCheckboxBlock(event: React.KeyboardEvent<HTMLDivElement>, editor: Editor) {
const flag = getMarkdownFlag(event, editor);
if (!flag) return false;
const isCheckboxMarkdown = /^((-)?\[(x|\s)?\])$/.test(flag);
return isCheckboxMarkdown;
}
export function canHandleToQuoteBlock(event: React.KeyboardEvent<HTMLDivElement>, editor: Editor) {
const flag = getMarkdownFlag(event, editor);
if (!flag) return false;
const isQuoteMarkdown = /^("|“|”)$/.test(flag);
return isQuoteMarkdown;
}
function getMarkdownFlag(event: React.KeyboardEvent<HTMLDivElement>, editor: Editor) {
const isSpaceKey = event.key === keyBoardEventKeyMap.Space;
const selection = editor.selection;
if (!isSpaceKey || !selection) {
return null;
}
return Editor.string(editor, getBeforeRangeAt(editor, selection)).trim();
}

View File

@ -41,8 +41,7 @@ export const useDocument = () => {
}
Log.debug('close document', params.id);
};
// dispose controller before unload
window.addEventListener('beforeunload', closeDocument);
return closeDocument;
}, [params.id]);

View File

@ -14,7 +14,10 @@ lazy_static! {
pub fn register_notification_sender<T: NotificationSender>(sender: T) {
let box_sender = Box::new(sender);
match NOTIFICATION_SENDER.write() {
Ok(mut write_guard) => write_guard.push(box_sender),
Ok(mut write_guard) => {
write_guard.pop();
write_guard.push(box_sender)
},
Err(err) => tracing::error!("Failed to push notification sender: {:?}", err),
}
}