From cf97c8ba9c967cdceb0650c1746506320113bc79 Mon Sep 17 00:00:00 2001 From: "Kilu.He" <108015703+qinluhe@users.noreply.github.com> Date: Thu, 4 May 2023 11:24:35 +0800 Subject: [PATCH] Support block config (#2419) * fix: refactor block config --- frontend/appflowy_tauri/.eslintrc.cjs | 4 +- frontend/appflowy_tauri/package.json | 1 + frontend/appflowy_tauri/pnpm-lock.yaml | 11 + .../document/TextBlock/TextBlock.hooks.ts | 203 +----------------- .../TextBlock/events/Actions.hooks.ts | 75 +++++++ .../document/TextBlock/events/Events.hooks.ts | 113 ++++++++++ .../TextBlock/events/TurnIntoEvents.hooks.ts | 67 ++++++ .../components/document/TextBlock/index.tsx | 1 - .../document/TextBlock/useMarkDown.hooks.ts | 86 -------- .../document/_shared/TextInput.hooks.ts | 36 ++-- .../appflowy_app/constants/document/config.ts | 84 ++++++-- .../src/appflowy_app/interfaces/document.ts | 2 + .../effects/document/document_controller.ts | 3 + .../effects/document/document_observer.ts | 3 - .../document/async-actions/blocks/heading.ts | 31 --- .../document/async-actions/blocks/index.ts | 1 + .../document/async-actions/blocks/quote.ts | 31 --- .../async-actions/blocks/text/backspace.ts | 2 +- .../async-actions/blocks/text/indent.ts | 7 +- .../async-actions/blocks/text/index.ts | 40 +--- .../async-actions/blocks/text/split.ts | 20 +- .../async-actions/blocks/text/turn_to.ts | 32 +++ .../async-actions/blocks/text/update.ts | 15 +- .../async-actions/blocks/todo_list.ts | 31 --- .../reducers/document/async-actions/cursor.ts | 1 + .../reducers/document/async-actions/index.ts | 8 +- .../document/async-actions/turn_to.ts | 21 +- .../utils/document/blocks/heading.ts | 22 -- .../utils/document/blocks/index.ts | 52 +++++ .../utils/document/blocks/quote.ts | 11 - .../utils/document/blocks/text.ts | 12 +- .../utils/document/blocks/todo_list.ts | 21 -- .../utils/document/slate/markdown.ts | 39 ---- .../appflowy_app/views/DocumentPage.hooks.ts | 3 +- .../rust-lib/flowy-notification/src/lib.rs | 5 +- 35 files changed, 513 insertions(+), 581 deletions(-) create mode 100644 frontend/appflowy_tauri/src/appflowy_app/components/document/TextBlock/events/Actions.hooks.ts create mode 100644 frontend/appflowy_tauri/src/appflowy_app/components/document/TextBlock/events/Events.hooks.ts create mode 100644 frontend/appflowy_tauri/src/appflowy_app/components/document/TextBlock/events/TurnIntoEvents.hooks.ts delete mode 100644 frontend/appflowy_tauri/src/appflowy_app/components/document/TextBlock/useMarkDown.hooks.ts delete mode 100644 frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/heading.ts create mode 100644 frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/index.ts delete mode 100644 frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/quote.ts create mode 100644 frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/text/turn_to.ts delete mode 100644 frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/todo_list.ts delete mode 100644 frontend/appflowy_tauri/src/appflowy_app/utils/document/blocks/heading.ts create mode 100644 frontend/appflowy_tauri/src/appflowy_app/utils/document/blocks/index.ts delete mode 100644 frontend/appflowy_tauri/src/appflowy_app/utils/document/blocks/quote.ts delete mode 100644 frontend/appflowy_tauri/src/appflowy_app/utils/document/blocks/todo_list.ts delete mode 100644 frontend/appflowy_tauri/src/appflowy_app/utils/document/slate/markdown.ts diff --git a/frontend/appflowy_tauri/.eslintrc.cjs b/frontend/appflowy_tauri/.eslintrc.cjs index 59f19f03f9..dfc0cc41f8 100644 --- a/frontend/appflowy_tauri/.eslintrc.cjs +++ b/frontend/appflowy_tauri/.eslintrc.cjs @@ -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', diff --git a/frontend/appflowy_tauri/package.json b/frontend/appflowy_tauri/package.json index b801eb8384..b718bfd7c5 100644 --- a/frontend/appflowy_tauri/package.json +++ b/frontend/appflowy_tauri/package.json @@ -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", diff --git a/frontend/appflowy_tauri/pnpm-lock.yaml b/frontend/appflowy_tauri/pnpm-lock.yaml index 335502f468..71505bc604 100644 --- a/frontend/appflowy_tauri/pnpm-lock.yaml +++ b/frontend/appflowy_tauri/pnpm-lock.yaml @@ -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'} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/TextBlock/TextBlock.hooks.ts b/frontend/appflowy_tauri/src/appflowy_app/components/document/TextBlock/TextBlock.hooks.ts index 763a3bc6f9..7fb5145e92 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/TextBlock/TextBlock.hooks.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/TextBlock/TextBlock.hooks.ts @@ -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) => { - // 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, - }; -} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/TextBlock/events/Actions.hooks.ts b/frontend/appflowy_tauri/src/appflowy_app/components/document/TextBlock/events/Actions.hooks.ts new file mode 100644 index 0000000000..8e5c178394 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/TextBlock/events/Actions.hooks.ts @@ -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, + }; +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/TextBlock/events/Events.hooks.ts b/frontend/appflowy_tauri/src/appflowy_app/components/document/TextBlock/events/Events.hooks.ts new file mode 100644 index 0000000000..2c8b324685 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/TextBlock/events/Events.hooks.ts @@ -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) => { + // 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, + }; +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/TextBlock/events/TurnIntoEvents.hooks.ts b/frontend/appflowy_tauri/src/appflowy_app/components/document/TextBlock/events/TurnIntoEvents.hooks.ts new file mode 100644 index 0000000000..50d34cfddd --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/TextBlock/events/TurnIntoEvents.hooks.ts @@ -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 BlockData | 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)); + }; +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/TextBlock/index.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/document/TextBlock/index.tsx index fc430b1ce5..9cdec5f24a 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/TextBlock/index.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/TextBlock/index.tsx @@ -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'; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/TextBlock/useMarkDown.hooks.ts b/frontend/appflowy_tauri/src/appflowy_app/components/document/TextBlock/useMarkDown.hooks.ts deleted file mode 100644 index ea8e86231e..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/TextBlock/useMarkDown.hooks.ts +++ /dev/null @@ -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, - }; -} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/TextInput.hooks.ts b/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/TextInput.hooks.ts index 3c45bbeba5..483ae2bf8a 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/TextInput.hooks.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/TextInput.hooks.ts @@ -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([]); 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; } diff --git a/frontend/appflowy_tauri/src/appflowy_app/constants/document/config.ts b/frontend/appflowy_tauri/src/appflowy_app/constants/document/config.ts index 7e81043af9..0155eb4eb9 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/constants/document/config.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/constants/document/config.ts @@ -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: [/^(```)$/], + }, +}; diff --git a/frontend/appflowy_tauri/src/appflowy_app/interfaces/document.ts b/frontend/appflowy_tauri/src/appflowy_app/interfaces/document.ts index 0c529dc1ab..620072bd10 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/interfaces/document.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/interfaces/document.ts @@ -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', diff --git a/frontend/appflowy_tauri/src/appflowy_app/stores/effects/document/document_controller.ts b/frontend/appflowy_tauri/src/appflowy_app/stores/effects/document/document_controller.ts index f55e26f871..804a03bb69 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/stores/effects/document/document_controller.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/stores/effects/document/document_controller.ts @@ -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(null); @@ -65,6 +66,7 @@ export class DocumentController { }; applyActions = async (actions: ReturnType[]) => { + 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?.({ diff --git a/frontend/appflowy_tauri/src/appflowy_app/stores/effects/document/document_observer.ts b/frontend/appflowy_tauri/src/appflowy_app/stores/effects/document/document_observer.ts index 7ab264ed18..f124748105 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/stores/effects/document/document_observer.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/stores/effects/document/document_observer.ts @@ -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'; diff --git a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/heading.ts b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/heading.ts deleted file mode 100644 index b9bb6c3883..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/heading.ts +++ /dev/null @@ -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, - }) - ); - } -); diff --git a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/index.ts b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/index.ts new file mode 100644 index 0000000000..1a9ac1460f --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/index.ts @@ -0,0 +1 @@ +export * from './text'; diff --git a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/quote.ts b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/quote.ts deleted file mode 100644 index 5950825233..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/quote.ts +++ /dev/null @@ -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, - }) - ); - } -); diff --git a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/text/backspace.ts b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/text/backspace.ts index da8053a923..5597867eec 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/text/backspace.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/text/backspace.ts @@ -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', diff --git a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/text/indent.ts b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/text/indent.ts index b7be58b237..b4bc905b41 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/text/indent.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/text/indent.ts @@ -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]; diff --git a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/text/index.ts b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/text/index.ts index 040e9545ec..c44117ea62 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/text/index.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/text/index.ts @@ -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'; diff --git a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/text/split.ts b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/text/split.ts index dd20d11c8b..cab78e9e38 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/text/split.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/text/split.ts @@ -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(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 diff --git a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/text/turn_to.ts b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/text/turn_to.ts new file mode 100644 index 0000000000..040e9545ec --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/text/turn_to.ts @@ -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, + }) + ); + } +); diff --git a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/text/update.ts b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/text/update.ts index 7c8d5f2910..ffe75a2afc 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/text/update.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/text/update.ts @@ -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, }), ]); }); diff --git a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/todo_list.ts b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/todo_list.ts deleted file mode 100644 index 6c6d53084b..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/todo_list.ts +++ /dev/null @@ -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, - }) - ); - } -); diff --git a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/cursor.ts b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/cursor.ts index 3cd15bc32a..00b994ee2d 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/cursor.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/cursor.ts @@ -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 })); } ); diff --git a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/index.ts b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/index.ts index be0989f7a9..2b82dfe792 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/index.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/index.ts @@ -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'; diff --git a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/turn_to.ts b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/turn_to.ts index 7120d767cc..470a0c2f56 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/turn_to.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/turn_to.ts @@ -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 }, 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(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); diff --git a/frontend/appflowy_tauri/src/appflowy_app/utils/document/blocks/heading.ts b/frontend/appflowy_tauri/src/appflowy_app/utils/document/blocks/heading.ts deleted file mode 100644 index 86b7138bee..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/utils/document/blocks/heading.ts +++ /dev/null @@ -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, - }; -} diff --git a/frontend/appflowy_tauri/src/appflowy_app/utils/document/blocks/index.ts b/frontend/appflowy_tauri/src/appflowy_app/utils/document/blocks/index.ts new file mode 100644 index 0000000000..8e373f7feb --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/utils/document/blocks/index.ts @@ -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, + }; +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/utils/document/blocks/quote.ts b/frontend/appflowy_tauri/src/appflowy_app/utils/document/blocks/quote.ts deleted file mode 100644 index 82b321d284..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/utils/document/blocks/quote.ts +++ /dev/null @@ -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', - }; -} diff --git a/frontend/appflowy_tauri/src/appflowy_app/utils/document/blocks/text.ts b/frontend/appflowy_tauri/src/appflowy_app/utils/document/blocks/text.ts index 8cdf73e41b..3fa667611b 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/utils/document/blocks/text.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/utils/document/blocks/text.ts @@ -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, 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()); +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/utils/document/blocks/todo_list.ts b/frontend/appflowy_tauri/src/appflowy_app/utils/document/blocks/todo_list.ts deleted file mode 100644 index 3d48f8529e..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/utils/document/blocks/todo_list.ts +++ /dev/null @@ -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, - }; -} diff --git a/frontend/appflowy_tauri/src/appflowy_app/utils/document/slate/markdown.ts b/frontend/appflowy_tauri/src/appflowy_app/utils/document/slate/markdown.ts deleted file mode 100644 index 8118a20eaa..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/utils/document/slate/markdown.ts +++ /dev/null @@ -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, 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, 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, editor: Editor) { - const flag = getMarkdownFlag(event, editor); - if (!flag) return false; - - const isQuoteMarkdown = /^("|“|”)$/.test(flag); - - return isQuoteMarkdown; -} - -function getMarkdownFlag(event: React.KeyboardEvent, 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(); -} diff --git a/frontend/appflowy_tauri/src/appflowy_app/views/DocumentPage.hooks.ts b/frontend/appflowy_tauri/src/appflowy_app/views/DocumentPage.hooks.ts index 6bba20b6fe..eaa626ab56 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/views/DocumentPage.hooks.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/views/DocumentPage.hooks.ts @@ -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]); diff --git a/frontend/rust-lib/flowy-notification/src/lib.rs b/frontend/rust-lib/flowy-notification/src/lib.rs index 1b29026408..40b722af4a 100644 --- a/frontend/rust-lib/flowy-notification/src/lib.rs +++ b/frontend/rust-lib/flowy-notification/src/lib.rs @@ -14,7 +14,10 @@ lazy_static! { pub fn register_notification_sender(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), } }