From 8cee792b941860a4681b10e4c2b8059f532e8865 Mon Sep 17 00:00:00 2001 From: "Kilu.He" <108015703+qinluhe@users.noreply.github.com> Date: Fri, 2 Jun 2023 10:05:38 +0800 Subject: [PATCH] Refactor text block delta and across block selection (#2671) * fix: add block menu comment * refactor: separation of abstract delta and editor, and optimization across block selections --- frontend/appflowy_tauri/package.json | 8 +- frontend/appflowy_tauri/pnpm-lock.yaml | 164 +++++++- .../BlockRangeSelection.hooks.ts | 146 +++++-- .../BlockRectSelection.hooks.ts | 13 +- .../BlockSelection/BlockRectSelection.tsx | 11 +- .../BlockSelection/RangeKeyDown.hooks.ts | 119 ++++++ .../document/BlockSelection/index.tsx | 5 +- .../document/BlockSideToolbar/BlockMenu.tsx | 19 +- .../BlockSideToolbar/BlockMenuTurnInto.tsx | 1 + .../BlockSideToolbar.hooks.tsx | 16 +- .../document/BlockSideToolbar/index.tsx | 6 +- .../document/BlockSlash/BlockSlashMenu.tsx | 6 + .../document/BlockSlash/index.hooks.ts | 4 +- .../document/CodeBlock/CodeBlock.hooks.ts | 86 ---- .../document/CodeBlock/SelectLanguage.tsx | 7 +- .../components/document/CodeBlock/index.tsx | 38 +- .../document/CodeBlock/useKeyDown.ts | 51 +++ .../document/HeadingBlock/index.tsx | 2 +- .../components/document/Node/index.tsx | 6 +- .../document/Overlay/BlockOverlay.tsx | 7 + .../components/document/Root/index.tsx | 2 +- .../document/TextActionMenu/index.hooks.ts | 45 ++- .../document/TextActionMenu/index.tsx | 57 ++- .../TextActionMenu/menu/FormatButton.tsx | 2 +- .../TextActionMenu/menu/FormatIcon.tsx | 11 +- .../TextActionMenu/menu/index.hooks.ts | 19 +- .../document/TextActionMenu/menu/index.tsx | 52 +-- .../components/document/TextBlock/Leaf.tsx | 40 -- .../document/TextBlock/TextBlock.hooks.ts | 14 - .../document/TextBlock/events/Events.hooks.ts | 134 ------- .../TextBlock/events/TurnIntoEvents.hooks.ts | 102 ----- .../components/document/TextBlock/index.tsx | 40 +- .../document/TextBlock/useKeyDown.ts | 105 +++++ .../TextBlock/useTurnIntoBlockEvents.ts | 185 +++++++++ .../TodoListBlock/TodoListBlock.hooks.ts | 2 +- .../ToggleListBlock/ToggleListBlock.hooks.ts | 2 +- .../VirtualizedList/VirtualizedList.hooks.tsx | 3 +- .../document/VirtualizedList/index.tsx | 7 +- .../document/_shared/EditorHooks/useChange.ts | 33 ++ .../_shared/EditorHooks/useCommonKeyEvents.ts | 79 ++++ .../document/_shared/EditorHooks/useDelta.ts | 43 ++ .../_shared/EditorHooks/useSelection.ts | 55 +++ .../document/_shared/QuillEditor/Editor.css | 23 ++ .../document/_shared/QuillEditor/Editor.tsx | 30 ++ .../document/_shared/QuillEditor/useEditor.ts | 100 +++++ .../_shared/SlateEditor/CodeEditor.tsx | 34 ++ .../SlateEditor/CodeElements.tsx} | 8 +- .../_shared/SlateEditor/TextEditor.tsx | 28 ++ .../_shared/SlateEditor/TextElement.tsx | 65 +++ .../document/_shared/SlateEditor/TextLeaf.tsx | 61 +++ .../_shared/SlateEditor/decorateCode.ts} | 27 +- .../document/_shared/SlateEditor/useEditor.ts | 142 +++++++ .../_shared/SlateEditor/useSlateYjs.ts | 43 ++ .../document/_shared/SubscribeNode.hooks.ts | 61 +-- .../_shared/SubscribeSelection.hooks.ts | 43 ++ .../document/_shared/Text/TextEvents.hooks.ts | 111 ----- .../document/_shared/Text/TextInput.hooks.ts | 134 ------- .../_shared/Text/TextSelection.hooks.ts | 98 ----- .../appflowy_app/constants/document/code.ts | 73 ---- .../constants/document/keyboard.ts | 32 ++ .../constants/document/text_block.ts | 13 - .../src/appflowy_app/interfaces/document.ts | 114 +++--- .../effects/document/document_controller.ts | 2 +- .../async-actions/blocks/duplicate.ts | 19 +- .../async-actions/blocks/{text => }/indent.ts | 4 +- .../document/async-actions/blocks/index.ts | 5 +- .../document/async-actions/blocks/insert.ts | 15 +- .../document/async-actions/blocks/merge.ts | 49 +++ .../blocks/{text => }/outdent.ts | 0 .../async-actions/blocks/text/backspace.ts | 44 -- .../async-actions/blocks/text/index.ts | 6 - .../async-actions/blocks/text/merge.ts | 82 ---- .../async-actions/blocks/text/split.ts | 74 ---- .../async-actions/blocks/text/turn_to.ts | 32 -- .../async-actions/blocks/{text => }/update.ts | 10 +- .../reducers/document/async-actions/cursor.ts | 109 ----- .../reducers/document/async-actions/format.ts | 71 ++-- .../reducers/document/async-actions/index.ts | 15 +- .../document/async-actions/keydown.ts | 288 +++++++++++++ .../reducers/document/async-actions/menu.ts | 44 +- .../reducers/document/async-actions/range.ts | 221 ++++++++++ .../document/async-actions/range_selection.ts | 119 ------ .../document/async-actions/rect_selection.ts | 10 +- .../document/async-actions/turn_to.ts | 56 +-- .../stores/reducers/document/slice.ts | 83 +++- .../src/appflowy_app/utils/document/action.ts | 307 ++++++++++++++ .../src/appflowy_app/utils/document/block.ts | 92 +++++ .../utils/document/blocks/code/index.ts | 34 -- .../utils/document/blocks/common.ts | 220 ---------- .../utils/document/blocks/index.ts | 132 ------ .../utils/document/blocks/selection.ts | 22 - .../utils/document/blocks/text/delta.ts | 378 ------------------ .../utils/document/blocks/text/hotkey.ts | 79 ---- .../src/appflowy_app/utils/document/delta.ts | 71 ++++ .../src/appflowy_app/utils/document/node.ts | 232 +++++++++++ .../utils/document/quill_editor.ts | 59 +++ .../utils/document/slate_editor.ts | 134 +++++++ .../appflowy_app/utils/document/toolbar.ts | 24 +- frontend/appflowy_tauri/tailwind.config.cjs | 4 + 99 files changed, 3497 insertions(+), 2636 deletions(-) create mode 100644 frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSelection/RangeKeyDown.hooks.ts delete mode 100644 frontend/appflowy_tauri/src/appflowy_app/components/document/CodeBlock/CodeBlock.hooks.ts create mode 100644 frontend/appflowy_tauri/src/appflowy_app/components/document/CodeBlock/useKeyDown.ts create mode 100644 frontend/appflowy_tauri/src/appflowy_app/components/document/Overlay/BlockOverlay.tsx delete mode 100644 frontend/appflowy_tauri/src/appflowy_app/components/document/TextBlock/Leaf.tsx delete mode 100644 frontend/appflowy_tauri/src/appflowy_app/components/document/TextBlock/TextBlock.hooks.ts delete mode 100644 frontend/appflowy_tauri/src/appflowy_app/components/document/TextBlock/events/Events.hooks.ts delete mode 100644 frontend/appflowy_tauri/src/appflowy_app/components/document/TextBlock/events/TurnIntoEvents.hooks.ts create mode 100644 frontend/appflowy_tauri/src/appflowy_app/components/document/TextBlock/useKeyDown.ts create mode 100644 frontend/appflowy_tauri/src/appflowy_app/components/document/TextBlock/useTurnIntoBlockEvents.ts create mode 100644 frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/EditorHooks/useChange.ts create mode 100644 frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/EditorHooks/useCommonKeyEvents.ts create mode 100644 frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/EditorHooks/useDelta.ts create mode 100644 frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/EditorHooks/useSelection.ts create mode 100644 frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/QuillEditor/Editor.css create mode 100644 frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/QuillEditor/Editor.tsx create mode 100644 frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/QuillEditor/useEditor.ts create mode 100644 frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SlateEditor/CodeEditor.tsx rename frontend/appflowy_tauri/src/appflowy_app/components/document/{CodeBlock/elements.tsx => _shared/SlateEditor/CodeElements.tsx} (87%) create mode 100644 frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SlateEditor/TextEditor.tsx create mode 100644 frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SlateEditor/TextElement.tsx create mode 100644 frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SlateEditor/TextLeaf.tsx rename frontend/appflowy_tauri/src/appflowy_app/{utils/document/blocks/code/decorate.ts => components/document/_shared/SlateEditor/decorateCode.ts} (63%) create mode 100644 frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SlateEditor/useEditor.ts create mode 100644 frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SlateEditor/useSlateYjs.ts create mode 100644 frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SubscribeSelection.hooks.ts delete mode 100644 frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/Text/TextEvents.hooks.ts delete mode 100644 frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/Text/TextInput.hooks.ts delete mode 100644 frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/Text/TextSelection.hooks.ts create mode 100644 frontend/appflowy_tauri/src/appflowy_app/constants/document/keyboard.ts delete mode 100644 frontend/appflowy_tauri/src/appflowy_app/constants/document/text_block.ts rename frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/{text => }/indent.ts (91%) create mode 100644 frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/merge.ts rename frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/{text => }/outdent.ts (100%) delete mode 100644 frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/text/backspace.ts delete mode 100644 frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/text/index.ts delete mode 100644 frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/text/merge.ts delete mode 100644 frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/text/split.ts delete mode 100644 frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/text/turn_to.ts rename frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/{text => }/update.ts (77%) delete mode 100644 frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/cursor.ts create mode 100644 frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/keydown.ts create mode 100644 frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/range.ts delete mode 100644 frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/range_selection.ts create mode 100644 frontend/appflowy_tauri/src/appflowy_app/utils/document/action.ts create mode 100644 frontend/appflowy_tauri/src/appflowy_app/utils/document/block.ts delete mode 100644 frontend/appflowy_tauri/src/appflowy_app/utils/document/blocks/code/index.ts delete mode 100644 frontend/appflowy_tauri/src/appflowy_app/utils/document/blocks/common.ts delete 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/selection.ts delete mode 100644 frontend/appflowy_tauri/src/appflowy_app/utils/document/blocks/text/delta.ts delete mode 100644 frontend/appflowy_tauri/src/appflowy_app/utils/document/blocks/text/hotkey.ts create mode 100644 frontend/appflowy_tauri/src/appflowy_app/utils/document/delta.ts create mode 100644 frontend/appflowy_tauri/src/appflowy_app/utils/document/node.ts create mode 100644 frontend/appflowy_tauri/src/appflowy_app/utils/document/quill_editor.ts create mode 100644 frontend/appflowy_tauri/src/appflowy_app/utils/document/slate_editor.ts diff --git a/frontend/appflowy_tauri/package.json b/frontend/appflowy_tauri/package.json index a428f94141..bfe25ad27f 100644 --- a/frontend/appflowy_tauri/package.json +++ b/frontend/appflowy_tauri/package.json @@ -22,6 +22,7 @@ "@mui/icons-material": "^5.11.11", "@mui/material": "^5.11.12", "@reduxjs/toolkit": "^1.9.2", + "@slate-yjs/core": "^1.0.0", "@tanstack/react-virtual": "3.0.0-beta.54", "@tauri-apps/api": "^1.2.0", "dayjs": "^1.11.7", @@ -35,6 +36,8 @@ "nanoid": "^4.0.0", "prismjs": "^1.29.0", "protoc-gen-ts": "^0.8.5", + "quill": "^1.3.7", + "quill-delta": "^5.1.0", "react": "^18.2.0", "react-beautiful-dnd": "^13.1.1", "react-calendar": "^4.1.0", @@ -46,8 +49,8 @@ "react18-input-otp": "^1.1.2", "redux": "^4.2.1", "rxjs": "^7.8.0", - "slate": "^0.91.4", - "slate-react": "^0.91.9", + "slate": "^0.94.1", + "slate-react": "^0.94.2", "ts-results": "^3.3.0", "utf8": "^3.0.0", "y-indexeddb": "^9.0.9", @@ -59,6 +62,7 @@ "@types/is-hotkey": "^0.1.7", "@types/node": "^18.7.10", "@types/prismjs": "^1.26.0", + "@types/quill": "^2.0.10", "@types/react": "^18.0.15", "@types/react-beautiful-dnd": "^13.1.3", "@types/react-dom": "^18.0.6", diff --git a/frontend/appflowy_tauri/pnpm-lock.yaml b/frontend/appflowy_tauri/pnpm-lock.yaml index f5b351706d..d725aacd21 100644 --- a/frontend/appflowy_tauri/pnpm-lock.yaml +++ b/frontend/appflowy_tauri/pnpm-lock.yaml @@ -22,6 +22,9 @@ dependencies: '@reduxjs/toolkit': specifier: ^1.9.2 version: 1.9.5(react-redux@8.0.5)(react@18.2.0) + '@slate-yjs/core': + specifier: ^1.0.0 + version: 1.0.0(slate@0.94.1)(yjs@13.6.1) '@tanstack/react-virtual': specifier: 3.0.0-beta.54 version: 3.0.0-beta.54(react@18.2.0) @@ -61,6 +64,12 @@ dependencies: protoc-gen-ts: specifier: ^0.8.5 version: 0.8.6(google-protobuf@3.21.2)(typescript@4.9.5) + quill: + specifier: ^1.3.7 + version: 1.3.7 + quill-delta: + specifier: ^5.1.0 + version: 5.1.0 react: specifier: ^18.2.0 version: 18.2.0 @@ -95,11 +104,11 @@ dependencies: specifier: ^7.8.0 version: 7.8.1 slate: - specifier: ^0.91.4 - version: 0.91.4 + specifier: ^0.94.1 + version: 0.94.1 slate-react: - specifier: ^0.91.9 - version: 0.91.11(react-dom@18.2.0)(react@18.2.0)(slate@0.91.4) + specifier: ^0.94.2 + version: 0.94.2(react-dom@18.2.0)(react@18.2.0)(slate@0.94.1) ts-results: specifier: ^3.3.0 version: 3.3.0 @@ -129,6 +138,9 @@ devDependencies: '@types/prismjs': specifier: ^1.26.0 version: 1.26.0 + '@types/quill': + specifier: ^2.0.10 + version: 2.0.10 '@types/react': specifier: ^18.0.15 version: 18.2.6 @@ -1425,6 +1437,17 @@ packages: '@sinonjs/commons': 3.0.0 dev: false + /@slate-yjs/core@1.0.0(slate@0.94.1)(yjs@13.6.1): + resolution: {integrity: sha512-G83+qvXtsMTP3kWu216GjhyeHlvKHX5kWaPf2JiG2uF5/YShUqjAVjDr/htKoKJsOl+IqK679lvLKeBYh7SYZQ==} + peerDependencies: + slate: '>=0.70.0' + yjs: ^13.5.29 + dependencies: + slate: 0.94.1 + y-protocols: 1.0.5 + yjs: 13.6.1 + dev: false + /@tanstack/react-virtual@3.0.0-beta.54(react@18.2.0): resolution: {integrity: sha512-D1mDMf4UPbrtHRZZriCly5bXTBMhylslm4dhcHqTtDJ6brQcgGmk8YD9JdWBGWfGSWPKoh2x1H3e7eh+hgPXtQ==} peerDependencies: @@ -1637,6 +1660,13 @@ packages: /@types/prop-types@15.7.5: resolution: {integrity: sha512-JCB8C6SnDoQf0cNycqd/35A7MjcnK+ZTqE7judS6o7utxUCg6imJg3QK2qzHKszlTjcj2cn+NwMB2i96ubpj7w==} + /@types/quill@2.0.10: + resolution: {integrity: sha512-L6OHONEj2v4NRbWQOsn7j1N0SyzhRR3M4g1M6j/uuIwIsIW2ShWHhwbqNvH8hSmVktzqu0lITfdnqVOQ4qkrhA==} + dependencies: + parchment: 1.1.4 + quill-delta: 4.2.2 + dev: true + /@types/react-beautiful-dnd@13.1.4: resolution: {integrity: sha512-4bIBdzOr0aavN+88q3C7Pgz+xkb7tz3whORYrmSj77wfVEMfiWiooIwVWFR7KM2e+uGTe5BVrXqSfb0aHeflJA==} dependencies: @@ -2133,7 +2163,6 @@ packages: dependencies: function-bind: 1.1.1 get-intrinsic: 1.2.1 - dev: true /callsites@3.1.0: resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} @@ -2210,6 +2239,11 @@ packages: wrap-ansi: 7.0.0 dev: false + /clone@2.1.2: + resolution: {integrity: sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==} + engines: {node: '>=0.8'} + dev: false + /clsx@1.2.1: resolution: {integrity: sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg==} engines: {node: '>=6'} @@ -2313,6 +2347,17 @@ packages: resolution: {integrity: sha512-Q6fKUPqnAHAyhiUgFU7BUzLiv0kd8saH9al7tnu5Q/okj6dnupxyTgFIBjVzJATdfIAm9NAsvXNzjaKa+bxVyA==} dev: false + /deep-equal@1.1.1: + resolution: {integrity: sha512-yd9c5AdiqVcR+JjcwUQb9DkhJc8ngNr0MahEBGvDiJw8puWab2yZlh+nkasOnZP+EGTAP6rRp2JzJhJZzvNF8g==} + dependencies: + is-arguments: 1.1.1 + is-date-object: 1.0.5 + is-regex: 1.1.4 + object-is: 1.1.5 + object-keys: 1.1.1 + regexp.prototype.flags: 1.5.0 + dev: false + /deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} dev: true @@ -2328,7 +2373,6 @@ packages: dependencies: has-property-descriptors: 1.0.0 object-keys: 1.1.1 - dev: true /detect-newline@3.1.0: resolution: {integrity: sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==} @@ -2661,6 +2705,10 @@ packages: engines: {node: '>=0.10.0'} dev: true + /eventemitter3@2.0.3: + resolution: {integrity: sha512-jLN68Dx5kyFHaePoXWPsCGW5qdyZQtLYHkxkg02/Mz6g0kYpDx4FyP6XfArhQdlOC4b8Mv+EMxPo/8La7Tzghg==} + dev: false + /events@3.3.0: resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} engines: {node: '>=0.8.x'} @@ -2697,10 +2745,26 @@ packages: jest-util: 29.5.0 dev: false + /extend@3.0.2: + resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} + dev: false + /fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} dev: true + /fast-diff@1.1.2: + resolution: {integrity: sha512-KaJUt+M9t1qaIteSvjc6P3RbMdXsNhK61GRftR6SNxqmhthcd9MGIi4T+o0jD8LUSpSnSKXE20nLtJ3fOHxQig==} + dev: false + + /fast-diff@1.2.0: + resolution: {integrity: sha512-xJuoT5+L99XlZ8twedaRf6Ax2TgQVxvgZOYoPKqZufmJib0tL2tegPBOZb1pVNgIhlqDlA0eO0c3wBvQcmzx4w==} + dev: true + + /fast-diff@1.3.0: + resolution: {integrity: sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==} + dev: false + /fast-glob@3.2.12: resolution: {integrity: sha512-DVj4CQIYYow0BlaelwK1pHl5n5cRSJfM60UA0zK891sVInoPri2Ekj7+e1CT3/3qxXenpI+nBBmQAcJPJgaj4w==} engines: {node: '>=8.6.0'} @@ -2811,7 +2875,6 @@ packages: /functions-have-names@1.2.3: resolution: {integrity: sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==} - dev: true /gensync@1.0.0-beta.2: resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} @@ -2829,7 +2892,6 @@ packages: has: 1.0.3 has-proto: 1.0.1 has-symbols: 1.0.3 - dev: true /get-package-type@0.1.0: resolution: {integrity: sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==} @@ -2955,24 +3017,20 @@ packages: resolution: {integrity: sha512-62DVLZGoiEBDHQyqG4w9xCuZ7eJEwNmJRWw2VY84Oedb7WFcA27fiEVe8oUQx9hAUJ4ekurquucTGwsyO1XGdQ==} dependencies: get-intrinsic: 1.2.1 - dev: true /has-proto@1.0.1: resolution: {integrity: sha512-7qE+iP+O+bgF9clE5+UoBFzE65mlBiVj3tKCrlNQ0Ogwm0BjpT/gK4SlLYDMybDh5I3TCTKnPPa0oMG7JDYrhg==} engines: {node: '>= 0.4'} - dev: true /has-symbols@1.0.3: resolution: {integrity: sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==} engines: {node: '>= 0.4'} - dev: true /has-tostringtag@1.0.0: resolution: {integrity: sha512-kFjcSNhnlGV1kyoGk7OXKSawH5JOb/LzUc5w9B02hOTO0dfFRjbHQKvg1d6cf3HbeUmtU9VbbV3qzZ2Teh97WQ==} engines: {node: '>= 0.4'} dependencies: has-symbols: 1.0.3 - dev: true /has@1.0.3: resolution: {integrity: sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==} @@ -3060,6 +3118,14 @@ packages: side-channel: 1.0.4 dev: true + /is-arguments@1.1.1: + resolution: {integrity: sha512-8Q7EARjzEnKpt/PCD7e1cgUS0a6X8u5tdSiMqXhojOdoV9TsMsiO+9VLC5vAmO8N7/GmXn7yjR8qnA6bVAEzfA==} + engines: {node: '>= 0.4'} + dependencies: + call-bind: 1.0.2 + has-tostringtag: 1.0.0 + dev: false + /is-array-buffer@3.0.2: resolution: {integrity: sha512-y+FyyR/w8vfIRq4eQcM1EYgSTnmHXPqaF+IgzgraytCFq5Xh8lllDVmAZolPJiZttZLeFSINPYMaEJ7/vWUa1w==} dependencies: @@ -3108,7 +3174,6 @@ packages: engines: {node: '>= 0.4'} dependencies: has-tostringtag: 1.0.0 - dev: true /is-extglob@2.1.1: resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} @@ -3172,7 +3237,6 @@ packages: dependencies: call-bind: 1.0.2 has-tostringtag: 1.0.0 - dev: true /is-shared-array-buffer@1.0.2: resolution: {integrity: sha512-sqN2UDu1/0y6uvXyStCOzyhAjCSlHceFoMKJW8W9EU9cvic/QdsZ0kEU93HEy3IUEFZIiH/3w+AH/UQbPHNdhA==} @@ -3783,6 +3847,12 @@ packages: p-locate: 5.0.0 dev: true + /lodash.clonedeep@4.5.0: + resolution: {integrity: sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==} + + /lodash.isequal@4.5.0: + resolution: {integrity: sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==} + /lodash.memoize@4.1.2: resolution: {integrity: sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==} dev: false @@ -3928,10 +3998,17 @@ packages: resolution: {integrity: sha512-geUvdk7c+eizMNUDkRpW1wJwgfOiOeHbxBR/hLXK1aT6zmVSO0jsQcs7fj6MGw89jC/cjGfLcNOrtMYtGqm81g==} dev: true + /object-is@1.1.5: + resolution: {integrity: sha512-3cyDsyHgtmi7I7DfSSI2LDp6SK2lwvtbg0p0R1e0RvTqF5ceGx+K2dfSjm1bKDMVCFEDAQvy+o8c6a7VujOddw==} + engines: {node: '>= 0.4'} + dependencies: + call-bind: 1.0.2 + define-properties: 1.2.0 + dev: false + /object-keys@1.1.1: resolution: {integrity: sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==} engines: {node: '>= 0.4'} - dev: true /object.assign@4.1.4: resolution: {integrity: sha512-1mxKf0e58bvyjSCtKYY4sRe9itRk3PJpquJOjeIkz885CczcI4IvJJDLPS72oowuSh+pBxUFROpX+TU++hxhZQ==} @@ -4033,6 +4110,9 @@ packages: engines: {node: '>=6'} dev: false + /parchment@1.1.4: + resolution: {integrity: sha512-J5FBQt/pM2inLzg4hEWmzQx/8h8D0CiDxaG3vyp9rKrQRSDgBlhjdP5jQGgosEajXPSQouXGHOmVdgo7QmJuOg==} + /parent-module@1.0.1: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} engines: {node: '>=6'} @@ -4277,6 +4357,43 @@ packages: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} dev: true + /quill-delta@3.6.3: + resolution: {integrity: sha512-wdIGBlcX13tCHOXGMVnnTVFtGRLoP0imqxM696fIPwIf5ODIYUHIvHbZcyvGlZFiFhK5XzDC2lpjbxRhnM05Tg==} + engines: {node: '>=0.10'} + dependencies: + deep-equal: 1.1.1 + extend: 3.0.2 + fast-diff: 1.1.2 + dev: false + + /quill-delta@4.2.2: + resolution: {integrity: sha512-qjbn82b/yJzOjstBgkhtBjN2TNK+ZHP/BgUQO+j6bRhWQQdmj2lH6hXG7+nwwLF41Xgn//7/83lxs9n2BkTtTg==} + dependencies: + fast-diff: 1.2.0 + lodash.clonedeep: 4.5.0 + lodash.isequal: 4.5.0 + dev: true + + /quill-delta@5.1.0: + resolution: {integrity: sha512-X74oCeRI4/p0ucjb5Ma8adTXd9Scumz367kkMK5V/IatcX6A0vlgLgKbzXWy5nZmCGeNJm2oQX0d2Eqj+ZIlCA==} + engines: {node: '>= 12.0.0'} + dependencies: + fast-diff: 1.3.0 + lodash.clonedeep: 4.5.0 + lodash.isequal: 4.5.0 + dev: false + + /quill@1.3.7: + resolution: {integrity: sha512-hG/DVzh/TiknWtE6QmWAF/pxoZKYxfe3J/d/+ShUWkDvvkZQVTPeVmUJVu1uE6DDooC4fWTiCLh84ul89oNz5g==} + dependencies: + clone: 2.1.2 + deep-equal: 1.1.1 + eventemitter3: 2.0.3 + extend: 3.0.2 + parchment: 1.1.4 + quill-delta: 3.6.3 + dev: false + /raf-schd@4.0.3: resolution: {integrity: sha512-tQkJl2GRWh83ui2DiPTJz9wEiMN20syf+5oKfB03yYP7ioZcJwsIK8FjrtLwH1m7C7e+Tt2yYBlrOpdT+dyeIQ==} dev: false @@ -4519,7 +4636,6 @@ packages: call-bind: 1.0.2 define-properties: 1.2.0 functions-have-names: 1.2.3 - dev: true /require-directory@2.1.1: resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} @@ -4661,8 +4777,8 @@ packages: resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} engines: {node: '>=8'} - /slate-react@0.91.11(react-dom@18.2.0)(react@18.2.0)(slate@0.91.4): - resolution: {integrity: sha512-2nS29rc2kuTTJrEUOXGyTkFATmTEw/R9KuUXadUYiz+UVwuFOUMnBKuwJWyuIBOsFipS+06SkIayEf5CKdARRQ==} + /slate-react@0.94.2(react-dom@18.2.0)(react@18.2.0)(slate@0.94.1): + resolution: {integrity: sha512-4wDSuTuGBkdQ609CS55uc2Yhfa5but21usBgAtCVhPJQazL85kzN2vUUYTmGb7d/mpP9tdnJiVPopIyhqlRJ8Q==} peerDependencies: react: '>=16.8.0' react-dom: '>=16.8.0' @@ -4678,12 +4794,12 @@ packages: react: 18.2.0 react-dom: 18.2.0(react@18.2.0) scroll-into-view-if-needed: 2.2.31 - slate: 0.91.4 + slate: 0.94.1 tiny-invariant: 1.0.6 dev: false - /slate@0.91.4: - resolution: {integrity: sha512-aUJ3rpjrdi5SbJ5G1Qjr3arytfRkEStTmHjBfWq2A2Q8MybacIzkScSvGJjQkdTk3djCK9C9SEOt39sSeZFwTw==} + /slate@0.94.1: + resolution: {integrity: sha512-GH/yizXr1ceBoZ9P9uebIaHe3dC/g6Plpf9nlUwnvoyf6V1UOYrRwkabtOCd3ZfIGxomY4P7lfgLr7FPH8/BKA==} dependencies: immer: 9.0.21 is-plain-object: 5.0.0 @@ -5154,6 +5270,12 @@ packages: yjs: 13.6.1 dev: false + /y-protocols@1.0.5: + resolution: {integrity: sha512-Wil92b7cGk712lRHDqS4T90IczF6RkcvCwAD0A2OPg+adKmOe+nOiT/N2hvpQIWS3zfjmtL4CPaH5sIW1Hkm/A==} + dependencies: + lib0: 0.2.74 + dev: false + /y18n@5.0.8: resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} engines: {node: '>=10'} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSelection/BlockRangeSelection.hooks.ts b/frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSelection/BlockRangeSelection.hooks.ts index a0861acf5c..edc6cc9fa4 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSelection/BlockRangeSelection.hooks.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSelection/BlockRangeSelection.hooks.ts @@ -1,31 +1,93 @@ import { useCallback, useEffect, useRef, useState } from 'react'; -import { getBlockIdByPoint } from '$app/utils/document/blocks/selection'; -import { rangeSelectionActions } from '$app_reducers/document/slice'; -import { useAppDispatch } from '$app/stores/store'; -import { getNodesInRange } from '$app/utils/document/blocks/common'; -import { setRangeSelectionThunk } from '$app_reducers/document/async-actions/range_selection'; +import { rangeActions } from '$app_reducers/document/slice'; +import { useAppDispatch, useAppSelector } from '$app/stores/store'; +import { + getBlockIdByPoint, + getNodeTextBoxByBlockId, + isFocused, + setCursorAtEndOfNode, + setCursorAtStartOfNode, +} from '$app/utils/document/node'; +import { useRangeKeyDown } from '$app/components/document/BlockSelection/RangeKeyDown.hooks'; export function useBlockRangeSelection(container: HTMLDivElement) { const dispatch = useAppDispatch(); + const onKeyDown = useRangeKeyDown(); + const range = useAppSelector((state) => state.documentRange); + const isDragging = range.isDragging; + const anchorRef = useRef<{ id: string; point: { x: number; y: number }; - range?: Range; } | null>(null); - const [isDragging, setDragging] = useState(false); + const [focus, setFocus] = useState<{ + id: string; + point: { x: number; y: number }; + } | null>(null); + + const [isForward, setForward] = useState(true); const reset = useCallback(() => { - dispatch(rangeSelectionActions.clearRange()); + dispatch(rangeActions.clearRange()); }, [dispatch]); + // display caret color useEffect(() => { - dispatch(rangeSelectionActions.setDragging(isDragging)); - }, [dispatch, isDragging]); + const { anchor, focus } = range; + if (!anchor || !focus) { + container.classList.remove('caret-transparent'); + return; + } + // if the focus block is different from the anchor block, we need to set the caret transparent + if (focus.id !== anchor.id) { + container.classList.add('caret-transparent'); + } else { + container.classList.remove('caret-transparent'); + } + }, [container.classList, range]); + + useEffect(() => { + const anchor = anchorRef.current; + if (!anchor || !focus) return; + const selection = window.getSelection(); + if (!selection) return; + // update focus point + dispatch(rangeActions.setFocusPoint(focus)); + + const focused = isFocused(focus.id); + // if the focus block is not focused, we need to set the cursor position + if (!focused) { + // if the focus block is the same as the anchor block, we just update the anchor's range + if (anchor.id === focus.id) { + const range = document.caretRangeFromPoint( + anchor.point.x - container.scrollLeft, + anchor.point.y - container.scrollTop + ); + if (!range) return; + const selection = window.getSelection(); + selection?.removeAllRanges(); + selection?.addRange(range); + return; + } + + const node = getNodeTextBoxByBlockId(focus.id); + if (!node) return; + // if the selection is forward, we set the cursor position to the start of the focus block + if (isForward) { + setCursorAtStartOfNode(node); + } else { + // if the selection is backward, we set the cursor position to the end of the focus block + setCursorAtEndOfNode(node); + } + } + }, [container, dispatch, focus, isForward]); const handleDragStart = useCallback( (e: MouseEvent) => { + // reset the range reset(); + // skip if the target is not a block const blockId = getBlockIdByPoint(e.target as HTMLElement); if (!blockId) { return; @@ -33,72 +95,76 @@ export function useBlockRangeSelection(container: HTMLDivElement) { const startX = e.clientX + container.scrollLeft; const startY = e.clientY + container.scrollTop; - anchorRef.current = { + + const anchor = { id: blockId, point: { x: startX, y: startY, }, }; - setDragging(true); + + anchorRef.current = { + ...anchor, + }; + // set the anchor point and focus point + dispatch(rangeActions.setAnchorPoint({ ...anchor })); + dispatch(rangeActions.setFocusPoint({ ...anchor })); + dispatch(rangeActions.setDragging(true)); }, - [container.scrollLeft, container.scrollTop, reset] + [container.scrollLeft, container.scrollTop, dispatch, reset] ); const handleDraging = useCallback( (e: MouseEvent) => { if (!isDragging || !anchorRef.current) return; + // skip if the target is not a block const blockId = getBlockIdByPoint(e.target as HTMLElement); if (!blockId) { return; } + const endX = e.clientX + container.scrollLeft; + const endY = e.clientY + container.scrollTop; + // set the focus point + setFocus({ + id: blockId, + point: { + x: endX, + y: endY, + }, + }); + // set forward const anchorId = anchorRef.current.id; if (anchorId === blockId) { - const endX = e.clientX + container.scrollTop; - const isForward = endX > anchorRef.current.point.x; - dispatch(rangeSelectionActions.setForward(isForward)); + const startX = anchorRef.current.point.x; + setForward(startX < endX); return; } - - const endY = e.clientY + container.scrollTop; - const isForward = endY > anchorRef.current.point.y; - dispatch(rangeSelectionActions.setForward(isForward)); + const startY = anchorRef.current.point.y; + setForward(startY < endY); }, - [container.scrollTop, dispatch, isDragging] + [container.scrollLeft, container.scrollTop, isDragging] ); const handleDragEnd = useCallback(() => { if (!isDragging) return; - setDragging(false); - dispatch(setRangeSelectionThunk()); + dispatch(rangeActions.setDragging(false)); }, [dispatch, isDragging]); - // TODO: This is a hack to fix the issue that the selection is lost when scrolling - const handleScroll = useCallback(() => { - if (isDragging || !anchorRef.current) return; - const selection = window.getSelection(); - if (!selection?.rangeCount && anchorRef.current.range) { - selection?.addRange(anchorRef.current.range); - } else { - anchorRef.current.range = selection?.getRangeAt(0); - } - }, [isDragging]); - useEffect(() => { document.addEventListener('mousedown', handleDragStart); - document.addEventListener('mousemove', handleDraging, true); + document.addEventListener('mousemove', handleDraging); document.addEventListener('mouseup', handleDragEnd); - container.addEventListener('scroll', handleScroll); - + container.addEventListener('keydown', onKeyDown, true); return () => { document.removeEventListener('mousedown', handleDragStart); - document.removeEventListener('mousemove', handleDraging, true); + document.removeEventListener('mousemove', handleDraging); document.removeEventListener('mouseup', handleDragEnd); - container.removeEventListener('scroll', handleScroll); + container.removeEventListener('keydown', onKeyDown, true); }; - }, [handleDragStart, handleDragEnd, handleDraging, container, handleScroll]); + }, [handleDragStart, handleDragEnd, handleDraging, container, onKeyDown]); return null; } diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSelection/BlockRectSelection.hooks.ts b/frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSelection/BlockRectSelection.hooks.ts index 370e57a3f3..7ef587a222 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSelection/BlockRectSelection.hooks.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSelection/BlockRectSelection.hooks.ts @@ -1,18 +1,21 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useAppDispatch } from '$app/stores/store'; import { rectSelectionActions } from '@/appflowy_app/stores/reducers/document/slice'; -import { useNodesRect } from '$app/components/document/BlockSelection/NodesRect.hooks'; import { setRectSelectionThunk } from '$app_reducers/document/async-actions/rect_selection'; -import { isPointInBlock } from '$app/utils/document/blocks/selection'; -export function useBlockRectSelection({ container }: { container: HTMLDivElement }) { +import { isPointInBlock } from '$app/utils/document/node'; + +export interface BlockRectSelectionProps { + container: HTMLDivElement; + getIntersectedBlockIds: (rect: { startX: number; startY: number; endX: number; endY: number }) => string[]; +} + +export function useBlockRectSelection({ container, getIntersectedBlockIds }: BlockRectSelectionProps) { const dispatch = useAppDispatch(); const [isDragging, setDragging] = useState(false); const startPointRef = useRef([]); - const { getIntersectedBlockIds } = useNodesRect(container); - useEffect(() => { dispatch(rectSelectionActions.setDragging(isDragging)); }, [dispatch, isDragging]); diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSelection/BlockRectSelection.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSelection/BlockRectSelection.tsx index 8d6b0c9fd4..197ef3f535 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSelection/BlockRectSelection.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSelection/BlockRectSelection.tsx @@ -1,10 +1,11 @@ import React from 'react'; -import { useBlockRectSelection } from '$app/components/document/BlockSelection/BlockRectSelection.hooks'; +import { + BlockRectSelectionProps, + useBlockRectSelection, +} from '$app/components/document/BlockSelection/BlockRectSelection.hooks'; -function BlockRectSelection({ container }: { container: HTMLDivElement }) { - const { isDragging, style } = useBlockRectSelection({ - container, - }); +function BlockRectSelection(props: BlockRectSelectionProps) { + const { isDragging, style } = useBlockRectSelection(props); if (!isDragging) return null; return
; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSelection/RangeKeyDown.hooks.ts b/frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSelection/RangeKeyDown.hooks.ts new file mode 100644 index 0000000000..feac345444 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSelection/RangeKeyDown.hooks.ts @@ -0,0 +1,119 @@ +import { useCallback, useContext, useMemo } from 'react'; +import { Keyboard } from '$app/constants/document/keyboard'; +import { useAppDispatch } from '$app/stores/store'; +import { DocumentControllerContext } from '$app/stores/effects/document/document_controller'; +import { arrowActionForRangeThunk, deleteRangeAndInsertThunk } from '$app_reducers/document/async-actions'; +import Delta from 'quill-delta'; +import isHotkey from 'is-hotkey'; +import { deleteRangeAndInsertEnterThunk } from '$app_reducers/document/async-actions/range'; +import { useRangeRef } from '$app/components/document/_shared/SubscribeSelection.hooks'; +import { isPrintableKeyEvent } from '$app/utils/document/action'; + +export function useRangeKeyDown() { + const rangeRef = useRangeRef(); + + const dispatch = useAppDispatch(); + const controller = useContext(DocumentControllerContext); + const interceptEvents = useMemo( + () => [ + { + // handle backspace and delete + canHandle: (e: KeyboardEvent) => { + return isHotkey(Keyboard.keys.BACKSPACE, e) || isHotkey(Keyboard.keys.DELETE, e); + }, + handler: (_: KeyboardEvent) => { + if (!controller) return; + dispatch( + deleteRangeAndInsertThunk({ + controller, + }) + ); + }, + }, + { + // handle char input + canHandle: (e: KeyboardEvent) => { + return isPrintableKeyEvent(e); + }, + handler: (e: KeyboardEvent) => { + if (!controller) return; + const insertDelta = new Delta().insert(e.key); + dispatch( + deleteRangeAndInsertThunk({ + controller, + insertDelta, + }) + ); + }, + }, + { + // handle shift + enter + canHandle: (e: KeyboardEvent) => { + return isHotkey(Keyboard.keys.SHIFT_ENTER, e); + }, + handler: (e: KeyboardEvent) => { + if (!controller) return; + dispatch( + deleteRangeAndInsertEnterThunk({ + controller, + shiftKey: true, + }) + ); + }, + }, + { + // handle enter + canHandle: (e: KeyboardEvent) => { + return isHotkey(Keyboard.keys.ENTER, e); + }, + handler: (e: KeyboardEvent) => { + if (!controller) return; + dispatch( + deleteRangeAndInsertEnterThunk({ + controller, + shiftKey: false, + }) + ); + }, + }, + { + // handle arrows + canHandle: (e: KeyboardEvent) => { + return ( + isHotkey(Keyboard.keys.LEFT, e) || + isHotkey(Keyboard.keys.RIGHT, e) || + isHotkey(Keyboard.keys.UP, e) || + isHotkey(Keyboard.keys.DOWN, e) + ); + }, + handler: (e: KeyboardEvent) => { + dispatch( + arrowActionForRangeThunk({ + key: e.key, + }) + ); + }, + }, + ], + [controller, dispatch] + ); + + const onKeyDown = useCallback( + (e: KeyboardEvent) => { + if (!rangeRef.current) { + return; + } + const { anchor, focus } = rangeRef.current; + if (anchor?.id === focus?.id) { + return; + } + e.stopPropagation(); + e.preventDefault(); + const filteredEvents = interceptEvents.filter((event) => event.canHandle(e)); + filteredEvents.forEach((event) => event.handler(e)); + }, + [interceptEvents, rangeRef] + ); + + return onKeyDown; +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSelection/index.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSelection/index.tsx index b311918d22..1361b46597 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSelection/index.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSelection/index.tsx @@ -1,12 +1,15 @@ import React from 'react'; import BlockRectSelection from '$app/components/document/BlockSelection/BlockRectSelection'; import { useBlockRangeSelection } from '$app/components/document/BlockSelection/BlockRangeSelection.hooks'; +import { useNodesRect } from '$app/components/document/BlockSelection/NodesRect.hooks'; function BlockSelection({ container }: { container: HTMLDivElement }) { + const { getIntersectedBlockIds } = useNodesRect(container); + useBlockRangeSelection(container); return (
- +
); } diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSideToolbar/BlockMenu.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSideToolbar/BlockMenu.tsx index bf8c2b6b8a..91b1aea221 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSideToolbar/BlockMenu.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSideToolbar/BlockMenu.tsx @@ -1,4 +1,4 @@ -import React, { useCallback } from 'react'; +import React, { useCallback, useState } from 'react'; import { List } from '@mui/material'; import { ContentCopy, Delete } from '@mui/icons-material'; import MenuItem from './MenuItem'; @@ -8,7 +8,7 @@ import BlockMenuTurnInto from '$app/components/document/BlockSideToolbar/BlockMe function BlockMenu({ id, onClose }: { id: string; onClose: () => void }) { const { handleDelete, handleDuplicate } = useBlockMenu(id); - const [turnIntoPup, setTurnIntoPup] = React.useState(false); + const [turnIntoOptionHovered, setTurnIntoOptionHorvered] = useState(false); const handleClick = useCallback( async ({ operate }: { operate: () => Promise }) => { await operate(); @@ -20,10 +20,12 @@ function BlockMenu({ id, onClose }: { id: string; onClose: () => void }) { return ( { + // Prevent the block from being selected. e.preventDefault(); e.stopPropagation(); }} > + {/** Delete option in the BlockMenu. */} } @@ -34,10 +36,11 @@ function BlockMenu({ id, onClose }: { id: string; onClose: () => void }) { } onHover={(isHovered) => { if (isHovered) { - setTurnIntoPup(false); + setTurnIntoOptionHorvered(false); } }} /> + {/** Duplicate option in the BlockMenu. */} } @@ -48,11 +51,17 @@ function BlockMenu({ id, onClose }: { id: string; onClose: () => void }) { } onHover={(isHovered) => { if (isHovered) { - setTurnIntoPup(false); + setTurnIntoOptionHorvered(false); } }} /> - setTurnIntoPup(true)} isHovered={turnIntoPup} onClose={onClose} id={id} /> + {/** Turn Into option in the BlockMenu. */} + setTurnIntoOptionHorvered(true)} + isHovered={turnIntoOptionHovered} + onClose={onClose} + id={id} + /> ); } diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSideToolbar/BlockMenuTurnInto.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSideToolbar/BlockMenuTurnInto.tsx index cb4077f2e2..54a3e3b141 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSideToolbar/BlockMenuTurnInto.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSideToolbar/BlockMenuTurnInto.tsx @@ -2,6 +2,7 @@ import React, { useState } from 'react'; import { ArrowRight, Transform } from '@mui/icons-material'; import MenuItem from '$app/components/document/BlockSideToolbar/MenuItem'; import TurnIntoPopover from '$app/components/document/_shared/TurnInto'; + function BlockMenuTurnInto({ id, onClose, diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSideToolbar/BlockSideToolbar.hooks.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSideToolbar/BlockSideToolbar.hooks.tsx index 5ecbb2407a..f7d789a796 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSideToolbar/BlockSideToolbar.hooks.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSideToolbar/BlockSideToolbar.hooks.tsx @@ -1,13 +1,13 @@ -import { BlockType, HeadingBlockData, NestedBlock } from '@/appflowy_app/interfaces/document'; +import { BlockType, HeadingBlockData } from '@/appflowy_app/interfaces/document'; import { useAppDispatch } from '@/appflowy_app/stores/store'; -import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { getBlockByIdThunk } from '$app_reducers/document/async-actions'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; import { PopoverOrigin } from '@mui/material/Popover/Popover'; +import { getBlock } from '$app/components/document/_shared/SubscribeNode.hooks'; const headingBlockTopOffset: Record = { 1: 7, - 2: 6, - 3: 3, + 2: 5, + 3: 4, }; export function useBlockSideToolbar({ container }: { container: HTMLDivElement }) { const [nodeId, setHoverNodeId] = useState(null); @@ -19,9 +19,7 @@ export function useBlockSideToolbar({ container }: { container: HTMLDivElement } const el = ref.current; if (!el || !nodeId) return; void (async () => { - const { payload: node } = (await dispatch(getBlockByIdThunk(nodeId))) as { - payload: NestedBlock; - }; + const node = getBlock(nodeId); if (!node) { setStyle({ opacity: '0', @@ -29,7 +27,7 @@ export function useBlockSideToolbar({ container }: { container: HTMLDivElement } }); return; } else { - let top = 1; + let top = 2; if (node.type === BlockType.HeadingBlock) { const nodeData = node.data as HeadingBlockData; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSideToolbar/index.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSideToolbar/index.tsx index 099a099c8b..b1d2fb7fbe 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSideToolbar/index.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSideToolbar/index.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useContext, useState } from 'react'; +import React, { useContext } from 'react'; import { useBlockSideToolbar, usePopover } from './BlockSideToolbar.hooks'; import Portal from '../BlockPortal'; import { useAppDispatch, useAppSelector } from '$app/stores/store'; @@ -15,9 +15,7 @@ export default function BlockSideToolbar({ container }: { container: HTMLDivElem const dispatch = useAppDispatch(); const controller = useContext(DocumentControllerContext); const { nodeId, style, ref } = useBlockSideToolbar({ container }); - const isDragging = useAppSelector( - (state) => state.documentRangeSelection.isDragging || state.documentRectSelection.isDragging - ); + const isDragging = useAppSelector((state) => state.documentRange.isDragging || state.documentRectSelection.isDragging); const { handleOpen, ...popoverProps } = usePopover(); // prevent popover from showing when anchorEl is not in DOM diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSlash/BlockSlashMenu.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSlash/BlockSlashMenu.tsx index 20d43ec1d2..bcd4fe5eb7 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSlash/BlockSlashMenu.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSlash/BlockSlashMenu.tsx @@ -10,6 +10,7 @@ import { Lightbulb, TextFields, Title, + SafetyDivider, } from '@mui/icons-material'; import { List } from '@mui/material'; import { BlockData, BlockType } from '$app/interfaces/document'; @@ -107,6 +108,11 @@ function BlockSlashMenu({ id, onClose, searchText }: { id: string; onClose?: () title: 'Callout', icon: , }, + { + type: BlockType.DividerBlock, + title: 'Divider', + icon: , + }, ], ], [] diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSlash/index.hooks.ts b/frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSlash/index.hooks.ts index 5361613e80..b2f26ca41e 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSlash/index.hooks.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSlash/index.hooks.ts @@ -2,7 +2,7 @@ import { useAppDispatch, useAppSelector } from '$app/stores/store'; import React, { useCallback, useEffect, useMemo } from 'react'; import { slashCommandActions } from '$app_reducers/document/slice'; import { useSubscribeNode } from '$app/components/document/_shared/SubscribeNode.hooks'; -import { TextDelta } from '$app/interfaces/document'; +import { Op } from 'quill-delta'; export function useBlockSlash() { const dispatch = useAppDispatch(); @@ -54,7 +54,7 @@ export function useSubscribeSlash() { if (!node) return ''; const delta = node.data.delta || []; return delta - .map((op: TextDelta) => { + .map((op: Op) => { if (typeof op.insert === 'string') { return op.insert; } else { diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/CodeBlock/CodeBlock.hooks.ts b/frontend/appflowy_tauri/src/appflowy_app/components/document/CodeBlock/CodeBlock.hooks.ts deleted file mode 100644 index 1d5a5cbf26..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/CodeBlock/CodeBlock.hooks.ts +++ /dev/null @@ -1,86 +0,0 @@ -import { useTextInput } from '$app/components/document/_shared/Text/TextInput.hooks'; -import isHotkey from 'is-hotkey'; -import { useCallback, useContext, useMemo } from 'react'; -import { Editor } from 'slate'; -import { BlockType, NestedBlock, TextBlockKeyEventHandlerParams } from '$app/interfaces/document'; -import { keyBoardEventKeyMap } from '$app/constants/document/text_block'; -import { useAppDispatch } from '$app/stores/store'; -import { DocumentControllerContext } from '$app/stores/effects/document/document_controller'; -import { splitNodeThunk } from '$app_reducers/document/async-actions'; -import { useDefaultTextInputEvents } from '$app/components/document/_shared/Text/TextEvents.hooks'; -import { indent, outdent } from '$app/utils/document/blocks/code'; - -export function useCodeBlock(node: NestedBlock) { - const id = node.id; - const dispatch = useAppDispatch(); - const controller = useContext(DocumentControllerContext); - const { editor, ...rest } = useTextInput(id); - const defaultTextInputEvents = useDefaultTextInputEvents(id); - - const customEvents = useMemo(() => { - return [ - { - // Here custom tab key event for TextBlock to insert 2 spaces - triggerEventKey: keyBoardEventKeyMap.Tab, - canHandle: (...args: TextBlockKeyEventHandlerParams) => isHotkey('tab', args[0]), - handler: (...args: TextBlockKeyEventHandlerParams) => { - const [e, editor] = args; - e.preventDefault(); - indent(editor, 2); - }, - }, - { - // Here custom shift+tab key event for TextBlock to delete 2 spaces - triggerEventKey: keyBoardEventKeyMap.Tab, - canHandle: (...args: TextBlockKeyEventHandlerParams) => isHotkey('shift+tab', args[0]), - handler: (...args: TextBlockKeyEventHandlerParams) => { - const [e, editor] = args; - e.preventDefault(); - outdent(editor, 2); - }, - }, - { - // Here custom enter key event for TextBlock - triggerEventKey: keyBoardEventKeyMap.Enter, - canHandle: (...args: TextBlockKeyEventHandlerParams) => isHotkey('enter', args[0]), - handler: (...args: TextBlockKeyEventHandlerParams) => { - const [e, editor] = args; - e.preventDefault(); - Editor.insertText(editor, '\n'); - }, - }, - { - // Here custom shift+enter key event for TextBlock - triggerEventKey: keyBoardEventKeyMap.Enter, - canHandle: (...args: TextBlockKeyEventHandlerParams) => isHotkey('shift+enter', args[0]), - handler: (...args: TextBlockKeyEventHandlerParams) => { - const [e, editor] = args; - e.preventDefault(); - void (async () => { - if (!controller) return; - await dispatch(splitNodeThunk({ id, controller, editor })); - })(); - }, - }, - ]; - }, [controller, dispatch, id]); - - const onKeyDown = useCallback>( - (e) => { - const keyEvents = [...defaultTextInputEvents, ...customEvents]; - keyEvents.forEach((keyEvent) => { - // Here we check if the key event can be handled by the current key event - if (keyEvent.canHandle(e, editor)) { - keyEvent.handler(e, editor); - } - }); - }, - [defaultTextInputEvents, customEvents, editor] - ); - - return { - editor, - onKeyDown, - ...rest - }; -} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/CodeBlock/SelectLanguage.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/document/CodeBlock/SelectLanguage.tsx index cc9d621b3e..46eadc986b 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/CodeBlock/SelectLanguage.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/CodeBlock/SelectLanguage.tsx @@ -30,7 +30,12 @@ function SelectLanguage({ id, language }: { id: string; language: string }) { return ( - {supportLanguage.map((item) => ( {item.title} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/CodeBlock/index.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/document/CodeBlock/index.tsx index fb89a5c9f3..a7c65bd5da 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/CodeBlock/index.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/CodeBlock/index.tsx @@ -1,39 +1,37 @@ import { BlockType, NestedBlock } from '$app/interfaces/document'; -import { useCodeBlock } from './CodeBlock.hooks'; -import { Editable, Slate } from 'slate-react'; import React from 'react'; -import { CodeLeaf, CodeBlockElement } from './elements'; import SelectLanguage from './SelectLanguage'; -import { decorateCodeFunc } from '$app/utils/document/blocks/code/decorate'; +import { useChange } from '$app/components/document/_shared/EditorHooks/useChange'; +import { useKeyDown } from './useKeyDown'; +import CodeEditor from '$app/components/document/_shared/SlateEditor/CodeEditor'; +import { useSelection } from '$app/components/document/_shared/EditorHooks/useSelection'; export default function CodeBlock({ node, placeholder, ...props }: { node: NestedBlock; placeholder?: string } & React.HTMLAttributes) { - const { editor, value, onChange, ...rest } = useCodeBlock(node); - - const className = props.className ? ` ${props.className}` : ''; const id = node.id; const language = node.data.language; + const onKeyDown = useKeyDown(id); + const className = props.className ? ` ${props.className}` : ''; + const { value, onChange } = useChange(node); + const { onSelectionChange, selection, lastSelection } = useSelection(id); return (
- - { - const codeRange = decorateCodeFunc(entry, language); - const range = rest.decorate(entry); - return [...range, ...codeRange]; - }} - renderLeaf={CodeLeaf} - renderElement={CodeBlockElement} - placeholder={placeholder || 'Please enter some text...'} - /> - +
); } diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/CodeBlock/useKeyDown.ts b/frontend/appflowy_tauri/src/appflowy_app/components/document/CodeBlock/useKeyDown.ts new file mode 100644 index 0000000000..ad4c586a26 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/CodeBlock/useKeyDown.ts @@ -0,0 +1,51 @@ +import isHotkey from 'is-hotkey'; +import { useCallback, useContext, useMemo } from 'react'; +import { useAppDispatch } from '$app/stores/store'; +import { DocumentControllerContext } from '$app/stores/effects/document/document_controller'; +import { Keyboard } from '$app/constants/document/keyboard'; +import { useCommonKeyEvents } from '$app/components/document/_shared/EditorHooks/useCommonKeyEvents'; +import { enterActionForBlockThunk } from '$app_reducers/document/async-actions'; + +export function useKeyDown(id: string) { + const dispatch = useAppDispatch(); + const controller = useContext(DocumentControllerContext); + + const commonKeyEvents = useCommonKeyEvents(id); + const customEvents = useMemo(() => { + return [ + ...commonKeyEvents, + + { + canHandle: (e: React.KeyboardEvent) => { + return isHotkey(Keyboard.keys.SHIFT_ENTER, e); + }, + handler: (e: React.KeyboardEvent) => { + e.preventDefault(); + if (!controller) return; + dispatch( + enterActionForBlockThunk({ + id, + controller, + }) + ); + }, + }, + ]; + }, [commonKeyEvents, controller, dispatch, id]); + + const onKeyDown = useCallback>( + (e) => { + e.stopPropagation(); + const keyEvents = [...customEvents]; + keyEvents.forEach((keyEvent) => { + // Here we check if the key event can be handled by the current key event + if (keyEvent.canHandle(e)) { + keyEvent.handler(e); + } + }); + }, + [customEvents] + ); + + return onKeyDown; +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/HeadingBlock/index.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/document/HeadingBlock/index.tsx index 30af6c677d..a8d1ee3da4 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/HeadingBlock/index.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/HeadingBlock/index.tsx @@ -1,4 +1,4 @@ -import TextBlock from '../TextBlock'; +import TextBlock from '$app/components/document/TextBlock'; import { BlockType, NestedBlock } from '@/appflowy_app/interfaces/document'; const fontSize: Record = { diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/Node/index.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/document/Node/index.tsx index d972c75183..9c2b95fd8a 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/Node/index.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/Node/index.tsx @@ -14,6 +14,7 @@ import NumberedListBlock from '$app/components/document/NumberedListBlock'; import ToggleListBlock from '$app/components/document/ToggleListBlock'; import DividerBlock from '$app/components/document/DividerBlock'; import CalloutBlock from '$app/components/document/CalloutBlock'; +import BlockOverlay from '$app/components/document/Overlay/BlockOverlay'; import CodeBlock from '$app/components/document/CodeBlock'; function NodeComponent({ id, ...props }: { id: string } & React.HTMLAttributes) { @@ -55,12 +56,13 @@ function NodeComponent({ id, ...props }: { id: string } & React.HTMLAttributes +
{renderBlock()} -
+ {isSelected ? (
) : null} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/Overlay/BlockOverlay.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/document/Overlay/BlockOverlay.tsx new file mode 100644 index 0000000000..f4f236f1fa --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/Overlay/BlockOverlay.tsx @@ -0,0 +1,7 @@ +import React from 'react'; + +function BlockOverlay({ id }: { id: string }) { + return
; +} + +export default BlockOverlay; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/Root/index.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/document/Root/index.tsx index a0795df350..75e35eb765 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/Root/index.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/Root/index.tsx @@ -20,7 +20,7 @@ function Root({ documentData }: { documentData: DocumentData }) { return ( <> -
+
diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/TextActionMenu/index.hooks.ts b/frontend/appflowy_tauri/src/appflowy_app/components/document/TextActionMenu/index.hooks.ts index 821d03e893..4d00d48393 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/TextActionMenu/index.hooks.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/TextActionMenu/index.hooks.ts @@ -1,20 +1,21 @@ -import { useEffect, useRef, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { calcToolbarPosition } from '$app/utils/document/toolbar'; import { useAppSelector } from '$app/stores/store'; +import { getNode } from '$app/utils/document/node'; +import { debounce } from '$app/utils/tool'; export function useMenuStyle(container: HTMLDivElement) { const ref = useRef(null); - const range = useAppSelector((state) => state.documentRangeSelection); + const id = useAppSelector((state) => state.documentRange.focus?.id); + const [isScrolling, setIsScrolling] = useState(false); - const [scrollTop, setScrollTop] = useState(container.scrollTop); - useEffect(() => { + const reCalculatePosition = useCallback(() => { const el = ref.current; - if (!el) return; + if (!el || !id) return; - const id = range.focus?.id; - if (!id) return; - - const position = calcToolbarPosition(el); + const node = getNode(id); + if (!node) return; + const position = calcToolbarPosition(el, node, container); if (!position) { el.style.opacity = '0'; @@ -22,22 +23,38 @@ export function useMenuStyle(container: HTMLDivElement) { } else { el.style.opacity = '1'; el.style.pointerEvents = 'auto'; - el.style.top = position.top; - el.style.left = position.left; + el.style.top = position.top + 'px'; + el.style.left = position.left + 'px'; } - }); + }, [container, id]); + + useEffect(() => { + // recalculating toolbar position when scrolling is finished + if (isScrolling) return; + reCalculatePosition(); + }, [container, id, isScrolling, reCalculatePosition]); + + const debounceScrollEnd = useMemo(() => { + return debounce(() => { + // set isScrolling to false after 20ms + setIsScrolling(false); + }, 20); + }, []); useEffect(() => { const handleScroll = () => { - setScrollTop(container.scrollTop); + setIsScrolling(true); + debounceScrollEnd(); }; container.addEventListener('scroll', handleScroll); return () => { + debounceScrollEnd.cancel(); container.removeEventListener('scroll', handleScroll); }; - }, [container]); + }, [container, debounceScrollEnd]); return { ref, + id, }; } diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/TextActionMenu/index.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/document/TextActionMenu/index.tsx index 3625e53c5a..82350c3a1b 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/TextActionMenu/index.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/TextActionMenu/index.tsx @@ -1,46 +1,45 @@ import { useMenuStyle } from './index.hooks'; import { useAppSelector } from '$app/stores/store'; -import { isEqual } from '$app/utils/tool'; import TextActionMenuList from '$app/components/document/TextActionMenu/menu'; +import BlockPortal from '$app/components/document/BlockPortal'; const TextActionComponent = ({ container }: { container: HTMLDivElement }) => { - const { ref } = useMenuStyle(container); + const { ref, id } = useMenuStyle(container); + if (!id) return null; return ( -
{ - // prevent toolbar from taking focus away from editor - e.preventDefault(); - e.stopPropagation(); - }} - > - -
+ +
{ + // prevent toolbar from taking focus away from editor + e.preventDefault(); + e.stopPropagation(); + }} + > + +
+
); }; const TextActionMenu = ({ container }: { container: HTMLDivElement }) => { const canShow = useAppSelector((state) => { - const range = state.documentRangeSelection; - if (range.isDragging) return false; - const anchorNode = range.anchor; - const focusNode = range.focus; - if (!anchorNode || !focusNode) return false; - const isSameLine = anchorNode.id === focusNode.id; - const isCollapsed = isEqual(anchorNode.selection.anchor, anchorNode.selection.focus); - return !(isSameLine && isCollapsed); + const { isDragging, focus, anchor, ranges } = state.documentRange; + if (isDragging) return false; + if (!focus || !anchor) return false; + const isSameLine = anchor.id === focus.id; + const anchorRange = ranges[anchor.id]; + if (!anchorRange) return false; + const isCollapsed = isSameLine && anchorRange.length === 0; + return !isCollapsed; }); if (!canShow) return null; - return ( -
- -
- ); + return ; }; export default TextActionMenu; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/TextActionMenu/menu/FormatButton.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/document/TextActionMenu/menu/FormatButton.tsx index 5fc5127c9c..cdbb725f94 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/TextActionMenu/menu/FormatButton.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/TextActionMenu/menu/FormatButton.tsx @@ -12,7 +12,7 @@ const FormatButton = ({ format, icon }: { format: TextAction; icon: string }) => const dispatch = useAppDispatch(); const controller = useContext(DocumentControllerContext); - const focusId = useAppSelector((state) => state.documentRangeSelection.focus?.id || ''); + const focusId = useAppSelector((state) => state.documentRange.focus?.id || ''); const { node: focusNode } = useSubscribeNode(focusId); const [isActive, setIsActive] = React.useState(false); diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/TextActionMenu/menu/FormatIcon.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/document/TextActionMenu/menu/FormatIcon.tsx index c3fa3bb69e..d8c0024213 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/TextActionMenu/menu/FormatIcon.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/TextActionMenu/menu/FormatIcon.tsx @@ -1,18 +1,19 @@ import React from 'react'; import { FormatBold, FormatUnderlined, FormatItalic, CodeOutlined, StrikethroughSOutlined } from '@mui/icons-material'; +import { TextAction } from '$app/interfaces/document'; export const iconSize = { width: 18, height: 18 }; export default function FormatIcon({ icon }: { icon: string }) { switch (icon) { - case 'bold': + case TextAction.Bold: return ; - case 'underlined': + case TextAction.Underline: return ; - case 'italic': + case TextAction.Italic: return ; - case 'code': + case TextAction.Code: return ; - case 'strikethrough': + case TextAction.Strikethrough: return ; default: return null; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/TextActionMenu/menu/index.hooks.ts b/frontend/appflowy_tauri/src/appflowy_app/components/document/TextActionMenu/menu/index.hooks.ts index d5c52fdfd2..f20ff69f69 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/TextActionMenu/menu/index.hooks.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/TextActionMenu/menu/index.hooks.ts @@ -11,17 +11,17 @@ import { useSubscribeNode } from '$app/components/document/_shared/SubscribeNode import { TextAction } from '$app/interfaces/document'; export function useTextActionMenu() { - const range = useAppSelector((state) => state.documentRangeSelection); - - const id = useMemo(() => { - return range.anchor?.id === range.focus?.id ? range.anchor?.id : undefined; + const range = useAppSelector((state) => state.documentRange); + const isSingleLine = useMemo(() => { + return range.focus?.id === range.anchor?.id; }, [range]); + const focusId = range.focus?.id; - const { node } = useSubscribeNode(id || ''); + const { node } = useSubscribeNode(focusId || ''); const items = useMemo(() => { - if (node) { - const config = blockConfig[node.type]; + if (isSingleLine) { + const config = blockConfig[node?.type]; const { customItems, excludeItems } = { ...defaultTextActionProps, ...config.textActionMenuProps, @@ -30,7 +30,7 @@ export function useTextActionMenu() { } else { return multiLineTextActionProps.customItems || []; } - }, [node]); + }, [isSingleLine, node?.type]); // the groups have default items, so we need to filter the items if this node has excluded items const groupItems: TextAction[][] = useMemo(() => { @@ -42,6 +42,7 @@ export function useTextActionMenu() { return { groupItems, - id, + isSingleLine, + focusId, }; } diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/TextActionMenu/menu/index.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/document/TextActionMenu/menu/index.tsx index 4184f99260..081f96f951 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/TextActionMenu/menu/index.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/TextActionMenu/menu/index.tsx @@ -5,33 +5,39 @@ import FormatButton from '$app/components/document/TextActionMenu/menu/FormatBut import { useTextActionMenu } from '$app/components/document/TextActionMenu/menu/index.hooks'; function TextActionMenuList() { - const { groupItems, id } = useTextActionMenu(); - const renderNode = useCallback((action: TextAction, id?: string) => { - switch (action) { - case TextAction.Turn: - return id ? : null; - case TextAction.Bold: - case TextAction.Italic: - case TextAction.Underline: - case TextAction.Strikethrough: - case TextAction.Code: - return ; - default: - return null; - } - }, []); + const { groupItems, isSingleLine, focusId } = useTextActionMenu(); + const renderNode = useCallback( + (action: TextAction) => { + switch (action) { + case TextAction.Turn: + return isSingleLine && focusId ? : null; + case TextAction.Bold: + case TextAction.Italic: + case TextAction.Underline: + case TextAction.Strikethrough: + case TextAction.Code: + return ; + default: + return null; + } + }, + [isSingleLine, focusId] + ); return (
- {groupItems.map((group, i: number) => ( -
- {group.map((item) => ( -
- {renderNode(item, id)} + {groupItems.map( + (group, i: number) => + group.length > 0 && ( +
+ {group.map((item) => ( +
+ {renderNode(item)} +
+ ))}
- ))} -
- ))} + ) + )}
); } diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/TextBlock/Leaf.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/document/TextBlock/Leaf.tsx deleted file mode 100644 index 06416564ee..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/TextBlock/Leaf.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import { BaseText } from 'slate'; -import { RenderLeafProps } from 'slate-react'; -interface LeafProps extends RenderLeafProps { - leaf: BaseText & { - bold?: boolean; - code?: boolean; - italic?: boolean; - underlined?: boolean; - strikethrough?: boolean; - selectionHighlighted?: boolean; - }; -} -const Leaf = ({ attributes, children, leaf }: LeafProps) => { - let newChildren = children; - if (leaf.bold) { - newChildren = {children}; - } - - if (leaf.italic) { - newChildren = {newChildren}; - } - - if (leaf.underlined) { - newChildren = {newChildren}; - } - - const className = [ - leaf.strikethrough && 'line-through', - leaf.selectionHighlighted && 'bg-main-secondary', - leaf.code && 'bg-main-selector', - ].filter(Boolean); - - return ( - - {newChildren} - - ); -}; - -export default Leaf; 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 deleted file mode 100644 index 1864fcb929..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/TextBlock/TextBlock.hooks.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { useTextInput } from '../_shared/Text/TextInput.hooks'; -import { useTextBlockKeyEvent } from '$app/components/document/TextBlock/events/Events.hooks'; - -export function useTextBlock(id: string) { - const { editor, ...props } = useTextInput(id); - - const { onKeyDown } = useTextBlockKeyEvent(id, editor); - - return { - onKeyDown, - editor, - ...props, - }; -} 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 deleted file mode 100644 index 135f7575c3..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/TextBlock/events/Events.hooks.ts +++ /dev/null @@ -1,134 +0,0 @@ -import { Editor } from 'slate'; -import { useTurnIntoBlock } from './TurnIntoEvents.hooks'; -import { useCallback, useContext, useMemo } from 'react'; -import { keyBoardEventKeyMap } from '$app/constants/document/text_block'; -import { BlockType, TextBlockKeyEventHandlerParams } from '$app/interfaces/document'; -import isHotkey from 'is-hotkey'; -import { indentNodeThunk, outdentNodeThunk, splitNodeThunk } from '$app_reducers/document/async-actions'; -import { DocumentControllerContext } from '$app/stores/effects/document/document_controller'; -import { useAppDispatch, useAppSelector } from '$app/stores/store'; -import { useDefaultTextInputEvents } from '$app/components/document/_shared/Text/TextEvents.hooks'; -import { ReactEditor } from 'slate-react'; -import { getBeforeRangeAt } from '$app/utils/document/blocks/text/delta'; -import { slashCommandActions } from '$app_reducers/document/slice'; -import { useSubscribeNode } from '$app/components/document/_shared/SubscribeNode.hooks'; - -export function useTextBlockKeyEvent(id: string, editor: ReactEditor) { - const controller = useContext(DocumentControllerContext); - const dispatch = useAppDispatch(); - const defaultTextInputEvents = useDefaultTextInputEvents(id); - const isFocusCurrentNode = useAppSelector((state) => { - const { anchor, focus } = state.documentRangeSelection; - if (!anchor || !focus) return false; - return anchor.id === id && focus.id === id; - }); - - const { node } = useSubscribeNode(id); - const nodeType = node?.type; - - const { turnIntoBlockEvents } = useTurnIntoBlock(id); - - // Here custom key events for TextBlock - const events = useMemo( - () => [ - ...defaultTextInputEvents, - { - // Here custom enter key event for TextBlock - triggerEventKey: keyBoardEventKeyMap.Enter, - canHandle: (...args: TextBlockKeyEventHandlerParams) => isHotkey('enter', args[0]), - handler: (...args: TextBlockKeyEventHandlerParams) => { - const [e, editor] = args; - e.preventDefault(); - void (async () => { - if (!controller) return; - await dispatch(splitNodeThunk({ id, controller, editor })); - })(); - }, - }, - { - // Here custom shift+enter key event for TextBlock - triggerEventKey: keyBoardEventKeyMap.Enter, - canHandle: (...args: TextBlockKeyEventHandlerParams) => isHotkey('shift+enter', args[0]), - handler: (...args: TextBlockKeyEventHandlerParams) => { - const [e, editor] = args; - e.preventDefault(); - Editor.insertText(editor, '\n'); - }, - }, - { - // Here custom tab key event for TextBlock - triggerEventKey: keyBoardEventKeyMap.Tab, - canHandle: (...args: TextBlockKeyEventHandlerParams) => isHotkey('tab', args[0]), - handler: (...args: TextBlockKeyEventHandlerParams) => { - const [e, _] = args; - e.preventDefault(); - if (!controller) return; - dispatch( - indentNodeThunk({ - id, - controller, - }) - ); - }, - }, - { - // Here custom shift+tab key event for TextBlock - triggerEventKey: keyBoardEventKeyMap.Tab, - canHandle: (...args: TextBlockKeyEventHandlerParams) => isHotkey('shift+tab', args[0]), - handler: (...args: TextBlockKeyEventHandlerParams) => { - const [e, _] = args; - e.preventDefault(); - if (!controller) return; - dispatch( - outdentNodeThunk({ - id, - controller, - }) - ); - }, - }, - { - // Here custom slash key event for TextBlock - triggerEventKey: keyBoardEventKeyMap.Slash, - canHandle: (...args: TextBlockKeyEventHandlerParams) => { - const [e, editor] = args; - if (!editor.selection) return false; - - return isHotkey('/', e) && Editor.string(editor, getBeforeRangeAt(editor, editor.selection)) === ''; - }, - handler: (...args: TextBlockKeyEventHandlerParams) => { - const [e, _] = args; - if (!controller) return; - dispatch( - slashCommandActions.openSlashCommand({ - blockId: id, - }) - ); - }, - }, - ], - [defaultTextInputEvents, controller, dispatch, id, nodeType] - ); - - const onKeyDown = useCallback( - (event: React.KeyboardEvent) => { - if (!isFocusCurrentNode) { - event.preventDefault(); - return; - } - - event.stopPropagation(); - // 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)); - - matchKeys.forEach((matchKey) => matchKey.handler(event, editor)); - }, - [editor, events, turnIntoBlockEvents, isFocusCurrentNode] - ); - - 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 deleted file mode 100644 index 8c3eead1e3..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/TextBlock/events/TurnIntoEvents.hooks.ts +++ /dev/null @@ -1,102 +0,0 @@ -import { useContext, useMemo } from 'react'; -import { BlockType, TextBlockKeyEventHandlerParams } from '$app/interfaces/document'; -import { keyBoardEventKeyMap } from '$app/constants/document/text_block'; -import { useAppDispatch } from '$app/stores/store'; -import { DocumentControllerContext } from '$app/stores/effects/document/document_controller'; -import { turnToBlockThunk, turnToDividerBlockThunk } from '$app_reducers/document/async-actions'; -import { blockConfig } from '$app/constants/document/config'; -import { Editor } from 'slate'; -import { getBeforeRangeAt, getDeltaAfterSelection } from '$app/utils/document/blocks/text/delta'; -import { - getHeadingDataFromEditor, - getQuoteDataFromEditor, - getTodoListDataFromEditor, - getBulletedDataFromEditor, - getNumberedListDataFromEditor, - getToggleListDataFromEditor, - getCalloutDataFromEditor, - getCodeBlockDataFromEditor, -} from '$app/utils/document/blocks'; - -export function useTurnIntoBlock(id: string) { - const controller = useContext(DocumentControllerContext); - const dispatch = useAppDispatch(); - - const turnIntoBlockEvents = useMemo(() => { - const spaceTriggerEvents = Object.entries({ - [BlockType.HeadingBlock]: getHeadingDataFromEditor, - [BlockType.TodoListBlock]: getTodoListDataFromEditor, - [BlockType.QuoteBlock]: getQuoteDataFromEditor, - [BlockType.BulletedListBlock]: getBulletedDataFromEditor, - [BlockType.NumberedListBlock]: getNumberedListDataFromEditor, - [BlockType.ToggleListBlock]: getToggleListDataFromEditor, - [BlockType.CalloutBlock]: getCalloutDataFromEditor, - }).map(([type, getData]) => { - const blockType = type as BlockType; - const triggerKey = keyBoardEventKeyMap.Space; - return { - triggerEventKey: keyBoardEventKeyMap.Space, - canHandle: canHandle(blockType, triggerKey), - handler: (...args: TextBlockKeyEventHandlerParams) => { - if (!controller) return; - const [_event, editor] = args; - const data = getData(editor); - if (!data) return; - dispatch(turnToBlockThunk({ id, data, type: blockType, controller })); - }, - }; - }); - return [ - ...spaceTriggerEvents, - { - triggerEventKey: keyBoardEventKeyMap.Reduce, - canHandle: canHandle(BlockType.DividerBlock, keyBoardEventKeyMap.Reduce), - handler: (...args: TextBlockKeyEventHandlerParams) => { - if (!controller) return; - const [_event, editor] = args; - const delta = getDeltaAfterSelection(editor) || []; - dispatch(turnToDividerBlockThunk({ id, controller, delta })); - }, - }, - { - triggerEventKey: keyBoardEventKeyMap.Backquote, - canHandle: canHandle(BlockType.CodeBlock, keyBoardEventKeyMap.Backquote), - handler: (...args: TextBlockKeyEventHandlerParams) => { - if (!controller) return; - const [_event, editor] = args; - const data = getCodeBlockDataFromEditor(editor); - dispatch(turnToBlockThunk({ id, data, type: BlockType.CodeBlock, controller })); - }, - }, - ]; - }, [controller, dispatch, id]); - - return { - turnIntoBlockEvents, - }; -} - -function canHandle(type: BlockType, triggerKey: string) { - 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 isTrigger = event.key === triggerKey; - const selection = editor.selection; - - if (!isTrigger || !selection) { - return false; - } - - const flag = Editor.string(editor, getBeforeRangeAt(editor, selection)).trim(); - if (flag === null) return false; - - return regex.some((r) => r.test(`${flag}${triggerKey}`)); - }; -} 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 275a79f94e..9401d1aac3 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,34 +1,32 @@ -import { Slate, Editable } from 'slate-react'; -import Leaf from './Leaf'; -import { useTextBlock } from './TextBlock.hooks'; import React from 'react'; import { NestedBlock } from '$app/interfaces/document'; +import Editor from '../_shared/SlateEditor/TextEditor'; +import { useChange } from '$app/components/document/_shared/EditorHooks/useChange'; import NodeChildren from '$app/components/document/Node/NodeChildren'; +import { useKeyDown } from '$app/components/document/TextBlock/useKeyDown'; +import { useSelection } from '$app/components/document/_shared/EditorHooks/useSelection'; -function TextBlock({ - node, - childIds, - placeholder, - className = '', -}: { +interface Props { node: NestedBlock; childIds?: string[]; placeholder?: string; - className?: string; -}) { - const { editor, value, onChange, ...rest } = useTextBlock(node.id); +} +function TextBlock({ node, childIds, placeholder }: Props) { + const { value, onChange } = useChange(node); + const { onSelectionChange, selection, lastSelection } = useSelection(node.id); + const { onKeyDown } = useKeyDown(node.id); return ( <> -
- - } - placeholder={placeholder || 'Please enter some text...'} - /> - -
+ ); diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/TextBlock/useKeyDown.ts b/frontend/appflowy_tauri/src/appflowy_app/components/document/TextBlock/useKeyDown.ts new file mode 100644 index 0000000000..d55afbf3c1 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/TextBlock/useKeyDown.ts @@ -0,0 +1,105 @@ +import { useCallback, useContext, useMemo } from 'react'; +import { Keyboard } from '$app/constants/document/keyboard'; +import isHotkey from 'is-hotkey'; +import { useAppDispatch } from '@/appflowy_app/stores/store'; +import { DocumentControllerContext } from '$app/stores/effects/document/document_controller'; +import { + enterActionForBlockThunk, + tabActionForBlockThunk, + shiftTabActionForBlockThunk, +} from '$app_reducers/document/async-actions'; +import { useTurnIntoBlockEvents } from './useTurnIntoBlockEvents'; +import { useCommonKeyEvents } from '../_shared/EditorHooks/useCommonKeyEvents'; + +export function useKeyDown(id: string) { + const controller = useContext(DocumentControllerContext); + const dispatch = useAppDispatch(); + const turnIntoEvents = useTurnIntoBlockEvents(id); + const commonKeyEvents = useCommonKeyEvents(id); + const interceptEvents = useMemo(() => { + return [ + ...commonKeyEvents, + { + // Prevent all enter key + canHandle: (e: React.KeyboardEvent) => { + return e.key === Keyboard.keys.ENTER; + }, + handler: (e: React.KeyboardEvent) => { + e.preventDefault(); + }, + }, + { + // handle enter key and no other key is pressed + canHandle: (e: React.KeyboardEvent) => { + return isHotkey(Keyboard.keys.ENTER, e); + }, + handler: (e: React.KeyboardEvent) => { + if (!controller) return; + dispatch( + enterActionForBlockThunk({ + id, + controller, + }) + ); + }, + }, + + { + // Prevent tab key from indenting + canHandle: (e: React.KeyboardEvent) => { + return e.key === Keyboard.keys.TAB; + }, + handler: (e: React.KeyboardEvent) => { + e.preventDefault(); + }, + }, + { + // handle tab key and no other key is pressed + canHandle: (e: React.KeyboardEvent) => { + return isHotkey(Keyboard.keys.TAB, e); + }, + handler: (e: React.KeyboardEvent) => { + if (!controller) return; + dispatch( + tabActionForBlockThunk({ + id, + controller, + }) + ); + }, + }, + { + // handle shift + tab key and no other key is pressed + canHandle: (e: React.KeyboardEvent) => { + return isHotkey(Keyboard.keys.SHIFT_TAB, e); + }, + handler: (e: React.KeyboardEvent) => { + if (!controller) return; + dispatch( + shiftTabActionForBlockThunk({ + id, + controller, + }) + ); + }, + }, + + ...turnIntoEvents, + ]; + }, [commonKeyEvents, controller, dispatch, id, turnIntoEvents]); + + const onKeyDown = useCallback( + (e: React.KeyboardEvent) => { + + e.stopPropagation(); + + const filteredEvents = interceptEvents.filter((event) => event.canHandle(e)); + filteredEvents.forEach((event) => event.handler(e)); + }, + [interceptEvents] + ); + + return { + onKeyDown, + }; +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/TextBlock/useTurnIntoBlockEvents.ts b/frontend/appflowy_tauri/src/appflowy_app/components/document/TextBlock/useTurnIntoBlockEvents.ts new file mode 100644 index 0000000000..7100f2cf3e --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/TextBlock/useTurnIntoBlockEvents.ts @@ -0,0 +1,185 @@ +import { useCallback, useContext, useMemo } from 'react'; +import { BlockType } from '$app/interfaces/document'; +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 Delta, { Op } from 'quill-delta'; +import { useRangeRef } from '$app/components/document/_shared/SubscribeSelection.hooks'; +import { getBlock } from '$app/components/document/_shared/SubscribeNode.hooks'; +import isHotkey from 'is-hotkey'; +import { slashCommandActions } from '$app_reducers/document/slice'; +import { Keyboard } from '$app/constants/document/keyboard'; +import { getDeltaText } from '$app/utils/document/delta'; + +export function useTurnIntoBlockEvents(id: string) { + const controller = useContext(DocumentControllerContext); + const dispatch = useAppDispatch(); + const rangeRef = useRangeRef(); + + const getFlag = useCallback(() => { + const range = rangeRef.current?.caret; + if (!range || range.id !== id) return; + const node = getBlock(id); + const delta = new Delta(node.data.delta || []); + const flag = getDeltaText(delta.slice(0, range.index)); + return flag; + }, [id, rangeRef]); + + const getDeltaContent = useCallback(() => { + const range = rangeRef.current?.caret; + if (!range || range.id !== id) return; + const node = getBlock(id); + const delta = new Delta(node.data.delta || []); + const content = delta.slice(range.index); + return new Delta(content); + }, [id, rangeRef]); + + const canHandle = useCallback( + (event: React.KeyboardEvent, type: BlockType, triggerKey: string) => { + { + 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`); + } + + const isTrigger = event.key === triggerKey; + + if (!isTrigger) { + return false; + } + const flag = getFlag(); + if (!flag) return false; + + return regex.some((r) => r.test(`${flag}${triggerKey}`)); + } + }, + [getFlag] + ); + + const getTurnIntoBlockDelta = useCallback(() => { + const content = getDeltaContent(); + if (!content) return; + return { + delta: content.ops, + }; + }, [getDeltaContent]); + + const spaceTriggerMap = useMemo(() => { + return { + [BlockType.HeadingBlock]: () => { + const flag = getFlag(); + if (!flag) return; + return { + level: flag.match(/#/g)?.length, + ...getTurnIntoBlockDelta(), + }; + }, + [BlockType.TodoListBlock]: () => { + const flag = getFlag(); + if (!flag) return; + + return { + checked: flag.includes('[x]'), + ...getTurnIntoBlockDelta(), + }; + }, + [BlockType.QuoteBlock]: getTurnIntoBlockDelta, + [BlockType.BulletedListBlock]: getTurnIntoBlockDelta, + [BlockType.NumberedListBlock]: getTurnIntoBlockDelta, + [BlockType.ToggleListBlock]: getTurnIntoBlockDelta, + [BlockType.CalloutBlock]: () => { + const flag = getFlag(); + if (!flag) return; + const tag = flag.match(/(TIP|INFO|WARNING|DANGER)/g)?.[0]; + if (!tag) return; + const iconMap: Record = { + TIP: '💡', + INFO: '❗', + WARNING: '⚠️', + DANGER: '‼️', + }; + return { + icon: iconMap[tag], + ...getTurnIntoBlockDelta(), + }; + }, + }; + }, [getFlag, getTurnIntoBlockDelta]); + + const turnIntoBlockEvents = useMemo(() => { + const spaceTriggerEvents = Object.entries(spaceTriggerMap).map(([type, getData]) => { + const blockType = type as BlockType; + const triggerKey = Keyboard.keys.Space; + + return { + canHandle: (e: React.KeyboardEvent) => canHandle(e, blockType, triggerKey), + handler: (e: React.KeyboardEvent) => { + e.preventDefault(); + if (!controller) return; + const data = getData(); + if (!data) return; + dispatch(turnToBlockThunk({ id, data, type: blockType, controller })); + }, + }; + }); + return [ + ...spaceTriggerEvents, + { + canHandle: (e: React.KeyboardEvent) => + canHandle(e, BlockType.DividerBlock, Keyboard.keys.Reduce), + handler: (e: React.KeyboardEvent) => { + e.preventDefault(); + if (!controller) return; + const delta = getDeltaContent(); + + dispatch( + turnToBlockThunk({ + id, + controller, + type: BlockType.DividerBlock, + data: { + delta: delta?.ops as Op[], + }, + }) + ); + }, + }, + { + canHandle: (e: React.KeyboardEvent) => + canHandle(e, BlockType.CodeBlock, Keyboard.keys.BackQuote), + handler: (e: React.KeyboardEvent) => { + e.preventDefault(); + if (!controller) return; + const defaultData = blockConfig[BlockType.CodeBlock].defaultData; + const data = { + ...defaultData, + delta: getDeltaContent()?.ops as Op[], + }; + dispatch(turnToBlockThunk({ id, data, type: BlockType.CodeBlock, controller })); + }, + }, + { + // Here custom slash key event for TextBlock + canHandle: (e: React.KeyboardEvent) => { + const flag = getFlag(); + return isHotkey('/', e) && flag === ''; + }, + handler: (e: React.KeyboardEvent) => { + if (!controller) return; + dispatch( + slashCommandActions.openSlashCommand({ + blockId: id, + }) + ); + }, + }, + ]; + }, [canHandle, controller, dispatch, getDeltaContent, getFlag, id, spaceTriggerMap]); + + return turnIntoBlockEvents; +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/TodoListBlock/TodoListBlock.hooks.ts b/frontend/appflowy_tauri/src/appflowy_app/components/document/TodoListBlock/TodoListBlock.hooks.ts index f6b6a3bd3e..7dd74bf654 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/TodoListBlock/TodoListBlock.hooks.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/TodoListBlock/TodoListBlock.hooks.ts @@ -1,7 +1,7 @@ import { useAppDispatch } from '$app/stores/store'; import { useCallback, useContext } from 'react'; import { DocumentControllerContext } from '$app/stores/effects/document/document_controller'; -import { updateNodeDataThunk } from '$app_reducers/document/async-actions/blocks/text/update'; +import { updateNodeDataThunk } from '$app_reducers/document/async-actions/blocks/update'; import { BlockData, BlockType } from '$app/interfaces/document'; import isHotkey from 'is-hotkey'; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/ToggleListBlock/ToggleListBlock.hooks.ts b/frontend/appflowy_tauri/src/appflowy_app/components/document/ToggleListBlock/ToggleListBlock.hooks.ts index 5cf079bafd..7d9dad3075 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/ToggleListBlock/ToggleListBlock.hooks.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/ToggleListBlock/ToggleListBlock.hooks.ts @@ -1,7 +1,7 @@ import { useAppDispatch } from '$app/stores/store'; import { useCallback, useContext } from 'react'; import { DocumentControllerContext } from '$app/stores/effects/document/document_controller'; -import { updateNodeDataThunk } from '$app_reducers/document/async-actions/blocks/text/update'; +import { updateNodeDataThunk } from '$app_reducers/document/async-actions/blocks/update'; import { BlockData, BlockType } from '$app/interfaces/document'; import isHotkey from 'is-hotkey'; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/VirtualizedList/VirtualizedList.hooks.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/document/VirtualizedList/VirtualizedList.hooks.tsx index cd58fa997b..59252a038b 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/VirtualizedList/VirtualizedList.hooks.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/VirtualizedList/VirtualizedList.hooks.tsx @@ -1,7 +1,7 @@ import { useVirtualizer } from '@tanstack/react-virtual'; import { useRef } from 'react'; -const defaultSize = 60; +const defaultSize = 30; export function useVirtualizedList(count: number) { const parentRef = useRef(null); @@ -9,6 +9,7 @@ export function useVirtualizedList(count: number) { const virtualize = useVirtualizer({ count, getScrollElement: () => parentRef.current, + overscan: 5, estimateSize: () => { return defaultSize; }, diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/VirtualizedList/index.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/document/VirtualizedList/index.tsx index 814a137084..9edd5239fe 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/VirtualizedList/index.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/VirtualizedList/index.tsx @@ -43,7 +43,12 @@ export default function VirtualizedList({ {virtualItems.map((virtualRow) => { const id = childIds[virtualRow.index]; return ( -
+
{virtualRow.index === 0 ? : null} {renderNode(id)}
diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/EditorHooks/useChange.ts b/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/EditorHooks/useChange.ts new file mode 100644 index 0000000000..d512742083 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/EditorHooks/useChange.ts @@ -0,0 +1,33 @@ +import { BlockType, NestedBlock } from '$app/interfaces/document'; +import { useCallback, useEffect, useState } from 'react'; +import Delta from 'quill-delta'; +import { useDelta } from '$app/components/document/_shared/EditorHooks/useDelta'; + +export function useChange(node: NestedBlock) { + const { update, delta } = useDelta({ id: node.id }); + + const [value, setValue] = useState(() => { + return delta; + }); + + useEffect(() => { + setValue(delta); + }, [delta]); + + const onChange = useCallback( + (newContents: Delta, oldContents: Delta, _source?: string) => { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + const isSame = newContents.diff(oldContents).ops.length === 0; + if (isSame) return; + setValue(newContents); + update(newContents); + }, + [update] + ); + + return { + value, + onChange, + }; +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/EditorHooks/useCommonKeyEvents.ts b/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/EditorHooks/useCommonKeyEvents.ts new file mode 100644 index 0000000000..41c0758922 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/EditorHooks/useCommonKeyEvents.ts @@ -0,0 +1,79 @@ +import isHotkey from 'is-hotkey'; +import { Keyboard } from '$app/constants/document/keyboard'; +import { + backspaceDeleteActionForBlockThunk, + leftActionForBlockThunk, + rightActionForBlockThunk, + upDownActionForBlockThunk, +} from '$app_reducers/document/async-actions'; +import { useContext, useMemo } from 'react'; +import { useFocused } from '$app/components/document/_shared/SubscribeSelection.hooks'; +import { DocumentControllerContext } from '$app/stores/effects/document/document_controller'; +import { useAppDispatch } from '$app/stores/store'; + +export function useCommonKeyEvents(id: string) { + const { focused, caretRef } = useFocused(id); + const controller = useContext(DocumentControllerContext); + const dispatch = useAppDispatch(); + const commonKeyEvents = useMemo(() => { + return [ + { + // handle backspace and delete key and the caret is at the beginning of the block + canHandle: (e: React.KeyboardEvent) => { + return ( + (isHotkey(Keyboard.keys.BACKSPACE, e) || isHotkey(Keyboard.keys.DELETE, e)) && + focused && + caretRef.current?.index === 0 && + caretRef.current?.length === 0 + ); + }, + handler: (e: React.KeyboardEvent) => { + e.preventDefault(); + if (!controller) return; + dispatch(backspaceDeleteActionForBlockThunk({ id, controller })); + }, + }, + { + // handle up arrow key and no other key is pressed + canHandle: (e: React.KeyboardEvent) => { + return isHotkey(Keyboard.keys.UP, e); + }, + handler: (e: React.KeyboardEvent) => { + e.preventDefault(); + dispatch(upDownActionForBlockThunk({ id })); + }, + }, + { + // handle down arrow key and no other key is pressed + canHandle: (e: React.KeyboardEvent) => { + return isHotkey(Keyboard.keys.DOWN, e); + }, + handler: (e: React.KeyboardEvent) => { + e.preventDefault(); + dispatch(upDownActionForBlockThunk({ id, down: true })); + }, + }, + { + // handle left arrow key and no other key is pressed + canHandle: (e: React.KeyboardEvent) => { + return isHotkey(Keyboard.keys.LEFT, e); + }, + handler: (e: React.KeyboardEvent) => { + e.preventDefault(); + dispatch(leftActionForBlockThunk({ id })); + }, + }, + { + // handle right arrow key and no other key is pressed + canHandle: (e: React.KeyboardEvent) => { + return isHotkey(Keyboard.keys.RIGHT, e); + }, + handler: (e: React.KeyboardEvent) => { + e.preventDefault(); + dispatch(rightActionForBlockThunk({ id })); + }, + }, + ]; + }, [caretRef, controller, dispatch, focused, id]); + return commonKeyEvents; +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/EditorHooks/useDelta.ts b/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/EditorHooks/useDelta.ts new file mode 100644 index 0000000000..aad0f377f2 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/EditorHooks/useDelta.ts @@ -0,0 +1,43 @@ +import { useSubscribeNode } from '$app/components/document/_shared/SubscribeNode.hooks'; +import { useCallback, useContext, useEffect, useMemo, useRef } from 'react'; +import { DocumentControllerContext } from '$app/stores/effects/document/document_controller'; +import { useAppDispatch } from '$app/stores/store'; +import { updateNodeDeltaThunk } from '$app_reducers/document/async-actions'; +import Delta from 'quill-delta'; + +export function useDelta({ id, onDeltaChange }: { id: string; onDeltaChange?: (delta: Delta) => void }) { + const controller = useContext(DocumentControllerContext); + const dispatch = useAppDispatch(); + const penddingRef = useRef(false); + const { node } = useSubscribeNode(id); + + const delta = useMemo(() => { + if (!node || !node.data.delta) return new Delta(); + return new Delta(node.data.delta); + }, [node]); + + useEffect(() => { + onDeltaChange?.(delta); + }, [delta, onDeltaChange]); + + const update = useCallback( + async (delta: Delta) => { + if (!controller) return; + await dispatch( + updateNodeDeltaThunk({ + id, + delta: delta.ops, + controller, + }) + ); + // reset pendding flag + penddingRef.current = false; + }, + [controller, dispatch, id] + ); + + return { + update, + delta, + }; +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/EditorHooks/useSelection.ts b/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/EditorHooks/useSelection.ts new file mode 100644 index 0000000000..7d6093f54d --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/EditorHooks/useSelection.ts @@ -0,0 +1,55 @@ +import { useCallback, useEffect, useState } from 'react'; +import { RangeStatic } from 'quill'; +import { useAppDispatch } from '$app/stores/store'; +import { rangeActions } from '$app_reducers/document/slice'; +import { useFocused, useRangeRef } from '$app/components/document/_shared/SubscribeSelection.hooks'; +import { storeRangeThunk } from '$app_reducers/document/async-actions/range'; + +export function useSelection(id: string) { + const rangeRef = useRangeRef(); + const { focusCaret, lastSelection } = useFocused(id); + const [selection, setSelection] = useState(undefined); + const dispatch = useAppDispatch(); + + const storeRange = useCallback( + (range: RangeStatic) => { + dispatch(storeRangeThunk({ id, range })); + }, + [id, dispatch] + ); + + const onSelectionChange = useCallback( + (range: RangeStatic | null, _oldRange: RangeStatic | null, _source?: string) => { + if (!range) return; + + dispatch( + rangeActions.setCaret({ + id, + index: range.index, + length: range.length, + }) + ); + storeRange(range); + }, + [id, dispatch, storeRange] + ); + + useEffect(() => { + if (rangeRef.current && rangeRef.current?.isDragging) return; + const caret = focusCaret; + if (!caret) { + return; + } + + setSelection({ + index: caret.index, + length: caret.length, + }); + }, [rangeRef, focusCaret]); + + return { + onSelectionChange, + selection, + lastSelection, + }; +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/QuillEditor/Editor.css b/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/QuillEditor/Editor.css new file mode 100644 index 0000000000..cf52b796ff --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/QuillEditor/Editor.css @@ -0,0 +1,23 @@ +.ql-container.ql-snow { + border: none; + font-family: 'Poppins', sans-serif; + font-size: inherit; + line-height: inherit; +} +.ql-editor { + outline: none; + max-width: 100%; + white-space: pre-wrap; + word-break: break-word; + padding: 4px 2px; + text-align: left; + flex-grow: 1; +} + +.ql-editor.ql-blank::before { + left: 2px; + right: 2px; + font-style: normal; +} + + diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/QuillEditor/Editor.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/QuillEditor/Editor.tsx new file mode 100644 index 0000000000..a4b203a215 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/QuillEditor/Editor.tsx @@ -0,0 +1,30 @@ +import React from 'react'; +import { useEditor } from '$app/components/document/_shared/QuillEditor/useEditor'; +import 'quill/dist/quill.snow.css'; +import './Editor.css'; +import { EditorProps } from '$app/interfaces/document'; + +function Editor({ + value, + onChange, + onSelectionChange, + selection, + placeholder = "Type '/' for commands", + ...props +}: EditorProps) { + const { ref, editor } = useEditor({ + value, + onChange, + onSelectionChange, + selection, + placeholder, + }); + return ( +
+
+ {!editor &&
{placeholder}
} +
+ ); +} + +export default React.memo(Editor); diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/QuillEditor/useEditor.ts b/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/QuillEditor/useEditor.ts new file mode 100644 index 0000000000..82bbd7b86b --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/QuillEditor/useEditor.ts @@ -0,0 +1,100 @@ +import { useEffect, useRef, useState } from 'react'; +import Quill, { Sources } from 'quill'; +import Delta from 'quill-delta'; +import { adaptDeltaForQuill } from '$app/utils/document/quill_editor'; +import { EditorProps } from '$app/interfaces/document'; + +/** + * Here we can use ts-ignore because the quill-delta's version of quill is not uploaded to DefinitelyTyped + */ +export function useEditor({ placeholder, value, onChange, onSelectionChange, selection }: EditorProps) { + const ref = useRef(null); + const [editor, setEditor] = useState(); + + useEffect(() => { + if (!ref.current) return; + const editor = new Quill(ref.current, { + modules: { + toolbar: false, // Snow includes toolbar by default + }, + theme: 'snow', + formats: ['bold', 'italic', 'underline', 'strike', 'code'], + placeholder: placeholder || 'Please enter some text...', + }); + const keyboard = editor.getModule('keyboard'); + // clear all keyboard bindings + keyboard.bindings = {}; + const initialDelta = new Delta(adaptDeltaForQuill(value?.ops || [])); + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + editor.setContents(initialDelta); + setEditor(editor); + }, []); + + // listen to text-change event + useEffect(() => { + if (!editor) return; + const onTextChange = (delta: Delta, oldContents: Delta, source: Sources) => { + const newContents = oldContents.compose(delta); + const newOps = adaptDeltaForQuill(newContents.ops, true); + const newDelta = new Delta(newOps); + onChange?.(newDelta, oldContents, source); + if (source === 'user') { + const selection = editor.getSelection(false); + onSelectionChange?.(selection, null, source); + } + }; + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + editor.on('text-change', onTextChange); + return () => { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + editor.off('text-change', onTextChange); + }; + }, [editor, onChange, onSelectionChange]); + + // listen to selection-change event + useEffect(() => { + const handleSelectionChange = () => { + if (!editor) return; + const selection = editor.getSelection(false); + onSelectionChange?.(selection, null, 'user'); + }; + document.addEventListener('selectionchange', handleSelectionChange); + return () => { + document.removeEventListener('selectionchange', handleSelectionChange); + }; + }, [editor, onSelectionChange]); + + // set value + useEffect(() => { + if (!editor) return; + const content = editor.getContents(); + + const newOps = adaptDeltaForQuill(value?.ops || []); + const newDelta = new Delta(newOps); + + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + const diffDelta = content.diff(newDelta); + const isSame = diffDelta.ops.length === 0; + if (isSame) return; + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + editor.updateContents(diffDelta, 'api'); + }, [editor, value]); + + // set Selection + useEffect(() => { + if (!editor || !selection) return; + if (JSON.stringify(selection) === JSON.stringify(editor.getSelection())) return; + + editor.setSelection(selection); + }, [selection, editor]); + + return { + ref, + editor, + }; +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SlateEditor/CodeEditor.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SlateEditor/CodeEditor.tsx new file mode 100644 index 0000000000..6feeaa68a8 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SlateEditor/CodeEditor.tsx @@ -0,0 +1,34 @@ +import React from 'react'; +import { CodeEditorProps } from '$app/interfaces/document'; +import { Editable, Slate } from 'slate-react'; +import { useEditor } from '$app/components/document/_shared/SlateEditor/useEditor'; +import { decorateCode } from '$app/components/document/_shared/SlateEditor/decorateCode'; +import { CodeLeaf, CodeBlockElement } from '$app/components/document/_shared/SlateEditor/CodeElements'; + +function CodeEditor({ language, ...props }: CodeEditorProps) { + const { editor, onChange, value, onDOMBeforeInput, decorate, ref, onKeyDown, onBlur } = useEditor({ + ...props, + isCodeBlock: true, + }); + + return ( +
+ + { + const codeRange = decorateCode(entry, language); + const range = decorate?.(entry) || []; + return [...range, ...codeRange]; + }} + renderLeaf={CodeLeaf} + renderElement={CodeBlockElement} + onKeyDown={onKeyDown} + onDOMBeforeInput={onDOMBeforeInput} + onBlur={onBlur} + /> + +
+ ); +} + +export default React.memo(CodeEditor); diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/CodeBlock/elements.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SlateEditor/CodeElements.tsx similarity index 87% rename from frontend/appflowy_tauri/src/appflowy_app/components/document/CodeBlock/elements.tsx rename to frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SlateEditor/CodeElements.tsx index 126a224a3a..8169420bfa 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/CodeBlock/elements.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SlateEditor/CodeElements.tsx @@ -5,10 +5,10 @@ interface CodeLeafProps extends RenderLeafProps { leaf: BaseText & { bold?: boolean; italic?: boolean; - underlined?: boolean; + underline?: boolean; strikethrough?: boolean; prism_token?: string; - selectionHighlighted?: boolean; + selection_high_lighted?: boolean; }; } @@ -24,7 +24,7 @@ export const CodeLeaf = (props: CodeLeafProps) => { newChildren = {newChildren}; } - if (leaf.underlined) { + if (leaf.underline) { newChildren = {newChildren}; } @@ -32,7 +32,7 @@ export const CodeLeaf = (props: CodeLeafProps) => { 'token', leaf.prism_token && leaf.prism_token, leaf.strikethrough && 'line-through', - leaf.selectionHighlighted && 'bg-main-secondary', + leaf.selection_high_lighted && 'bg-main-secondary', ].filter(Boolean); return ( diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SlateEditor/TextEditor.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SlateEditor/TextEditor.tsx new file mode 100644 index 0000000000..21683b6e9d --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SlateEditor/TextEditor.tsx @@ -0,0 +1,28 @@ +import React from 'react'; +import { EditorProps } from '$app/interfaces/document'; +import { Editable, Slate } from 'slate-react'; +import { useEditor } from '$app/components/document/_shared/SlateEditor/useEditor'; +import TextLeaf from '$app/components/document/_shared/SlateEditor/TextLeaf'; +import { TextElement } from '$app/components/document/_shared/SlateEditor/TextElement'; + +function TextEditor({ placeholder = "Type '/' for commands", ...props }: EditorProps) { + const { editor, onChange, value, onDOMBeforeInput, decorate, ref, onKeyDown, onBlur } = useEditor(props); + + return ( +
+ + + +
+ ); +} + +export default React.memo(TextEditor); diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SlateEditor/TextElement.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SlateEditor/TextElement.tsx new file mode 100644 index 0000000000..283e11fc74 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SlateEditor/TextElement.tsx @@ -0,0 +1,65 @@ +import { RenderElementProps } from 'slate-react'; +import React, { useEffect, useRef } from 'react'; + +export function TextElement(props: RenderElementProps) { + const ref = useRef(null); + useEffect(() => { + if (!ref.current) return; + amendCodeLeafs(ref.current); + }); + + return ( +
{ + ref.current = e; + props.attributes.ref(e); + }} + > + {props.children} +
+ ); +} + +function amendCodeLeafs(textElement: Element) { + const leafNodes = textElement.querySelectorAll(`[data-slate-leaf="true"]`); + let codeLeafNodes: Element[] = []; + leafNodes?.forEach((leafNode, index) => { + const isCodeLeaf = leafNode.classList.contains('inline-code'); + if (isCodeLeaf) { + codeLeafNodes.push(leafNode); + } else { + if (codeLeafNodes.length > 0) { + addStyleToCodeLeafs(codeLeafNodes); + codeLeafNodes = []; + } + } + if (codeLeafNodes.length > 0 && index === leafNodes.length - 1) { + addStyleToCodeLeafs(codeLeafNodes); + codeLeafNodes = []; + } + }); +} + +function addStyleToCodeLeafs(codeLeafs: Element[]) { + if (codeLeafs.length === 0) return; + if (codeLeafs.length === 1) { + const codeNode = codeLeafs[0].firstChild as Element; + codeNode.classList.add('rounded', 'px-1.5'); + return; + } + codeLeafs.forEach((codeLeaf, index) => { + const codeNode = codeLeaf.firstChild as Element; + codeNode.classList.remove('rounded', 'px-1.5'); + codeNode.classList.remove('rounded-l', 'pl-1.5'); + codeNode.classList.remove('rounded-r', 'pr-1.5'); + if (index === 0) { + codeNode.classList.add('rounded-l', 'pl-1.5'); + return; + } + if (index === codeLeafs.length - 1) { + codeNode.classList.add('rounded-r', 'pr-1.5'); + return; + } + }); +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SlateEditor/TextLeaf.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SlateEditor/TextLeaf.tsx new file mode 100644 index 0000000000..b463547f9e --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SlateEditor/TextLeaf.tsx @@ -0,0 +1,61 @@ +import { RenderLeafProps } from 'slate-react'; +import { BaseText } from 'slate'; +import { useRef } from 'react'; + +interface TextLeafProps extends RenderLeafProps { + leaf: BaseText & { + bold?: boolean; + italic?: boolean; + underline?: boolean; + strikethrough?: boolean; + code?: string; + selection_high_lighted?: boolean; + }; +} + +const TextLeaf = (props: TextLeafProps) => { + const { attributes, children, leaf } = props; + + const ref = useRef(null); + + let newChildren = children; + if (leaf.bold) { + newChildren = {children}; + } + + if (leaf.italic) { + newChildren = {newChildren}; + } + + if (leaf.underline) { + newChildren = {newChildren}; + } + + if (leaf.code) { + newChildren = ( + + {newChildren} + + ); + } + + const className = [ + leaf.strikethrough && 'line-through', + leaf.selection_high_lighted && 'bg-main-secondary', + leaf.code && 'inline-code', + ].filter(Boolean); + + return ( + + {newChildren} + + ); +}; + +export default TextLeaf; diff --git a/frontend/appflowy_tauri/src/appflowy_app/utils/document/blocks/code/decorate.ts b/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SlateEditor/decorateCode.ts similarity index 63% rename from frontend/appflowy_tauri/src/appflowy_app/utils/document/blocks/code/decorate.ts rename to frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SlateEditor/decorateCode.ts index 81e7ff7423..b772bd622a 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/utils/document/blocks/code/decorate.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SlateEditor/decorateCode.ts @@ -1,34 +1,11 @@ import Prism from 'prismjs'; import 'prismjs/themes/prism.css'; -import 'prismjs/components/prism-bash'; -import 'prismjs/components/prism-c'; -import 'prismjs/components/prism-cpp'; -import 'prismjs/components/prism-csharp'; -import 'prismjs/components/prism-css'; -import 'prismjs/components/prism-dart'; -import 'prismjs/components/prism-docker'; -import 'prismjs/components/prism-go'; -import 'prismjs/components/prism-graphql'; -import 'prismjs/components/prism-groovy'; -import 'prismjs/components/prism-http'; -import 'prismjs/components/prism-java'; import 'prismjs/components/prism-javascript'; import 'prismjs/components/prism-json'; -import 'prismjs/components/prism-less'; import 'prismjs/components/prism-typescript'; -import 'prismjs/components/prism-markdown'; -import 'prismjs/components/prism-python'; -import 'prismjs/components/prism-yaml'; -import 'prismjs/components/prism-regex'; -import 'prismjs/components/prism-ruby'; import 'prismjs/components/prism-rust'; -import 'prismjs/components/prism-sass'; -import 'prismjs/components/prism-swift'; -import 'prismjs/components/prism-php'; -import 'prismjs/components/prism-sql'; -import 'prismjs/components/prism-visual-basic'; -import { BaseRange, NodeEntry, Text, Path, Range, Editor } from 'slate'; +import { BaseRange, NodeEntry, Text, Path } from 'slate'; const push_string = ( token: string | Prism.Token, @@ -75,7 +52,7 @@ const recurseTokenize = ( } }; -export const decorateCodeFunc = ([node, path]: NodeEntry, language: string) => { +export const decorateCode = ([node, path]: NodeEntry, language: string) => { const ranges: BaseRange[] = []; if (!Text.isText(node)) { return ranges; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SlateEditor/useEditor.ts b/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SlateEditor/useEditor.ts new file mode 100644 index 0000000000..ba1bb6af14 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SlateEditor/useEditor.ts @@ -0,0 +1,142 @@ +import { EditorProps } from "$app/interfaces/document"; +import { useCallback, useEffect, useMemo, useRef } from "react"; +import { ReactEditor } from "slate-react"; +import { BaseRange, Descendant, Editor, NodeEntry, Range, Selection } from "slate"; +import { + converToIndexLength, + convertToDelta, + convertToSlateSelection, + indent, + outdent +} from "$app/utils/document/slate_editor"; +import { focusNodeByIndex } from "$app/utils/document/node"; +import { Keyboard } from "$app/constants/document/keyboard"; +import Delta from "quill-delta"; +import isHotkey from "is-hotkey"; +import { useSlateYjs } from "$app/components/document/_shared/SlateEditor/useSlateYjs"; + +export function useEditor({ + onChange, + onSelectionChange, + selection, + value: delta, + lastSelection, + onKeyDown, + isCodeBlock, +}: EditorProps) { + const editor = useSlateYjs({ delta }); + const ref = useRef(null); + + const newValue = useMemo(() => [], []); + const onSelectionChangeHandler = useCallback( + (slateSelection: Selection) => { + const rangeStatic = converToIndexLength(editor, slateSelection); + onSelectionChange?.(rangeStatic, null); + }, + [editor, onSelectionChange] + ); + + const onChangeHandler = useCallback( + (slateValue: Descendant[]) => { + const oldContents = delta || new Delta(); + onChange?.(convertToDelta(slateValue), oldContents); + onSelectionChangeHandler(editor.selection); + }, + [delta, editor.selection, onChange, onSelectionChangeHandler] + ); + + const onDOMBeforeInput = useCallback((e: InputEvent) => { + // COMPAT: in Apple, `compositionend` is dispatched after the `beforeinput` for "insertFromComposition". + // It will cause repeated characters when inputting Chinese. + // Here, prevent the beforeInput event and wait for the compositionend event to take effect. + if (e.inputType === 'insertFromComposition') { + e.preventDefault(); + } + }, []); + + const decorate = useCallback( + (entry: NodeEntry) => { + const [node, path] = entry; + if (!lastSelection) return []; + const slateSelection = convertToSlateSelection(lastSelection.index, lastSelection.length, editor.children); + if (slateSelection && !Range.isCollapsed(slateSelection as BaseRange)) { + const intersection = Range.intersection(slateSelection, Editor.range(editor, path)); + + if (!intersection) { + return []; + } + const range = { + selection_high_lighted: true, + ...intersection, + }; + + return [range]; + } + return []; + }, + [editor, lastSelection] + ); + + const onKeyDownRewrite = useCallback( + (event: React.KeyboardEvent) => { + onKeyDown?.(event); + const insertBreak = () => { + event.preventDefault(); + editor.insertText('\n'); + }; + // There is different behavior for code block and normal text + // In code block, we press enter to insert a new line + // In normal text, we press shift + enter to insert a new line + if (isCodeBlock) { + if (isHotkey(Keyboard.keys.ENTER, event)) { + insertBreak(); + return; + } + if (isHotkey(Keyboard.keys.TAB, event)) { + event.preventDefault(); + indent(editor, 2); + return; + } + if (isHotkey(Keyboard.keys.SHIFT_TAB, event)) { + event.preventDefault(); + outdent(editor, 2); + return; + } + } else if (isHotkey(Keyboard.keys.SHIFT_ENTER, event)) { + insertBreak(); + } + }, + [editor, onKeyDown, isCodeBlock] + ); + + + const onBlur = useCallback( + (_event: React.FocusEvent) => { + editor.deselect(); + }, + [editor] + ); + + useEffect(() => { + if (!selection || !ref.current) return; + const slateSelection = convertToSlateSelection(selection.index, selection.length, editor.children); + if (!slateSelection) return; + const isFocused = ReactEditor.isFocused(editor); + + if (isFocused && JSON.stringify(slateSelection) === JSON.stringify(editor.selection)) return; + + focusNodeByIndex(ref.current, selection.index, selection.length); + }, [editor, selection]); + + return { + editor, + value: newValue, + onChange: onChangeHandler, + onDOMBeforeInput, + decorate, + ref, + onKeyDown: onKeyDownRewrite, + onBlur, + }; +} + diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SlateEditor/useSlateYjs.ts b/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SlateEditor/useSlateYjs.ts new file mode 100644 index 0000000000..dd696efb44 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SlateEditor/useSlateYjs.ts @@ -0,0 +1,43 @@ +import Delta from "quill-delta"; +import { useEffect, useMemo, useRef } from "react"; +import * as Y from "yjs"; +import { convertToSlateValue } from "$app/utils/document/slate_editor"; +import { slateNodesToInsertDelta, withYjs, YjsEditor } from "@slate-yjs/core"; +import { withReact } from "slate-react"; +import { createEditor } from "slate"; + +export function useSlateYjs({ delta }: { delta?: Delta }) { + const yTextRef = useRef(); + const sharedType = useMemo(() => { + const yDoc = new Y.Doc(); + const sharedType = yDoc.get("content", Y.XmlText) as Y.XmlText; + const value = convertToSlateValue(delta || new Delta()); + const insertDelta = slateNodesToInsertDelta(value); + sharedType.applyDelta(insertDelta); + yTextRef.current = insertDelta[0].insert as Y.Text; + return sharedType; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const editor = useMemo(() => withYjs(withReact(createEditor()), sharedType), []); + + // Connect editor in useEffect to comply with concurrent mode requirements. + useEffect(() => { + YjsEditor.connect(editor); + return () => { + yTextRef.current = undefined; + YjsEditor.disconnect(editor); + }; + }, [editor]); + + useEffect(() => { + const yText = yTextRef.current; + if (!yText) return; + const oldContents = new Delta(yText.toDelta()); + const diffDelta = oldContents.diff(delta || new Delta()); + if (diffDelta.ops.length === 0) return; + yText.applyDelta(diffDelta.ops); + }, [delta, editor]); + + return editor; +} \ No newline at end of file diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SubscribeNode.hooks.ts b/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SubscribeNode.hooks.ts index e159b73b11..a4aaf27920 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SubscribeNode.hooks.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SubscribeNode.hooks.ts @@ -1,8 +1,6 @@ -import { useAppSelector } from '@/appflowy_app/stores/store'; -import { useMemo, useRef } from 'react'; -import { DocumentState, Node, RangeSelectionState } from '$app/interfaces/document'; -import { nodeInRange } from '$app/utils/document/blocks/common'; -import { getNodeEndSelection } from '$app/utils/document/blocks/text/delta'; +import { store, useAppSelector } from '@/appflowy_app/stores/store'; +import { useEffect, useMemo, useRef } from 'react'; +import { Node } from '$app/interfaces/document'; /** * Subscribe node information @@ -34,55 +32,6 @@ export function useSubscribeNode(id: string) { }; } -/** - * Subscribe selection information - * @param id - */ -export function useSubscribeRangeSelection(id: string) { - const rangeRef = useRef(); - - const currentSelection = useAppSelector((state) => { - const range = state.documentRangeSelection; - rangeRef.current = range; - if (range.anchor?.id === id) { - return range.anchor.selection; - } - if (range.focus?.id === id) { - return range.focus.selection; - } - - return getAmendInRangeNodeSelection(id, range, state.document); - }); - - return { - rangeRef, - currentSelection, - }; -} - -function getAmendInRangeNodeSelection(id: string, range: RangeSelectionState, document: DocumentState) { - if (!range.anchor || !range.focus || range.anchor.id === range.focus.id || range.isForward === undefined) { - return null; - } - - const isNodeInRange = nodeInRange( - id, - { - startId: range.anchor.id, - endId: range.focus.id, - }, - range.isForward, - document - ); - - if (isNodeInRange) { - const delta = document.nodes[id].data.delta; - return { - anchor: { - path: [0, 0], - offset: 0, - }, - focus: getNodeEndSelection(delta).anchor, - }; - } +export function getBlock(id: string) { + return store.getState().document.nodes[id]; } diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SubscribeSelection.hooks.ts b/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SubscribeSelection.hooks.ts new file mode 100644 index 0000000000..27aa9bd1ee --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SubscribeSelection.hooks.ts @@ -0,0 +1,43 @@ +import { useAppSelector } from '$app/stores/store'; +import { RangeState, RangeStatic } from '$app/interfaces/document'; +import { useMemo, useRef } from 'react'; + +export function useFocused(id: string) { + const caretRef = useRef(); + const focusCaret = useAppSelector((state) => { + const currentCaret = state.documentRange.caret; + caretRef.current = currentCaret; + if (currentCaret?.id === id) { + return currentCaret; + } + return null; + }); + + const lastSelection = useAppSelector((state) => { + return state.documentRange.ranges[id]; + }); + + const focused = useMemo(() => { + return focusCaret && focusCaret?.id === id; + }, [focusCaret, id]); + + const memoizedLastSelection = useMemo(() => { + return lastSelection; + }, [JSON.stringify(lastSelection)]); + + return { + focused, + caretRef, + focusCaret, + lastSelection: memoizedLastSelection, + }; +} + +export function useRangeRef() { + const rangeRef = useRef(); + useAppSelector((state) => { + const currentRange = state.documentRange; + rangeRef.current = currentRange; + }); + return rangeRef; +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/Text/TextEvents.hooks.ts b/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/Text/TextEvents.hooks.ts deleted file mode 100644 index f6d878a557..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/Text/TextEvents.hooks.ts +++ /dev/null @@ -1,111 +0,0 @@ -import { useAppDispatch } from '$app/stores/store'; -import { useCallback, useContext } from 'react'; -import { DocumentControllerContext } from '$app/stores/effects/document/document_controller'; -import { backspaceNodeThunk, setCursorNextLineThunk, setCursorPreLineThunk } from '$app_reducers/document/async-actions'; -import { keyBoardEventKeyMap } from '$app/constants/document/text_block'; -import { - canHandleBackspaceKey, - canHandleDownKey, - canHandleLeftKey, - canHandleRightKey, - canHandleUpKey, -} from '$app/utils/document/blocks/text/hotkey'; -import { TextBlockKeyEventHandlerParams } from '$app/interfaces/document'; -import { ReactEditor } from 'slate-react'; - -export function useDefaultTextInputEvents(id: string) { - const dispatch = useAppDispatch(); - const controller = useContext(DocumentControllerContext); - - const focusPreLineAction = useCallback( - async (params: { editor: ReactEditor; focusEnd?: boolean }) => { - await dispatch(setCursorPreLineThunk({ id, ...params })); - }, - [dispatch, id] - ); - - const focusNextLineAction = useCallback( - async (params: { editor: ReactEditor; focusStart?: boolean }) => { - await dispatch(setCursorNextLineThunk({ id, ...params })); - }, - [dispatch, id] - ); - return [ - { - triggerEventKey: keyBoardEventKeyMap.Up, - canHandle: canHandleUpKey, - handler: (...args: TextBlockKeyEventHandlerParams) => { - const [e, _] = args; - e.preventDefault(); - void focusPreLineAction({ - editor: args[1], - }); - }, - }, - { - triggerEventKey: keyBoardEventKeyMap.Down, - canHandle: canHandleDownKey, - handler: (...args: TextBlockKeyEventHandlerParams) => { - const [e, _] = args; - e.preventDefault(); - void focusNextLineAction({ - editor: args[1], - }); - }, - }, - { - triggerEventKey: keyBoardEventKeyMap.Left, - canHandle: canHandleLeftKey, - handler: (...args: TextBlockKeyEventHandlerParams) => { - const [e, _] = args; - e.preventDefault(); - void focusPreLineAction({ - editor: args[1], - focusEnd: true, - }); - }, - }, - { - triggerEventKey: keyBoardEventKeyMap.Right, - canHandle: canHandleRightKey, - handler: (...args: TextBlockKeyEventHandlerParams) => { - const [e, _] = args; - e.preventDefault(); - void focusNextLineAction({ - editor: args[1], - focusStart: true, - }); - }, - }, - { - triggerEventKey: keyBoardEventKeyMap.Backspace, - canHandle: canHandleBackspaceKey, - handler: (...args: TextBlockKeyEventHandlerParams) => { - const [e, editor] = args; - e.preventDefault(); - void (async () => { - if (!controller) return; - await dispatch(backspaceNodeThunk({ id, controller, editor })); - })(); - }, - }, - // Here prevent the default behavior of the enter key - { - triggerEventKey: keyBoardEventKeyMap.Enter, - canHandle: (...args: TextBlockKeyEventHandlerParams) => args[0].key === 'Enter', - handler: (...args: TextBlockKeyEventHandlerParams) => { - const [e] = args; - e.preventDefault(); - }, - }, - // Here prevent the default behavior of the tab key - { - triggerEventKey: keyBoardEventKeyMap.Tab, - canHandle: (...args: TextBlockKeyEventHandlerParams) => args[0].key === 'Tab', - handler: (...args: TextBlockKeyEventHandlerParams) => { - const [e] = args; - e.preventDefault(); - }, - }, - ]; -} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/Text/TextInput.hooks.ts b/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/Text/TextInput.hooks.ts deleted file mode 100644 index 635976f621..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/Text/TextInput.hooks.ts +++ /dev/null @@ -1,134 +0,0 @@ -import { createEditor, Descendant, Editor, Transforms } from 'slate'; -import { withReact } from 'slate-react'; -import { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'; - -import { DocumentControllerContext } from '$app/stores/effects/document/document_controller'; -import { TextDelta } from '$app/interfaces/document'; -import { useAppDispatch } from '$app/stores/store'; -import { updateNodeDeltaThunk } from '$app_reducers/document/async-actions/blocks/text/update'; -import { deltaToSlateValue, slateValueToDelta } from '$app/utils/document/blocks/common'; -import { isSameDelta } from '$app/utils/document/blocks/text/delta'; -import { useSubscribeNode } from '$app/components/document/_shared/SubscribeNode.hooks'; -import { useTextSelections } from '$app/components/document/_shared/Text/TextSelection.hooks'; - -export function useTextInput(id: string) { - const { node } = useSubscribeNode(id); - - const [editor] = useState(() => withReact(createEditor())); - const isComposition = useRef(false); - const { setLastActiveSelection, ...selectionProps } = useTextSelections(id, editor); - - const delta = useMemo(() => { - if (!node || !('delta' in node.data)) { - return []; - } - return node.data.delta; - }, [node]); - const [value, setValue] = useState(deltaToSlateValue(delta)); - - const { sync, receive } = useUpdateDelta(id, editor); - - // Update the editor's value when the node's delta changes. - useEffect(() => { - // If composition is in progress, do nothing. - if (isComposition.current) return; - receive(delta, setValue); - }, [delta, receive]); - - // Update the node's delta when the editor's value changes. - const onChange = useCallback( - (e: Descendant[]) => { - // Update the editor's value and selection. - setValue(e); - // If the selection is not null, update the last active selection. - if (editor.selection !== null) setLastActiveSelection(editor.selection); - // If composition is in progress, do nothing. - if (isComposition.current) return; - sync(); - }, - [editor.selection, setLastActiveSelection, sync] - ); - - const onDOMBeforeInput = useCallback((e: InputEvent) => { - // COMPAT: in Apple, `compositionend` is dispatched after the `beforeinput` for "insertFromComposition". - // It will cause repeated characters when inputting Chinese. - // Here, prevent the beforeInput event and wait for the compositionend event to take effect. - if (e.inputType === 'insertFromComposition') { - e.preventDefault(); - } - }, []); - - const onCompositionStart = useCallback(() => { - isComposition.current = true; - }, []); - - const onCompositionUpdate = useCallback(() => { - isComposition.current = true; - }, []); - - const onCompositionEnd = useCallback(() => { - isComposition.current = false; - }, []); - - return { - editor, - onChange, - value, - ...selectionProps, - onDOMBeforeInput, - onCompositionStart, - onCompositionUpdate, - onCompositionEnd, - }; -} - -function useUpdateDelta(id: string, editor: Editor) { - const controller = useContext(DocumentControllerContext); - const dispatch = useAppDispatch(); - const penddingRef = useRef(false); - - const update = useCallback(() => { - if (!controller) return; - const delta = slateValueToDelta(editor.children); - void (async () => { - await dispatch( - updateNodeDeltaThunk({ - id, - delta, - controller, - }) - ); - // reset pendding flag - penddingRef.current = false; - })(); - }, [controller, dispatch, editor, id]); - - const sync = useCallback(() => { - // set pendding flag - penddingRef.current = true; - update(); - }, [update]); - - const receive = useCallback( - (delta: TextDelta[], setValue: (children: Descendant[]) => void) => { - // if pendding, do nothing - if (penddingRef.current) return; - - // If the delta is the same as the editor's value, do nothing. - const localDelta = slateValueToDelta(editor.children); - const isSame = isSameDelta(delta, localDelta); - if (isSame) return; - - Transforms.deselect(editor); - const slateValue = deltaToSlateValue(delta); - editor.children = slateValue; - setValue(slateValue); - }, - [editor] - ); - - return { - sync, - receive, - }; -} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/Text/TextSelection.hooks.ts b/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/Text/TextSelection.hooks.ts deleted file mode 100644 index 234bcf4837..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/Text/TextSelection.hooks.ts +++ /dev/null @@ -1,98 +0,0 @@ -import { MouseEvent, useCallback, useEffect, useRef } from 'react'; -import { BaseRange, Editor, Node, Path, Range, Transforms } from 'slate'; -import { EditableProps } from 'slate-react/dist/components/editable'; -import { useSubscribeRangeSelection } from '$app/components/document/_shared/SubscribeNode.hooks'; -import { useAppDispatch } from '$app/stores/store'; -import { TextSelection } from '$app/interfaces/document'; -import { ReactEditor } from 'slate-react'; -import { syncRangeSelectionThunk } from '$app_reducers/document/async-actions/range_selection'; -import { getNodeEndSelection } from '$app/utils/document/blocks/text/delta'; -import { slateValueToDelta } from '$app/utils/document/blocks/common'; -import { isEqual } from '$app/utils/tool'; - -export function useTextSelections(id: string, editor: ReactEditor) { - const { rangeRef, currentSelection } = useSubscribeRangeSelection(id); - const dispatch = useAppDispatch(); - - useEffect(() => { - if (!rangeRef.current) return; - if (!currentSelection) { - ReactEditor.deselect(editor); - ReactEditor.blur(editor); - return; - } - - const { isDragging, focus } = rangeRef.current; - if (isDragging || focus?.id !== id) return; - if (!ReactEditor.isFocused(editor)) { - ReactEditor.focus(editor); - } - if (!isEqual(editor.selection, currentSelection)) { - Transforms.select(editor, currentSelection); - } - }, [currentSelection, editor, id, rangeRef]); - - const decorate: EditableProps['decorate'] = useCallback( - (entry: [Node, Path]) => { - const [node, path] = entry; - - if (currentSelection && !Range.isCollapsed(currentSelection as BaseRange)) { - const intersection = Range.intersection(currentSelection, Editor.range(editor, path)); - - if (!intersection) { - return []; - } - const range = { - selectionHighlighted: true, - ...intersection, - }; - - return [range]; - } - return []; - }, - [editor, currentSelection] - ); - - const setLastActiveSelection = useCallback( - (lastActiveSelection: Range) => { - const selection = lastActiveSelection as TextSelection; - dispatch(syncRangeSelectionThunk({ id, selection })); - }, - [dispatch, id] - ); - - const onBlur = useCallback(() => { - ReactEditor.deselect(editor); - }, [editor]); - - const onMouseMove = useCallback( - (e: MouseEvent) => { - if (!rangeRef.current) return; - const { isDragging, isForward, anchor } = rangeRef.current; - if (!isDragging || !anchor) return; - if (ReactEditor.isFocused(editor)) { - return; - } - - if (anchor.id === id) { - Transforms.select(editor, anchor.selection); - } else if (!isForward) { - const endSelection = getNodeEndSelection(slateValueToDelta(editor.children)); - Transforms.select(editor, { - anchor: endSelection.anchor, - focus: editor.selection?.focus || endSelection.focus, - }); - } - ReactEditor.focus(editor); - }, - [editor, id, rangeRef] - ); - - return { - decorate, - onBlur, - onMouseMove, - setLastActiveSelection, - }; -} diff --git a/frontend/appflowy_tauri/src/appflowy_app/constants/document/code.ts b/frontend/appflowy_tauri/src/appflowy_app/constants/document/code.ts index 8b443763fc..d2f4ba0033 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/constants/document/code.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/constants/document/code.ts @@ -1,12 +1,4 @@ export const supportLanguage = [ - { - id: 'css', - title: 'CSS', - }, - { - id: 'html', - title: 'HTML', - }, { id: 'javascript', title: 'JavaScript', @@ -15,78 +7,13 @@ export const supportLanguage = [ id: 'json', title: 'JSON', }, - { - id: 'markdown', - title: 'Markdown', - }, - { - id: 'python', - title: 'Python', - }, { id: 'typescript', title: 'TypeScript', }, - { - id: 'xml', - title: 'XML', - }, - { - id: 'yaml', - title: 'YAML', - }, - { - id: 'bash', - title: 'Bash', - }, - { - id: 'c', - title: 'C', - }, - { - id: 'cpp', - title: 'C++', - }, - { - id: 'csharp', - title: 'C#', - }, - { - id: 'go', - title: 'Go', - }, - { - id: 'java', - title: 'Java', - }, - { - id: 'php', - title: 'PHP', - }, - { - id: 'ruby', - title: 'Ruby', - }, { id: 'rust', title: 'Rust', }, - - { - id: 'swift', - title: 'Swift', - }, - { - id: 'sql', - title: 'SQL', - }, - { - id: 'vb', - title: 'Visual Basic', - }, - { - id: 'dart', - title: 'Dart', - }, ]; diff --git a/frontend/appflowy_tauri/src/appflowy_app/constants/document/keyboard.ts b/frontend/appflowy_tauri/src/appflowy_app/constants/document/keyboard.ts new file mode 100644 index 0000000000..7fc04d42df --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/constants/document/keyboard.ts @@ -0,0 +1,32 @@ +export const Keyboard = { + codes: { + BACKSPACE: 8, + TAB: 9, + ENTER: 13, + ESCAPE: 27, + SPACE: 32, + LEFT: 37, + UP: 38, + RIGHT: 39, + DOWN: 40, + DELETE: 46, + }, + keys: { + BACKSPACE: 'Backspace', + TAB: 'Tab', + ENTER: 'Enter', + ESCAPE: 'Escape', + SPACE: ' ', + LEFT: 'ArrowLeft', + UP: 'ArrowUp', + RIGHT: 'ArrowRight', + DOWN: 'ArrowDown', + DELETE: 'Delete', + SHIFT_ENTER: 'Shift+Enter', + SHIFT_TAB: 'Shift+Tab', + Slash: '/', + Space: ' ', + Reduce: '-', + BackQuote: '`', + }, +}; diff --git a/frontend/appflowy_tauri/src/appflowy_app/constants/document/text_block.ts b/frontend/appflowy_tauri/src/appflowy_app/constants/document/text_block.ts deleted file mode 100644 index 067b28aa03..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/constants/document/text_block.ts +++ /dev/null @@ -1,13 +0,0 @@ -export const keyBoardEventKeyMap = { - Enter: 'Enter', - Backspace: 'Backspace', - Tab: 'Tab', - Up: 'ArrowUp', - Down: 'ArrowDown', - Left: 'ArrowLeft', - Right: 'ArrowRight', - Space: ' ', - Reduce: '-', - Backquote: '`', - Slash: '/', -}; diff --git a/frontend/appflowy_tauri/src/appflowy_app/interfaces/document.ts b/frontend/appflowy_tauri/src/appflowy_app/interfaces/document.ts index 74332d6b92..1bdda2c086 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/interfaces/document.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/interfaces/document.ts @@ -1,6 +1,13 @@ -import { Editor } from 'slate'; -import { RegionGrid } from '$app/utils/region_grid'; -import { ReactEditor } from 'slate-react'; +import Delta, { Op } from 'quill-delta'; +import { BlockActionTypePB } from '@/services/backend'; +import { Sources } from 'quill'; +import React from 'react'; + +export interface RangeStatic { + id: string; + length: number; + index: number; +} export enum BlockType { PageBlock = 'page', @@ -50,7 +57,7 @@ export interface CalloutBlockData extends TextBlockData { } export interface TextBlockData { - delta: TextDelta[]; + delta: Op[]; } export interface DividerBlockData {} @@ -86,38 +93,9 @@ export interface NestedBlock { parent: string | null; children: string; } -export interface TextDelta { - insert: string; - attributes?: Record; -} - -export enum BlockActionType { - Insert = 0, - Update = 1, - Delete = 2, - Move = 3, -} - -export interface DeltaItem { - action: 'inserted' | 'removed' | 'updated'; - payload: { - id: string; - value?: NestedBlock | string[]; - }; -} export type Node = NestedBlock; -export interface SelectionPoint { - path: [number, number]; - offset: number; -} - -export interface TextSelection { - anchor: SelectionPoint; - focus: SelectionPoint; -} - export interface DocumentData { rootId: string; // map of block id to block @@ -140,17 +118,35 @@ export interface RectSelectionState { selection: string[]; isDragging: boolean; } -export interface RangeSelectionState { - anchor?: PointState; - focus?: PointState; - isForward?: boolean; - isDragging: boolean; - selection: string[]; -} -export interface PointState { - id: string; - selection: TextSelection; +export interface RangeState { + anchor?: { + id: string; + point: { + x: number; + y: number; + index?: number; + length?: number; + }; + }; + focus?: { + id: string; + point: { + x: number; + y: number; + }; + }; + ranges: Partial< + Record< + string, + { + index: number; + length: number; + } + > + >; + isDragging: boolean; + caret?: RangeStatic; } export enum ChangeType { @@ -170,8 +166,6 @@ export interface BlockPBValue { data: string; } -export type TextBlockKeyEventHandlerParams = [React.KeyboardEvent, ReactEditor & Editor]; - export enum SplitRelationship { NextSibling, FirstChild, @@ -180,7 +174,7 @@ export enum TextAction { Turn = 'turn', Bold = 'bold', Italic = 'italic', - Underline = 'underlined', + Underline = 'underline', Strikethrough = 'strikethrough', Code = 'code', Equation = 'equation', @@ -230,3 +224,31 @@ export interface BlockConfig { */ textActionMenuProps?: TextActionMenuProps; } + +export interface ControllerAction { + action: BlockActionTypePB; + payload: { + block: { id: string; parent_id: string; children_id: string; data: string; ty: BlockType }; + parent_id: string; + prev_id: string; + }; +} + +export interface RangeStaticNoId { + index: number; + length: number; +} + +export interface CodeEditorProps extends EditorProps { + language: string; +} +export interface EditorProps { + isCodeBlock?: boolean; + placeholder?: string; + value?: Delta; + selection?: RangeStaticNoId; + lastSelection?: RangeStaticNoId; + onSelectionChange?: (range: RangeStaticNoId | null, oldRange: RangeStaticNoId | null, source?: Sources) => void; + onChange?: (delta: Delta, oldDelta: Delta, source?: Sources) => void; + onKeyDown?: (event: React.KeyboardEvent) => void; +} 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 f99d922eb9..6158b4aabd 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 @@ -14,7 +14,7 @@ import { DocumentObserver } from './document_observer'; 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 { blockPB2Node } from '$app/utils/document/block'; import { Log } from '$app/utils/log'; export const DocumentControllerContext = createContext(null); diff --git a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/duplicate.ts b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/duplicate.ts index 668f698095..d66e6c15e9 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/duplicate.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/duplicate.ts @@ -1,22 +1,23 @@ import { DocumentState } from '$app/interfaces/document'; import { DocumentController } from '$app/stores/effects/document/document_controller'; import { createAsyncThunk } from '@reduxjs/toolkit'; -import { newBlock } from '$app/utils/document/blocks/common'; +import { newBlock } from '$app/utils/document/block'; +import { rectSelectionActions } from '$app_reducers/document/slice'; +import { getDuplicateActions } from '$app/utils/document/action'; export const duplicateBelowNodeThunk = createAsyncThunk( 'document/duplicateBelowNode', async (payload: { id: string; controller: DocumentController }, thunkAPI) => { const { id, controller } = payload; - const { getState } = thunkAPI; + const { getState, dispatch } = thunkAPI; const state = getState() as { document: DocumentState }; const node = state.document.nodes[id]; - if (!node) return; - const parentId = node.parent; - if (!parentId) return; - // duplicate new node - const newNode = newBlock(node.type, parentId, node.data); - await controller.applyActions([controller.getInsertAction(newNode, node.id)]); + if (!node || !node.parent) return; + const duplicateActions = getDuplicateActions(id, node.parent, state.document, controller); - return newNode.id; + if (!duplicateActions) return; + await controller.applyActions(duplicateActions.actions); + + dispatch(rectSelectionActions.updateSelections([duplicateActions.newNodeId])); } ); 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/indent.ts similarity index 91% rename from frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/text/indent.ts rename to frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/indent.ts index 2e8e46bd35..11fc4bb583 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/indent.ts @@ -2,7 +2,7 @@ import { DocumentState } from '$app/interfaces/document'; import { DocumentController } from '$app/stores/effects/document/document_controller'; import { createAsyncThunk } from '@reduxjs/toolkit'; import { blockConfig } from '$app/constants/document/config'; -import { getPrevNodeId } from "$app/utils/document/blocks/common"; +import { getPrevNodeId } from '$app/utils/document/block'; /** * indent node @@ -33,7 +33,7 @@ export const indentNodeThunk = createAsyncThunk( const newPrevId = newParentChildren[newParentChildren.length - 1]; const moveAction = controller.getMoveAction(node, newParentNode.id, newPrevId); - const childrenNodes = state.children[node.children].map(id => state.nodes[id]); + const childrenNodes = state.children[node.children].map((id) => state.nodes[id]); const moveChildrenActions = controller.getMoveChildrenAction(childrenNodes, newParentNode.id, node.id); await controller.applyActions([moveAction, ...moveChildrenActions]); 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 index 1c2c2ce75c..7af8592ad1 100644 --- 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 @@ -1,4 +1,7 @@ -export * from './text'; export * from './delete'; export * from './duplicate'; export * from './insert'; +export * from './merge'; +export * from './update'; +export * from './indent'; +export * from './outdent'; diff --git a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/insert.ts b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/insert.ts index 0a7a6a3fdb..58b1d2d082 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/insert.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/insert.ts @@ -1,7 +1,7 @@ import { BlockData, BlockType, DocumentState } from '$app/interfaces/document'; import { DocumentController } from '$app/stores/effects/document/document_controller'; import { createAsyncThunk } from '@reduxjs/toolkit'; -import { newBlock } from '$app/utils/document/blocks/common'; +import { newBlock } from '$app/utils/document/block'; export const insertAfterNodeThunk = createAsyncThunk( 'document/insertAfterNode', @@ -21,8 +21,17 @@ export const insertAfterNodeThunk = createAsyncThunk( if (!parentId) return; // create new node const newNode = newBlock(type, parentId, data); - await controller.applyActions([controller.getInsertAction(newNode, node.id)]); + let nodeId = newNode.id; + const actions = [controller.getInsertAction(newNode, node.id)]; + if (type === BlockType.DividerBlock) { + const newTextNode = newBlock(BlockType.TextBlock, parentId, { + delta: [], + }); + nodeId = newTextNode.id; + actions.push(controller.getInsertAction(newTextNode, newNode.id)); + } + await controller.applyActions(actions); - return newNode.id; + return nodeId; } ); diff --git a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/merge.ts b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/merge.ts new file mode 100644 index 0000000000..6350efdc55 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/merge.ts @@ -0,0 +1,49 @@ +import { createAsyncThunk } from '@reduxjs/toolkit'; +import { DocumentController } from '$app/stores/effects/document/document_controller'; +import { DocumentState } from '$app/interfaces/document'; +import Delta from 'quill-delta'; +import { blockConfig } from '$app/constants/document/config'; + +/** + * Merge two blocks + * 1. merge delta + * 2. move children + * 3. delete current block + */ +export const mergeDeltaThunk = createAsyncThunk( + 'document/mergeDelta', + async (payload: { sourceId: string; targetId: string; controller: DocumentController }, thunkAPI) => { + const { sourceId, targetId, controller } = payload; + const { getState } = thunkAPI; + const state = (getState() as { document: DocumentState }).document; + const target = state.nodes[targetId]; + const source = state.nodes[sourceId]; + if (!target || !source) return; + const targetDelta = new Delta(target.data.delta); + const sourceDelta = new Delta(source.data.delta); + const mergeDelta = targetDelta.concat(sourceDelta); + const ops = mergeDelta.ops; + const updateAction = controller.getUpdateAction({ + ...target, + data: { + ...target.data, + delta: ops, + }, + }); + + const actions = [updateAction]; + // move children + const config = blockConfig[target.type]; + const children = state.children[source.children].map((id) => state.nodes[id]); + const targetParentId = config.canAddChild ? target.id : target.parent; + if (!targetParentId) return; + const targetPrevId = targetParentId === target.id ? '' : target.id; + const moveActions = controller.getMoveChildrenAction(children, targetParentId, targetPrevId); + actions.push(...moveActions); + // delete current block + const deleteAction = controller.getDeleteAction(source); + actions.push(deleteAction); + + await controller.applyActions(actions); + } +); diff --git a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/text/outdent.ts b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/outdent.ts similarity index 100% rename from frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/text/outdent.ts rename to frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/outdent.ts 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 deleted file mode 100644 index 10207b5799..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/text/backspace.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { BlockType, DocumentState } from '$app/interfaces/document'; -import { DocumentController } from '$app/stores/effects/document/document_controller'; -import { createAsyncThunk } from '@reduxjs/toolkit'; -import { outdentNodeThunk } from './outdent'; -import { turnToTextBlockThunk } from '$app_reducers/document/async-actions/blocks/text/turn_to'; -import { mergeToPrevLineThunk } from '$app_reducers/document/async-actions/blocks/text/merge'; -import { ReactEditor } from 'slate-react'; - -/** - * 1. If current node is not text block, turn it to text block - * 2. If current node is text block - * 2.1 If the current node has next node, merge it to the previous line - * 2.2 If the parent is root, merge it to the previous line - * 2.3 If the parent is not root and has no next node, outdent it - */ -export const backspaceNodeThunk = createAsyncThunk( - 'document/backspaceNode', - async (payload: { id: string; controller: DocumentController; editor: ReactEditor }, thunkAPI) => { - const { id, controller, editor } = payload; - const { dispatch, getState } = thunkAPI; - const state = (getState() as { document: DocumentState }).document; - const node = state.nodes[id]; - if (!node.parent) return; - const parent = state.nodes[node.parent]; - const children = state.children[parent.children]; - const index = children.indexOf(id); - const nextNodeId = children[index + 1]; - // turn to text block - if (node.type !== BlockType.TextBlock) { - await dispatch(turnToTextBlockThunk({ id, controller })); - return; - } - const parentIsRoot = !parent.parent; - // merge to previous line when parent is root - if (parentIsRoot || nextNodeId) { - // merge to previous line - ReactEditor.deselect(editor); - await dispatch(mergeToPrevLineThunk({ id, controller, deleteCurrentNode: true })); - return; - } - // outdent - await dispatch(outdentNodeThunk({ id, controller })); - } -); 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 deleted file mode 100644 index 56032b59d2..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/text/index.ts +++ /dev/null @@ -1,6 +0,0 @@ -export * from './indent'; -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/merge.ts b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/text/merge.ts deleted file mode 100644 index 39e8842dec..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/text/merge.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { createAsyncThunk } from '@reduxjs/toolkit'; -import { DocumentController } from '$app/stores/effects/document/document_controller'; -import { DocumentState } from '$app/interfaces/document'; -import { getCollapsedRange, getPrevLineId } from "$app/utils/document/blocks/common"; -import { rangeSelectionActions } from "$app_reducers/document/slice"; -import { blockConfig } from '$app/constants/document/config'; -import { getNodeEndSelection } from '$app/utils/document/blocks/text/delta'; - -/** - * It will merge delta to the prev line - * 1. find the prev line and has delta - * 1.1 Set cursor after the prev line - * 1.2 merge delta - * 2. If deleteCurrentNode is true, delete the current node and move children - * 2.2.1 if the prev line can add children, move children to the prev line. - * 2.2.2 Otherwise, move children to the parent and below the prev line - * 3. If deleteCurrentNode is false, clear the current node delta - */ -export const mergeToPrevLineThunk = createAsyncThunk( - 'document/codeBlockBackspace', - async (payload: { id: string; controller: DocumentController; deleteCurrentNode?: boolean }, thunkAPI) => { - const { id, controller, deleteCurrentNode = false } = payload; - const { dispatch, getState } = thunkAPI; - const state = (getState() as { document: DocumentState }).document; - const node = state.nodes[id]; - const prevLineId = getPrevLineId(state, id); - if (!prevLineId) return; - let prevLine = state.nodes[prevLineId]; - // Find the prev line that has delta - while (prevLine && !prevLine.data.delta) { - const id = getPrevLineId(state, prevLine.id); - if (!id) return; - prevLine = state.nodes[id]; - } - if (!prevLine) return; - - const prevLineDelta = prevLine.data.delta; - - const selection = getNodeEndSelection(prevLineDelta); - - const mergeDelta = [...prevLineDelta, ...node.data.delta]; - - const updateAction = controller.getUpdateAction({ - ...prevLine, - data: { - ...prevLine.data, - delta: mergeDelta, - }, - }); - - const actions = [updateAction]; - - if (deleteCurrentNode) { - // move children - const config = blockConfig[prevLine.type]; - const children = state.children[node.children].map((id) => state.nodes[id]); - const targetParentId = config.canAddChild ? prevLine.id : prevLine.parent; - if (!targetParentId) return; - const targetPrevId = targetParentId === prevLine.id ? '' : prevLine.id; - const moveActions = controller.getMoveChildrenAction(children, targetParentId, targetPrevId); - actions.push(...moveActions); - // delete current block - const deleteAction = controller.getDeleteAction(node); - actions.push(deleteAction); - } else { - // clear current block delta - const updateAction = controller.getUpdateAction({ - ...node, - data: { - ...node.data, - delta: [], - }, - }); - actions.push(updateAction); - } - await controller.applyActions(actions); - - // set cursor after the prev line - const range = getCollapsedRange(prevLine.id, selection); - dispatch(rangeSelectionActions.setRange(range)); - } -); 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 deleted file mode 100644 index f5e931773c..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/text/split.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { DocumentState, SplitRelationship } from '$app/interfaces/document'; -import { DocumentController } from '$app/stores/effects/document/document_controller'; -import { createAsyncThunk } from '@reduxjs/toolkit'; -import { setCursorBeforeThunk } from '../../cursor'; -import { newBlock } from '$app/utils/document/blocks/common'; -import { blockConfig } from '$app/constants/document/config'; -import { getSplitDelta } from '@/appflowy_app/utils/document/blocks/text/delta'; -import { ReactEditor } from 'slate-react'; - -export const splitNodeThunk = createAsyncThunk( - 'document/splitNode', - async (payload: { id: string; editor: ReactEditor; controller: DocumentController }, thunkAPI) => { - const { id, controller, editor } = payload; - // get the split content - const { retain, insert } = getSplitDelta(editor); - - const { dispatch, getState } = thunkAPI; - const state = (getState() as { document: DocumentState }).document; - const node = state.nodes[id]; - if (!node.parent) return; - const children = state.children[node.children]; - const parent = state.nodes[node.parent]; - - const config = blockConfig[node.type].splitProps; - // Here we are using the splitProps property of the blockConfig object to determine the type of the new node. - // if the splitProps property is not defined for the block type, we throw an error. - if (!config) { - throw new Error(`Cannot split node of type ${node.type}`); - } - const newNodeType = config.nextLineBlockType; - const relationShip = config.nextLineRelationShip; - const defaultData = blockConfig[newNodeType].defaultData; - // if the defaultData property is not defined for the new block type, we throw an error. - if (!defaultData) { - throw new Error(`Cannot split node of type ${node.type} to ${newNodeType}`); - } - - // if the next line is a sibling, parent is the same as the current node, and prev is the current node. - // otherwise, parent is the current node, and prev is empty. - const newParentId = relationShip === SplitRelationship.NextSibling ? parent.id : node.id; - const newPrevId = relationShip === SplitRelationship.NextSibling ? node.id : ''; - - const newNode = newBlock(newNodeType, newParentId, { - ...defaultData, - delta: insert, - }); - const retainNode = { - ...node, - data: { - ...node.data, - delta: retain, - }, - }; - const insertAction = controller.getInsertAction(newNode, newPrevId); - const updateAction = controller.getUpdateAction(retainNode); - - // if the next line is a sibling, we need to move the children of the current node to the new node. - // otherwise, we don't need to do anything. - const moveChildrenAction = - relationShip === SplitRelationship.NextSibling - ? controller.getMoveChildrenAction( - children.map((id) => state.nodes[id]), - newNode.id, - '' - ) - : []; - - await controller.applyActions([insertAction, ...moveChildrenAction, updateAction]); - - ReactEditor.deselect(editor); - // set cursor - await dispatch(setCursorBeforeThunk({ id: newNode.id })); - } -); 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 deleted file mode 100644 index 040e9545ec..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/text/turn_to.ts +++ /dev/null @@ -1,32 +0,0 @@ -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/update.ts similarity index 77% rename from frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/text/update.ts rename to frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/update.ts index 9248f15667..44f677efa5 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/update.ts @@ -1,18 +1,18 @@ -import { TextDelta, DocumentState, BlockData } from '$app/interfaces/document'; +import { DocumentState, BlockData } from '$app/interfaces/document'; import { DocumentController } from '$app/stores/effects/document/document_controller'; import { createAsyncThunk } from '@reduxjs/toolkit'; -import { isSameDelta } from '$app/utils/document/blocks/text/delta'; +import Delta, { Op } from 'quill-delta'; export const updateNodeDeltaThunk = createAsyncThunk( 'document/updateNodeDelta', - async (payload: { id: string; delta: TextDelta[]; controller: DocumentController }, thunkAPI) => { + async (payload: { id: string; delta: Op[]; controller: DocumentController }, thunkAPI) => { const { id, delta, controller } = payload; const { getState } = thunkAPI; const state = (getState() as { document: DocumentState }).document; const node = state.nodes[id]; - const isSame = isSameDelta(delta, node.data.delta || []); + const diffDelta = new Delta(delta).diff(new Delta(node.data.delta || [])); + if (diffDelta.ops.length === 0) return; - if (isSame) return; const newData = { ...node.data, delta }; await controller.applyActions([ 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 deleted file mode 100644 index 9645df28f8..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/cursor.ts +++ /dev/null @@ -1,109 +0,0 @@ -import { createAsyncThunk } from '@reduxjs/toolkit'; -import { rangeSelectionActions } from "../slice"; -import { DocumentState, TextSelection } from '$app/interfaces/document'; -import { Editor } from 'slate'; -import { - getBeforeRangeAt, - getEndLineSelectionByOffset, - getLastLineOffsetByDelta, - getNodeBeginSelection, - getNodeEndSelection, - getStartLineSelectionByOffset, -} from '$app/utils/document/blocks/text/delta'; -import { getCollapsedRange, getNextLineId, getPrevLineId } from "$app/utils/document/blocks/common"; -import { ReactEditor } from "slate-react"; - -export const setCursorBeforeThunk = createAsyncThunk( - 'document/setCursorBefore', - async (payload: { id: string }, thunkAPI) => { - const { id } = payload; - const { dispatch } = thunkAPI; - const selection = getNodeBeginSelection(); - - const range = getCollapsedRange(id, selection); - dispatch(rangeSelectionActions.setRange(range)); - } -); - -export const setCursorAfterThunk = createAsyncThunk( - 'document/setCursorAfter', - async (payload: { id: string }, thunkAPI) => { - const { id } = payload; - const { dispatch, getState } = thunkAPI; - const state = (getState() as { document: DocumentState }).document; - const node = state.nodes[id]; - const selection = getNodeEndSelection(node.data.delta); - const range = getCollapsedRange(id, selection); - dispatch(rangeSelectionActions.setRange(range)); - } -); - -export const setCursorPreLineThunk = createAsyncThunk( - 'document/setCursorPreLine', - async (payload: { id: string; editor: ReactEditor; focusEnd?: boolean }, thunkAPI) => { - const { id, editor, focusEnd } = payload; - const selection = editor.selection as TextSelection; - const { dispatch, getState } = thunkAPI; - const state = (getState() as { document: DocumentState }).document; - const prevId = getPrevLineId(state, id); - if (!prevId) return; - - let prevLineNode = state.nodes[prevId]; - // Find the prev line that has delta - while (prevLineNode && !prevLineNode.data.delta) { - const id = getPrevLineId(state, prevLineNode.id); - if (!id) return; - prevLineNode = state.nodes[id]; - } - if (!prevLineNode) return; - - // whatever the selection is, set cursor to the end of prev line when focusEnd is true - if (focusEnd) { - await dispatch(setCursorAfterThunk({ id: prevLineNode.id })); - return; - } - - const range = getBeforeRangeAt(editor, selection); - const textOffset = Editor.string(editor, range).length; - - // set the cursor to prev line with the relative offset - const newSelection = getEndLineSelectionByOffset(prevLineNode.data.delta, textOffset); - dispatch(rangeSelectionActions.setRange(getCollapsedRange(prevLineNode.id, newSelection))); - } -); - -export const setCursorNextLineThunk = createAsyncThunk( - 'document/setCursorNextLine', - async (payload: { id: string; editor: ReactEditor; focusStart?: boolean }, thunkAPI) => { - const { id, editor, focusStart } = payload; - const selection = editor.selection as TextSelection; - const { dispatch, getState } = thunkAPI; - const state = (getState() as { document: DocumentState }).document; - const node = state.nodes[id]; - const nextId = getNextLineId(state, id); - if (!nextId) return; - let nextLineNode = state.nodes[nextId]; - // Find the next line that has delta - while (nextLineNode && !nextLineNode.data.delta) { - const id = getNextLineId(state, nextLineNode.id); - if (!id) return; - nextLineNode = state.nodes[id]; - } - if (!nextLineNode) return; - - const delta = nextLineNode.data.delta; - // whatever the selection is, set cursor to the start of next line when focusStart is true - if (focusStart) { - await dispatch(setCursorBeforeThunk({ id: nextLineNode.id })); - return; - } - - const range = getBeforeRangeAt(editor, selection); - const textOffset = Editor.string(editor, range).length - getLastLineOffsetByDelta(node.data.delta); - - // set the cursor to next line with the relative offset - const newSelection = getStartLineSelectionByOffset(delta, textOffset); - - dispatch(rangeSelectionActions.setRange(getCollapsedRange(nextLineNode.id, newSelection))); - } -); diff --git a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/format.ts b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/format.ts index 94078f5d09..11d2b234f8 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/format.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/format.ts @@ -1,31 +1,28 @@ import { createAsyncThunk } from '@reduxjs/toolkit'; import { RootState } from '$app/stores/store'; -import { TextAction, TextDelta, TextSelection } from '$app/interfaces/document'; -import { getAfterRangeDelta, getBeforeRangeDelta, getRangeDelta } from '$app/utils/document/blocks/text/delta'; +import { TextAction } from '$app/interfaces/document'; import { DocumentController } from '$app/stores/effects/document/document_controller'; +import Delta from 'quill-delta'; +import { rangeActions } from '$app_reducers/document/slice'; export const getFormatActiveThunk = createAsyncThunk( 'document/getFormatActive', async (format, thunkAPI) => { const { getState } = thunkAPI; const state = getState() as RootState; - const { document } = state; - const { selection, anchor, focus } = state.documentRangeSelection; - - const match = (delta: TextDelta[], format: TextAction) => { - return delta.every((op) => op.attributes?.[format] === true); + const { document, documentRange } = state; + const { ranges } = documentRange; + const match = (delta: Delta, format: TextAction) => { + return delta.ops.every((op) => op.attributes?.[format] === true); }; - return selection.every((id) => { + return Object.entries(ranges).every(([id, range]) => { const node = document.nodes[id]; - let delta = node.data?.delta as TextDelta[]; - if (!delta) return false; + const delta = new Delta(node.data?.delta); + const index = range?.index || 0; + const length = range?.length || 0; + const rangeDelta = delta.slice(index, index + length); - if (id === anchor?.id) { - delta = getRangeDelta(delta, anchor.selection); - } else if (id === focus?.id) { - delta = getRangeDelta(delta, focus.selection); - } - return match(delta, format); + return match(rangeDelta, format); }); } ); @@ -33,15 +30,14 @@ export const getFormatActiveThunk = createAsyncThunk( export const toggleFormatThunk = createAsyncThunk( 'document/toggleFormat', async (payload: { format: TextAction; controller: DocumentController; isActive: boolean }, thunkAPI) => { - const { getState } = thunkAPI; + const { getState, dispatch } = thunkAPI; const { format, controller, isActive } = payload; const state = getState() as RootState; const { document } = state; - const { selection, anchor, focus } = state.documentRangeSelection; - const ids = Array.from(new Set(selection)); + const { ranges, caret } = state.documentRange; - const toggle = (delta: TextDelta[], format: TextAction) => { - return delta.map((op) => { + const toggle = (delta: Delta, format: TextAction) => { + const newOps = delta.ops.map((op) => { const attributes = { ...op.attributes, [format]: isActive ? undefined : true, @@ -51,36 +47,25 @@ export const toggleFormatThunk = createAsyncThunk( attributes: attributes, }; }); + return new Delta(newOps); }; - const splitDelta = (delta: TextDelta[], selection: TextSelection) => { - const before = getBeforeRangeDelta(delta, selection); - const after = getAfterRangeDelta(delta, selection); - let middle = getRangeDelta(delta, selection); - - middle = toggle(middle, format); - - return [...before, ...middle, ...after]; - }; - - const actions = ids.map((id) => { + const actions = Object.entries(ranges).map(([id, range]) => { const node = document.nodes[id]; - let delta = node.data?.delta as TextDelta[]; - if (!delta) return controller.getUpdateAction(node); - - if (id === anchor?.id) { - delta = splitDelta(delta, anchor.selection); - } else if (id === focus?.id) { - delta = splitDelta(delta, focus.selection); - } else { - delta = toggle(delta, format); - } + const delta = new Delta(node.data?.delta); + const index = range?.index || 0; + const length = range?.length || 0; + const beforeDelta = delta.slice(0, index); + const afterDelta = delta.slice(index + length); + const rangeDelta = delta.slice(index, index + length); + const toggleFormatDelta = toggle(rangeDelta, format); + const newDelta = beforeDelta.concat(toggleFormatDelta).concat(afterDelta); return controller.getUpdateAction({ ...node, data: { ...node.data, - delta, + delta: newDelta.ops, }, }); }); 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 721c18513f..73eb214085 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,15 +1,4 @@ -import { createAsyncThunk } from "@reduxjs/toolkit"; -import { DocumentState, NestedBlock } from "$app/interfaces/document"; - -export * from './cursor'; export * from './blocks'; export * from './turn_to'; - -export const getBlockByIdThunk = createAsyncThunk( - 'document/getBlockById', - async (id, thunkAPI) => { - const { getState } = thunkAPI; - const state = getState() as { document: DocumentState }; - const node = state.document.nodes[id] as NestedBlock; - return node; - }); \ No newline at end of file +export * from './keydown'; +export * from './range'; diff --git a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/keydown.ts b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/keydown.ts new file mode 100644 index 0000000000..a6c8d51561 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/keydown.ts @@ -0,0 +1,288 @@ +import { createAsyncThunk } from '@reduxjs/toolkit'; +import { DocumentController } from '$app/stores/effects/document/document_controller'; +import { BlockType, DocumentState, SplitRelationship } from '$app/interfaces/document'; +import { turnToTextBlockThunk } from '$app_reducers/document/async-actions/turn_to'; +import { + findNextHasDeltaNode, + findPrevHasDeltaNode, + getInsertEnterNodeAction, + getLeftCaretByRange, + getRightCaretByRange, + transformToNextLineCaret, + transformToPrevLineCaret, +} from '$app/utils/document/action'; +import Delta from 'quill-delta'; +import { indentNodeThunk, mergeDeltaThunk, outdentNodeThunk } from '$app_reducers/document/async-actions/blocks'; +import { rangeActions } from '$app_reducers/document/slice'; +import { RootState } from '$app/stores/store'; +import { blockConfig } from '$app/constants/document/config'; +import { Keyboard } from '$app/constants/document/keyboard'; + +/** + * Delete a block by backspace or delete key + * 1. If the block is not a text block, turn it to a text block + * 2. If the block is a text block + * 2.1 If the block has next node or is top level, merge it to the previous line + * 2.2 If the block has no next node and is not top level, outdent it + */ +export const backspaceDeleteActionForBlockThunk = createAsyncThunk( + 'document/backspaceDeleteActionForBlock', + async (payload: { id: string; controller: DocumentController }, thunkAPI) => { + const { id, controller } = payload; + const { dispatch, getState } = thunkAPI; + const state = (getState() as { document: DocumentState }).document; + const node = state.nodes[id]; + if (!node.parent) return; + const parent = state.nodes[node.parent]; + const children = state.children[parent.children]; + const index = children.indexOf(id); + const nextNodeId = children[index + 1]; + // turn to text block + if (node.type !== BlockType.TextBlock) { + await dispatch(turnToTextBlockThunk({ id, controller })); + return; + } + const isTopLevel = parent.type === BlockType.PageBlock; + if (isTopLevel || nextNodeId) { + // merge to previous line + const prevLine = findPrevHasDeltaNode(state, id); + if (!prevLine) return; + const caretIndex = new Delta(prevLine.data.delta).length(); + const caret = { + id: prevLine.id, + index: caretIndex, + length: 0, + }; + await dispatch( + mergeDeltaThunk({ + sourceId: id, + targetId: prevLine.id, + controller, + }) + ); + dispatch(rangeActions.setCaret(caret)); + return; + } + // outdent + await dispatch(outdentNodeThunk({ id, controller })); + } +); + +/** + * Insert a new node after the current node by pressing enter. + * 1. Split the current node into two nodes. + * 2. Insert a new node after the current node. + * 3. Move the children of the current node to the new node if needed. + */ +export const enterActionForBlockThunk = createAsyncThunk( + 'document/insertNodeByEnter', + async (payload: { id: string; controller: DocumentController }, thunkAPI) => { + const { id, controller } = payload; + const { getState, dispatch } = thunkAPI; + const state = getState() as RootState; + const node = state.document.nodes[id]; + const caret = state.documentRange.caret; + if (!node || !caret || caret.id !== id) return; + + const nodeDelta = new Delta(node.data.delta).slice(0, caret.index); + const insertNodeDelta = new Delta(node.data.delta).slice(caret.index + caret.length); + + const insertNodeAction = getInsertEnterNodeAction(node, insertNodeDelta, controller); + if (!insertNodeAction) return; + const updateNode = { + ...node, + data: { + ...node.data, + delta: nodeDelta.ops, + }, + }; + + const children = state.document.children[node.children]; + const needMoveChildren = blockConfig[node.type].splitProps?.nextLineRelationShip === SplitRelationship.NextSibling; + console.log('needMoveChildren', needMoveChildren); + const moveChildrenAction = needMoveChildren + ? controller.getMoveChildrenAction( + children.map((id) => state.document.nodes[id]), + insertNodeAction.id, + '' + ) + : []; + const actions = [insertNodeAction.action, controller.getUpdateAction(updateNode), ...moveChildrenAction]; + await controller.applyActions(actions); + + dispatch(rangeActions.clearRange()); + dispatch( + rangeActions.setCaret({ + id: insertNodeAction.id, + index: 0, + length: 0, + }) + ); + } +); + +export const tabActionForBlockThunk = createAsyncThunk( + 'document/tabActionForBlock', + async (payload: { id: string; controller: DocumentController }, thunkAPI) => { + const { dispatch } = thunkAPI; + return dispatch(indentNodeThunk(payload)); + } +); + +export const upDownActionForBlockThunk = createAsyncThunk( + 'document/upActionForBlock', + async (payload: { id: string; down?: boolean }, thunkAPI) => { + const { id, down } = payload; + const { dispatch, getState } = thunkAPI; + const state = getState() as RootState; + const rangeState = state.documentRange; + const caret = rangeState.caret; + const node = state.document.nodes[id]; + if (!node || !caret || id !== caret.id) return; + + let newCaret; + + if (down) { + newCaret = transformToNextLineCaret(state.document, caret); + } else { + newCaret = transformToPrevLineCaret(state.document, caret); + } + if (!newCaret) { + return; + } + dispatch(rangeActions.setCaret(newCaret)); + } +); + +export const leftActionForBlockThunk = createAsyncThunk( + 'document/leftActionForBlock', + async (payload: { id: string }, thunkAPI) => { + const { id } = payload; + const { dispatch, getState } = thunkAPI; + const state = getState() as RootState; + const rangeState = state.documentRange; + const caret = rangeState.caret; + const node = state.document.nodes[id]; + if (!node || !caret || id !== caret.id) return; + let newCaret; + if (caret.length > 0) { + newCaret = { + id, + index: caret.index, + length: 0, + }; + } else { + if (caret.index > 0) { + newCaret = { + id, + index: caret.index - 1, + length: 0, + }; + } else { + const prevNode = findPrevHasDeltaNode(state.document, id); + if (!prevNode) return; + const prevDelta = new Delta(prevNode.data.delta); + newCaret = { + id: prevNode.id, + index: prevDelta.length(), + length: 0, + }; + } + } + + if (!newCaret) { + return; + } + dispatch(rangeActions.setCaret(newCaret)); + } +); + +export const rightActionForBlockThunk = createAsyncThunk( + 'document/rightActionForBlock', + async (payload: { id: string }, thunkAPI) => { + const { id } = payload; + const { dispatch, getState } = thunkAPI; + const state = getState() as RootState; + const rangeState = state.documentRange; + const caret = rangeState.caret; + const node = state.document.nodes[id]; + if (!node || !caret || id !== caret.id) return; + let newCaret; + const delta = new Delta(node.data.delta); + const deltaLength = delta.length(); + if (caret.length > 0) { + newCaret = { + id, + index: caret.index + caret.length, + length: 0, + }; + } else { + if (caret.index < deltaLength) { + const newIndex = caret.index + caret.length + 1; + newCaret = { + id, + index: newIndex > deltaLength ? deltaLength : newIndex, + length: 0, + }; + } else { + const nextNode = findNextHasDeltaNode(state.document, id); + if (!nextNode) return; + newCaret = { + id: nextNode.id, + index: 0, + length: 0, + }; + } + } + + if (!newCaret) { + return; + } + dispatch(rangeActions.setCaret(newCaret)); + } +); + +export const shiftTabActionForBlockThunk = createAsyncThunk( + 'document/shiftTabActionForBlock', + async (payload: { id: string; controller: DocumentController }, thunkAPI) => { + const { dispatch } = thunkAPI; + return dispatch(outdentNodeThunk(payload)); + } +); + +export const arrowActionForRangeThunk = createAsyncThunk( + 'document/arrowLeftActionForRange', + async ( + payload: { + key: string; + }, + thunkAPI + ) => { + const { dispatch, getState } = thunkAPI; + const state = getState() as RootState; + const rangeState = state.documentRange; + let caret; + const leftCaret = getLeftCaretByRange(rangeState); + const rightCaret = getRightCaretByRange(rangeState); + + if (!leftCaret || !rightCaret) return; + + switch (payload.key) { + case Keyboard.keys.LEFT: + caret = leftCaret; + break; + case Keyboard.keys.RIGHT: + caret = rightCaret; + break; + case Keyboard.keys.UP: + caret = transformToPrevLineCaret(state.document, leftCaret); + break; + case Keyboard.keys.DOWN: + caret = transformToNextLineCaret(state.document, rightCaret); + break; + } + if (!caret) return; + dispatch(rangeActions.clearRange()); + dispatch(rangeActions.setCaret(caret)); + } +); diff --git a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/menu.ts b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/menu.ts index 65bf5bc3d6..ec023d9675 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/menu.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/menu.ts @@ -1,11 +1,13 @@ import { createAsyncThunk } from '@reduxjs/toolkit'; -import { BlockData, BlockType, DocumentState, TextDelta } from '$app/interfaces/document'; +import { BlockData, BlockType, DocumentState } from '$app/interfaces/document'; import { insertAfterNodeThunk } from '$app_reducers/document/async-actions/blocks'; import { DocumentController } from '$app/stores/effects/document/document_controller'; -import { slashCommandActions } from '$app_reducers/document/slice'; -import { setCursorBeforeThunk } from '$app_reducers/document/async-actions/cursor'; +import { rangeActions, slashCommandActions } from '$app_reducers/document/slice'; import { turnToBlockThunk } from '$app_reducers/document/async-actions/turn_to'; import { blockConfig } from '$app/constants/document/config'; +import Delta, { Op } from 'quill-delta'; +import { getDeltaText } from '$app/utils/document/delta'; +import { RootState } from '$app/stores/store'; /** * add block below click @@ -20,7 +22,7 @@ export const addBlockBelowClickThunk = createAsyncThunk( const state = (getState() as { document: DocumentState }).document; const node = state.nodes[id]; if (!node) return; - const delta = (node.data.delta as TextDelta[]) || []; + const delta = (node.data.delta as Op[]) || []; const text = delta.map((d) => d.insert).join(''); // if current block is not empty, insert a new block after current block @@ -29,13 +31,14 @@ export const addBlockBelowClickThunk = createAsyncThunk( insertAfterNodeThunk({ id: id, type: BlockType.TextBlock, controller, data: { delta: [] } }) ); if (newBlockId) { - await dispatch(setCursorBeforeThunk({ id: newBlockId as string })); + dispatch(rangeActions.setCaret({ id: newBlockId as string, index: 0, length: 0 })); dispatch(slashCommandActions.openSlashCommand({ blockId: newBlockId as string })); } return; } // if current block is empty, open slash command - await dispatch(setCursorBeforeThunk({ id })); + dispatch(rangeActions.setCaret({ id, index: 0, length: 0 })); + dispatch(slashCommandActions.openSlashCommand({ blockId: id })); } ); @@ -60,12 +63,14 @@ export const triggerSlashCommandActionThunk = createAsyncThunk( ) => { const { id, controller, props } = payload; const { dispatch, getState } = thunkAPI; - const state = (getState() as { document: DocumentState }).document; - const node = state.nodes[id]; + const state = getState() as RootState; + const { document } = state; + const node = document.nodes[id]; if (!node) return; - const delta = (node.data.delta as TextDelta[]) || []; - const text = delta.map((d) => d.insert).join(''); + const delta = new Delta(node.data.delta); + const text = getDeltaText(delta); const defaultData = blockConfig[props.type].defaultData; + if (node.type === BlockType.TextBlock && (text === '' || text === '/')) { dispatch( turnToBlockThunk({ @@ -80,7 +85,20 @@ export const triggerSlashCommandActionThunk = createAsyncThunk( ); return; } - const { payload: newBlockId } = await dispatch( + + // if current block has slash command, remove slash command + if (text.slice(0, 1) === '/') { + const updateNode = { + ...node, + data: { + ...node.data, + delta: delta.slice(1, delta.length()).ops, + }, + }; + await controller.applyActions([controller.getUpdateAction(updateNode)]); + } + + const insertNodePayload = await dispatch( insertAfterNodeThunk({ id, controller, @@ -91,6 +109,8 @@ export const triggerSlashCommandActionThunk = createAsyncThunk( }, }) ); - dispatch(setCursorBeforeThunk({ id: newBlockId as string })); + const newBlockId = insertNodePayload.payload as string; + + dispatch(rangeActions.setCaret({ id: newBlockId, index: 0, length: 0 })); } ); diff --git a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/range.ts b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/range.ts new file mode 100644 index 0000000000..ae6a8db291 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/range.ts @@ -0,0 +1,221 @@ +import { createAsyncThunk } from '@reduxjs/toolkit'; +import { RootState } from '$app/stores/store'; +import { rangeActions } from '$app_reducers/document/slice'; +import { getNextLineId } from '$app/utils/document/block'; +import Delta from 'quill-delta'; +import { DocumentController } from '$app/stores/effects/document/document_controller'; +import { + getAfterMergeCaretByRange, + getInsertEnterNodeAction, + getMergeEndDeltaToStartActionsByRange, + getMiddleIdsByRange, + getStartAndEndDeltaExpectRange, +} from '$app/utils/document/action'; +import { RangeState, SplitRelationship } from '$app/interfaces/document'; +import { blockConfig } from '$app/constants/document/config'; + +interface storeRangeThunkPayload { + id: string; + range: { + index: number; + length: number; + }; +} + +/** + * store range to redux store + * 1. if isDragging is false, just store range + * 2. if isDragging is true, we need amend range between anchor and focus + */ +export const storeRangeThunk = createAsyncThunk('document/storeRange', (payload: storeRangeThunkPayload, thunkAPI) => { + const { id, range } = payload; + const { dispatch, getState } = thunkAPI; + const state = getState() as RootState; + const rangeState = state.documentRange; + // we need amend range between anchor and focus + const { anchor, focus, isDragging } = rangeState; + if (!isDragging || !anchor || !focus) return; + + const ranges: RangeState['ranges'] = {}; + ranges[id] = range; + // pin anchor index + let anchorIndex = anchor.point.index; + let anchorLength = anchor.point.length; + if (anchorIndex === undefined || anchorLength === undefined) { + dispatch(rangeActions.setAnchorPointRange(range)); + anchorIndex = range.index; + anchorLength = range.length; + } + + // if anchor and focus are in the same node, we don't need to amend range + if (anchor.id === id) { + dispatch(rangeActions.setRanges(ranges)); + return; + } + + // amend anchor range because slatejs will stop update selection when dragging quickly + const isForward = anchor.point.y < focus.point.y; + const anchorDelta = new Delta(state.document.nodes[anchor.id].data.delta); + if (isForward) { + const selectedDelta = anchorDelta.slice(anchorIndex); + ranges[anchor.id] = { + index: anchorIndex, + length: selectedDelta.length(), + }; + } else { + const selectedDelta = anchorDelta.slice(0, anchorIndex + anchorLength); + ranges[anchor.id] = { + index: 0, + length: selectedDelta.length(), + }; + } + + // select all ids between anchor and focus + const startId = isForward ? anchor.id : focus.id; + const endId = isForward ? focus.id : anchor.id; + + let currentId: string | undefined = startId; + while (currentId && currentId !== endId) { + const nextId = getNextLineId(state.document, currentId); + if (nextId && nextId !== endId) { + const node = state.document.nodes[nextId]; + + if (!node || !node.data.delta) return; + const delta = new Delta(node.data.delta); + + // set full range + const rangeStatic = { + index: 0, + length: delta.length(), + }; + + ranges[nextId] = rangeStatic; + } + currentId = nextId; + } + + dispatch(rangeActions.setRanges(ranges)); +}); + +/** + * delete range and insert delta + * 1. merge start and end delta to start node and delete end node + * 2. delete middle nodes + * 3. clear range + */ +export const deleteRangeAndInsertThunk = createAsyncThunk( + 'document/deleteRange', + async (payload: { controller: DocumentController; insertDelta?: Delta }, thunkAPI) => { + const { controller, insertDelta } = payload; + const { getState, dispatch } = thunkAPI; + const state = getState() as RootState; + const rangeState = state.documentRange; + const actions = []; + // get merge actions + const mergeActions = getMergeEndDeltaToStartActionsByRange(state, controller, insertDelta); + if (mergeActions) { + actions.push(...mergeActions); + } + // get middle nodes + const middleIds = getMiddleIdsByRange(rangeState, state.document); + // delete middle nodes + const deleteMiddleNodesActions = middleIds?.map((id) => controller.getDeleteAction(state.document.nodes[id])) || []; + actions.push(...deleteMiddleNodesActions); + + const caret = getAfterMergeCaretByRange(rangeState, insertDelta); + + // apply actions + await controller.applyActions(actions); + + // clear range + dispatch(rangeActions.clearRange()); + if (caret) { + dispatch(rangeActions.setCaret(caret)); + } + } +); + +/** + * delete range and insert enter + * 1. if shift key, insert '\n' to start node and concat end node delta + * 2. if not shift key + * 2.1 insert node under start node, and concat end node delta to insert node + * 2.2 filter rest children and move to insert node, if need + * 3. delete middle nodes + * 4. clear range + */ +export const deleteRangeAndInsertEnterThunk = createAsyncThunk( + 'document/deleteRangeAndInsertEnter', + async (payload: { controller: DocumentController; shiftKey: boolean }, thunkAPI) => { + const { controller, shiftKey } = payload; + const { getState, dispatch } = thunkAPI; + const state = getState() as RootState; + const rangeState = state.documentRange; + const actions = []; + + const { startDelta, endDelta, endNode, startNode } = getStartAndEndDeltaExpectRange(state) || {}; + if (!startDelta || !endDelta || !endNode || !startNode) return; + + // get middle nodes + const middleIds = getMiddleIdsByRange(rangeState, state.document); + + let newStartDelta = new Delta(startDelta); + let caret = null; + if (shiftKey) { + newStartDelta = newStartDelta.insert('\n').concat(endDelta); + caret = getAfterMergeCaretByRange(rangeState, new Delta().insert('\n')); + } else { + const insertNodeDelta = new Delta(endDelta); + const insertNodeAction = getInsertEnterNodeAction(startNode, insertNodeDelta, controller); + if (!insertNodeAction) return; + actions.push(insertNodeAction.action); + caret = { + id: insertNodeAction.id, + index: 0, + length: 0, + }; + // move start node children to insert node + const needMoveChildren = + blockConfig[startNode.type].splitProps?.nextLineRelationShip === SplitRelationship.NextSibling; + if (needMoveChildren) { + // filter children by delete middle ids + const children = state.document.children[startNode.children].filter((id) => middleIds?.includes(id)); + const moveChildrenAction = needMoveChildren + ? controller.getMoveChildrenAction( + children.map((id) => state.document.nodes[id]), + insertNodeAction.id, + '' + ) + : []; + actions.push(...moveChildrenAction); + } + } + + // udpate start node + const updateAction = controller.getUpdateAction({ + ...startNode, + data: { + ...startNode.data, + delta: newStartDelta.ops, + }, + }); + if (endNode.id !== startNode.id) { + // delete end node + const deleteAction = controller.getDeleteAction(endNode); + actions.push(updateAction, deleteAction); + } + + // delete middle nodes + const deleteMiddleNodesActions = middleIds?.map((id) => controller.getDeleteAction(state.document.nodes[id])) || []; + actions.push(...deleteMiddleNodesActions); + + // apply actions + await controller.applyActions(actions); + + // clear range + dispatch(rangeActions.clearRange()); + if (caret) { + dispatch(rangeActions.setCaret(caret)); + } + } +); diff --git a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/range_selection.ts b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/range_selection.ts deleted file mode 100644 index 34ccbb0bfe..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/range_selection.ts +++ /dev/null @@ -1,119 +0,0 @@ -import { createAsyncThunk } from '@reduxjs/toolkit'; -import { DocumentState, TextSelection } from '$app/interfaces/document'; -import { rangeSelectionActions } from '$app_reducers/document/slice'; -import { getNodeBeginSelection, getNodeEndSelection } from '$app/utils/document/blocks/text/delta'; -import { isEqual } from '$app/utils/tool'; -import { RootState } from '$app/stores/store'; -import { getNodesInRange } from '$app/utils/document/blocks/common'; - -const amendAnchorNodeThunk = createAsyncThunk( - 'document/amendAnchorNode', - async ( - payload: { - id: string; - }, - thunkAPI - ) => { - const { id } = payload; - const { getState, dispatch } = thunkAPI; - const nodes = (getState() as { document: DocumentState }).document.nodes; - - const state = getState() as RootState; - const { isDragging, isForward, ...range } = state.documentRangeSelection; - const { anchor: anchorNode, focus: focusNode } = range; - - if (!isDragging || !anchorNode || anchorNode.id !== id) return; - const isCollapsed = focusNode?.id === id && anchorNode?.id === id; - if (isCollapsed) return; - - const selection = anchorNode.selection; - const node = nodes[id]; - const focus = isForward ? getNodeEndSelection(node.data.delta).anchor : getNodeBeginSelection().anchor; - if (isEqual(focus, selection.focus)) return; - const newSelection = { - anchor: selection.anchor, - focus, - }; - - dispatch( - rangeSelectionActions.setRange({ - anchor: { - id, - selection: newSelection as TextSelection, - }, - }) - ); - } -); - -export const syncRangeSelectionThunk = createAsyncThunk( - 'document/syncRangeSelection', - async ( - payload: { - id: string; - selection: TextSelection; - }, - thunkAPI - ) => { - const { getState, dispatch } = thunkAPI; - const state = getState() as RootState; - const range = state.documentRangeSelection; - const isDragging = range.isDragging; - - const { id, selection } = payload; - - const updateRange = { - focus: { - id, - selection, - }, - }; - - if (!isDragging && range.anchor?.id === id) { - Object.assign(updateRange, { - anchor: { - id, - selection: { ...selection }, - }, - }); - dispatch(rangeSelectionActions.setRange(updateRange)); - return; - } - if (!range.anchor || range.anchor.id === id) { - Object.assign(updateRange, { - anchor: { - id, - selection: { - anchor: !range.anchor ? selection.anchor : range.anchor.selection.anchor, - focus: selection.focus, - }, - }, - }); - } - - dispatch(rangeSelectionActions.setRange(updateRange)); - - const anchorId = range.anchor?.id; - // more than one node is selected - if (anchorId && anchorId !== id) { - dispatch(amendAnchorNodeThunk({ id: anchorId })); - } - } -); - -export const setRangeSelectionThunk = createAsyncThunk('document/setRangeSelection', async (payload, thunkAPI) => { - const { getState, dispatch } = thunkAPI; - const state = getState() as RootState; - const { anchor, focus, isForward } = state.documentRangeSelection; - const document = state.document; - if (!anchor || !focus || isForward === undefined) return; - const rangeIds = getNodesInRange( - { - startId: anchor.id, - endId: focus.id, - }, - isForward, - document - ); - dispatch(rangeSelectionActions.setSelection(rangeIds)); -}); diff --git a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/rect_selection.ts b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/rect_selection.ts index e8e8acc1fe..151b07dcca 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/rect_selection.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/rect_selection.ts @@ -1,7 +1,7 @@ -import { createAsyncThunk } from "@reduxjs/toolkit"; -import { getNextNodeId, getPrevNodeId } from "$app/utils/document/blocks/common"; -import { DocumentState } from "$app/interfaces/document"; -import { rectSelectionActions } from "$app_reducers/document/slice"; +import { createAsyncThunk } from '@reduxjs/toolkit'; +import { getNextNodeId, getPrevNodeId } from '$app/utils/document/block'; +import { DocumentState } from '$app/interfaces/document'; +import { rectSelectionActions } from '$app_reducers/document/slice'; export const setRectSelectionThunk = createAsyncThunk( 'document/setRectSelection', @@ -22,6 +22,6 @@ export const setRectSelectionThunk = createAsyncThunk( selected[node.parent] = true; } }); - dispatch(rectSelectionActions.updateSelections(payload.filter((id) => selected[id]))) + dispatch(rectSelectionActions.updateSelections(payload.filter((id) => selected[id]))); } ); 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 69d112d244..6ba9185482 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 @@ -1,10 +1,9 @@ import { createAsyncThunk } from '@reduxjs/toolkit'; import { DocumentController } from '$app/stores/effects/document/document_controller'; -import { BlockData, BlockType, DocumentState, NestedBlock, TextDelta } from '$app/interfaces/document'; -import { setCursorBeforeThunk } from '$app_reducers/document/async-actions/cursor'; +import { BlockData, BlockType, DocumentState } from '$app/interfaces/document'; import { blockConfig } from '$app/constants/document/config'; -import { newBlock } from '$app/utils/document/blocks/common'; -import { insertAfterNodeThunk } from '$app_reducers/document/async-actions/blocks'; +import { newBlock } from '$app/utils/document/block'; +import { rangeActions } from '$app_reducers/document/slice'; /** * transform to block @@ -27,10 +26,15 @@ export const turnToBlockThunk = createAsyncThunk( const parent = state.nodes[node.parent]; const children = state.children[node.children].map((id) => state.nodes[id]); - const block = newBlock(type, parent.id, data); + const block = newBlock(type, parent.id, type === BlockType.DividerBlock ? {} : data); + let caretId = block.id; // insert new block after current block - const insertHeadingAction = controller.getInsertAction(block, node.id); - + let insertActions = [controller.getInsertAction(block, node.id)]; + if (type === BlockType.DividerBlock) { + const newTextNode = newBlock(BlockType.TextBlock, parent.id, data); + insertActions.push(controller.getInsertAction(newTextNode, block.id)); + caretId = newTextNode.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 @@ -43,34 +47,36 @@ export const turnToBlockThunk = createAsyncThunk( const deleteAction = controller.getDeleteAction(node); // submit actions - await controller.applyActions([insertHeadingAction, ...moveChildrenActions, deleteAction]); + await controller.applyActions([...insertActions, ...moveChildrenActions, deleteAction]); // set cursor in new block - await dispatch(setCursorBeforeThunk({ id: block.id })); + dispatch(rangeActions.setCaret({ id: caretId, index: 0, length: 0 })); } ); /** - * turn to divider block - * 1. insert text block with delta after current block - * 2. turn current block to divider block + * transform to text block + * 1. insert text block after current block + * 2. move children to text block + * 3. delete current block */ -export const turnToDividerBlockThunk = createAsyncThunk( - 'document/turnToDividerBlock', - async (payload: { id: string; controller: DocumentController; delta: TextDelta[] }, thunkAPI) => { - const { id, controller, delta } = payload; - const { dispatch } = thunkAPI; - const { payload: newNodeId } = await dispatch( - insertAfterNodeThunk({ +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: { - delta, - }, + data, }) ); - if (!newNodeId) return; - await dispatch(turnToBlockThunk({ id, type: BlockType.DividerBlock, controller, data: {} })); - dispatch(setCursorBeforeThunk({ id: newNodeId as string })); } ); diff --git a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/slice.ts b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/slice.ts index da850f6185..5262b0e4ee 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/slice.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/slice.ts @@ -1,10 +1,10 @@ import { DocumentState, Node, - PointState, - RangeSelectionState, RectSelectionState, SlashCommandState, + RangeState, + RangeStatic, } from '@/appflowy_app/interfaces/document'; import { BlockEventPayloadPB } from '@/services/backend'; import { createSlice, PayloadAction } from '@reduxjs/toolkit'; @@ -20,9 +20,9 @@ const rectSelectionInitialState: RectSelectionState = { isDragging: false, }; -const rangeSelectionInitialState: RangeSelectionState = { +const rangeInitialState: RangeState = { isDragging: false, - selection: [], + ranges: {}, }; const slashCommandInitialState: SlashCommandState = { @@ -99,37 +99,81 @@ export const rectSelectionSlice = createSlice({ }, }); -export const rangeSelectionSlice = createSlice({ - name: 'documentRangeSelection', - initialState: rangeSelectionInitialState, +export const rangeSlice = createSlice({ + name: 'documentRange', + initialState: rangeInitialState, reducers: { + setRanges: (state, action: PayloadAction) => { + state.ranges = action.payload; + }, setRange: ( state, action: PayloadAction<{ - anchor?: PointState; - focus?: PointState; + id: string; + rangeStatic: { + index: number; + length: number; + }; }> ) => { - return { - ...state, + const { id, rangeStatic } = action.payload; + state.ranges[id] = rangeStatic; + }, + removeRange: (state, action: PayloadAction) => { + const id = action.payload; + delete state.ranges[id]; + }, + setAnchorPoint: ( + state, + action: PayloadAction<{ + id: string; + point: { x: number; y: number }; + }> + ) => { + state.anchor = action.payload; + }, + setAnchorPointRange: ( + state, + action: PayloadAction<{ + index: number; + length: number; + }> + ) => { + const anchor = state.anchor; + if (!anchor) return; + anchor.point = { + ...anchor.point, ...action.payload, }; }, - setSelection: (state, action: PayloadAction) => { - state.selection = action.payload; + setFocusPoint: ( + state, + action: PayloadAction<{ + id: string; + point: { x: number; y: number }; + }> + ) => { + state.focus = action.payload; }, setDragging: (state, action: PayloadAction) => { state.isDragging = action.payload; }, - setForward: (state, action: PayloadAction) => { - state.isForward = action.payload; + setCaret: (state, action: PayloadAction) => { + const id = action.payload.id; + state.ranges[id] = { + index: action.payload.index, + length: action.payload.length, + }; + state.caret = action.payload; }, clearRange: (state, _: PayloadAction) => { - return rangeSelectionInitialState; + state.isDragging = false; + state.ranges = {}; + state.anchor = undefined; + state.focus = undefined; }, }, }); - export const slashCommandSlice = createSlice({ name: 'documentSlashCommand', initialState: slashCommandInitialState, @@ -156,12 +200,11 @@ export const slashCommandSlice = createSlice({ export const documentReducers = { [documentSlice.name]: documentSlice.reducer, [rectSelectionSlice.name]: rectSelectionSlice.reducer, - [rangeSelectionSlice.name]: rangeSelectionSlice.reducer, + [rangeSlice.name]: rangeSlice.reducer, [slashCommandSlice.name]: slashCommandSlice.reducer, }; export const documentActions = documentSlice.actions; export const rectSelectionActions = rectSelectionSlice.actions; -export const rangeSelectionActions = rangeSelectionSlice.actions; - +export const rangeActions = rangeSlice.actions; export const slashCommandActions = slashCommandSlice.actions; diff --git a/frontend/appflowy_tauri/src/appflowy_app/utils/document/action.ts b/frontend/appflowy_tauri/src/appflowy_app/utils/document/action.ts new file mode 100644 index 0000000000..39bba2a494 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/utils/document/action.ts @@ -0,0 +1,307 @@ +import { + BlockType, + ControllerAction, + DocumentState, + NestedBlock, + RangeState, + RangeStatic, + SplitRelationship, +} from '$app/interfaces/document'; +import { getNextLineId, getPrevLineId, newBlock } from '$app/utils/document/block'; +import Delta from 'quill-delta'; +import { RootState } from '$app/stores/store'; +import { DocumentController } from '$app/stores/effects/document/document_controller'; +import { blockConfig } from '$app/constants/document/config'; +import { + caretInBottomEdgeByDelta, + caretInTopEdgeByDelta, + getDeltaText, + getIndexRelativeEnter, + getLastLineIndex, + transformIndexToNextLine, + transformIndexToPrevLine, +} from '$app/utils/document/delta'; + +export function getMiddleIdsByRange(rangeState: RangeState, document: DocumentState) { + const { anchor, focus } = rangeState; + if (!anchor || !focus) return; + if (anchor.id === focus.id) return; + const isForward = anchor.point.y < focus.point.y; + // get all ids between anchor and focus + const amendIds = []; + const startId = isForward ? anchor.id : focus.id; + const endId = isForward ? focus.id : anchor.id; + + let currentId: string | undefined = startId; + while (currentId && currentId !== endId) { + const nextId = getNextLineId(document, currentId); + if (nextId && nextId !== endId) { + amendIds.push(nextId); + } + currentId = nextId; + } + return amendIds; +} + +export function getAfterMergeCaretByRange(rangeState: RangeState, insertDelta?: Delta) { + const { anchor, focus, ranges } = rangeState; + if (!anchor || !focus) return; + if (anchor.id === focus.id) return; + + const isForward = anchor.point.y < focus.point.y; + const startId = isForward ? anchor.id : focus.id; + const startRange = ranges[startId]; + if (!startRange) return; + const offset = insertDelta ? insertDelta.length() : 0; + + return { + id: startId, + index: startRange.index + offset, + length: 0, + }; +} + +export function getStartAndEndDeltaExpectRange(state: RootState) { + const rangeState = state.documentRange; + const { anchor, focus, ranges } = rangeState; + if (!anchor || !focus) return; + if (anchor.id === focus.id) return; + + const isForward = anchor.point.y < focus.point.y; + const startId = isForward ? anchor.id : focus.id; + const endId = isForward ? focus.id : anchor.id; + + // get start and end delta + const startRange = ranges[startId]; + const endRange = ranges[endId]; + if (!startRange || !endRange) return; + const startNode = state.document.nodes[startId]; + let startDelta = new Delta(startNode.data.delta); + startDelta = startDelta.slice(0, startRange.index); + + const endNode = state.document.nodes[endId]; + let endDelta = new Delta(endNode.data.delta); + endDelta = endDelta.slice(endRange.index + endRange.length); + + return { + startNode, + endNode, + startDelta, + endDelta, + }; +} +export function getMergeEndDeltaToStartActionsByRange( + state: RootState, + controller: DocumentController, + insertDelta?: Delta +) { + const actions = []; + const { startDelta, endDelta, endNode, startNode } = getStartAndEndDeltaExpectRange(state) || {}; + if (!startDelta || !endDelta || !endNode || !startNode) return; + // merge start and end nodes + const mergeDelta = startDelta.concat(insertDelta || new Delta()).concat(endDelta); + actions.push( + controller.getUpdateAction({ + ...startNode, + data: { + delta: mergeDelta.ops, + }, + }) + ); + if (endNode.id !== startNode.id) { + // delete end node + actions.push(controller.getDeleteAction(endNode)); + } + + return actions; +} + +export function getInsertEnterNodeFields(sourceNode: NestedBlock) { + if (!sourceNode.parent) return; + const parentId = sourceNode.parent; + + const config = blockConfig[sourceNode.type].splitProps || { + nextLineRelationShip: SplitRelationship.NextSibling, + nextLineBlockType: BlockType.TextBlock, + }; + + const newNodeType = config.nextLineBlockType; + const relationShip = config.nextLineRelationShip; + const defaultData = blockConfig[newNodeType].defaultData; + // if the defaultData property is not defined for the new block type, we throw an error. + if (!defaultData) { + throw new Error(`Cannot split node of type ${sourceNode.type} to ${newNodeType}`); + } + const newParentId = relationShip === SplitRelationship.NextSibling ? parentId : sourceNode.id; + const newPrevId = relationShip === SplitRelationship.NextSibling ? sourceNode.id : ''; + + return { + parentId: newParentId, + prevId: newPrevId, + type: newNodeType, + data: defaultData, + }; +} + +export function getInsertEnterNodeAction( + sourceNode: NestedBlock, + insertNodeDelta: Delta, + controller: DocumentController +) { + const insertNodeFields = getInsertEnterNodeFields(sourceNode); + if (!insertNodeFields) return; + const { type, data, parentId, prevId } = insertNodeFields; + const insertNode = newBlock(type, parentId, { + ...data, + delta: insertNodeDelta.ops, + }); + + return { + id: insertNode.id, + action: controller.getInsertAction(insertNode, prevId), + }; +} + +export function findPrevHasDeltaNode(state: DocumentState, id: string) { + const prevLineId = getPrevLineId(state, id); + if (!prevLineId) return; + let prevLine = state.nodes[prevLineId]; + // Find the prev line that has delta + while (prevLine && !prevLine.data.delta) { + const id = getPrevLineId(state, prevLine.id); + if (!id) return; + prevLine = state.nodes[id]; + } + return prevLine; +} + +export function findNextHasDeltaNode(state: DocumentState, id: string) { + const nextLineId = getNextLineId(state, id); + if (!nextLineId) return; + let nextLine = state.nodes[nextLineId]; + // Find the next line that has delta + while (nextLine && !nextLine.data.delta) { + const id = getNextLineId(state, nextLine.id); + if (!id) return; + nextLine = state.nodes[id]; + } + return nextLine; +} + +export function isPrintableKeyEvent(event: KeyboardEvent) { + const key = event.key; + const isPrintable = key.length === 1; + + return isPrintable; +} + +export function getLeftCaretByRange(rangeState: RangeState) { + const { anchor, ranges, focus } = rangeState; + if (!anchor || !focus) return; + const isForward = anchor.point.y < focus.point.y; + const startId = isForward ? anchor.id : focus.id; + + const range = ranges[startId]; + if (!range) return; + return { + id: startId, + index: range.index, + length: 0, + }; +} + +export function getRightCaretByRange(rangeState: RangeState) { + const { anchor, focus, ranges, caret } = rangeState; + if (!anchor || !focus) return; + const isForward = anchor.point.y < focus.point.y; + const endId = isForward ? focus.id : anchor.id; + + const range = ranges[endId]; + if (!range) return; + + return { + id: endId, + index: range.index + range.length, + length: 0, + }; +} + +export function transformToPrevLineCaret(document: DocumentState, caret: RangeStatic) { + const delta = new Delta(document.nodes[caret.id].data.delta); + const inTopEdge = caretInTopEdgeByDelta(delta, caret.index); + + if (!inTopEdge) { + const index = transformIndexToPrevLine(delta, caret.index); + return { + id: caret.id, + index, + length: 0, + }; + } + const prevLine = findPrevHasDeltaNode(document, caret.id); + if (!prevLine) return; + const relativeIndex = getIndexRelativeEnter(delta, caret.index); + const prevLineIndex = getLastLineIndex(new Delta(prevLine.data.delta)); + const prevLineText = getDeltaText(new Delta(prevLine.data.delta)); + const newPrevLineIndex = prevLineIndex + relativeIndex; + const prevLineLength = prevLineText.length; + const index = newPrevLineIndex > prevLineLength ? prevLineLength : newPrevLineIndex; + return { + id: prevLine.id, + index, + length: 0, + }; +} + +export function transformToNextLineCaret(document: DocumentState, caret: RangeStatic) { + const delta = new Delta(document.nodes[caret.id].data.delta); + const inBottomEdge = caretInBottomEdgeByDelta(delta, caret.index); + if (!inBottomEdge) { + const index = transformIndexToNextLine(delta, caret.index); + return { + id: caret.id, + index, + length: 0, + }; + return; + } + + const nextLine = findNextHasDeltaNode(document, caret.id); + if (!nextLine) return; + const nextLineText = getDeltaText(new Delta(nextLine.data.delta)); + const relativeIndex = getIndexRelativeEnter(delta, caret.index); + const index = relativeIndex >= nextLineText.length ? nextLineText.length : relativeIndex; + + return { + id: nextLine.id, + index, + length: 0, + }; +} + +export function getDuplicateActions( + id: string, + parentId: string, + document: DocumentState, + controller: DocumentController +) { + const actions: ControllerAction[] = []; + const node = document.nodes[id]; + if (!node) return; + // duplicate new node + const newNode = newBlock(node.type, parentId, { + ...node.data, + }); + actions.push(controller.getInsertAction(newNode, node.id)); + const children = document.children[node.children]; + children.forEach((child) => { + const duplicateChildActions = getDuplicateActions(child, newNode.id, document, controller); + if (!duplicateChildActions) return; + actions.push(...duplicateChildActions.actions); + }); + + return { + actions, + newNodeId: newNode.id, + }; +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/utils/document/block.ts b/frontend/appflowy_tauri/src/appflowy_app/utils/document/block.ts new file mode 100644 index 0000000000..379ff44acd --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/utils/document/block.ts @@ -0,0 +1,92 @@ +import { BlockData, BlockType, DocumentState, NestedBlock } from '$app/interfaces/document'; +import { BlockPB } from '@/services/backend'; +import { Log } from '$app/utils/log'; +import { nanoid } from 'nanoid'; + +export function blockPB2Node(block: BlockPB) { + let data = {}; + try { + data = JSON.parse(block.data); + } catch { + Log.error('[Document Open] json parse error', block.data); + } + const node = { + id: block.id, + type: block.ty as BlockType, + parent: block.parent_id, + children: block.children_id, + data, + }; + return node; +} + +export function generateId() { + return nanoid(10); +} + +export function getPrevLineId(state: DocumentState, id: string) { + const node = state.nodes[id]; + if (!node.parent) return; + const parent = state.nodes[node.parent]; + const children = state.children[parent.children]; + const index = children.indexOf(id); + const prevNodeId = children[index - 1]; + const prevNode = state.nodes[prevNodeId]; + if (!prevNode) { + return parent.id; + } + // find prev line + let prevLineId = prevNode.id; + while (prevLineId) { + const prevLineChildren = state.children[state.nodes[prevLineId].children]; + if (prevLineChildren.length === 0) break; + prevLineId = prevLineChildren[prevLineChildren.length - 1]; + } + return prevLineId || parent.id; +} + +export function getNextLineId(state: DocumentState, id: string) { + const node = state.nodes[id]; + if (!node.parent) return; + + const firstChild = state.children[node.children][0]; + if (firstChild) return firstChild; + + let nextNodeId = getNextNodeId(state, id); + let parent: NestedBlock | null = state.nodes[node.parent]; + while (!nextNodeId && parent) { + nextNodeId = getNextNodeId(state, parent.id); + parent = parent.parent ? state.nodes[parent.parent] : null; + } + return nextNodeId; +} + +export function getNextNodeId(state: DocumentState, id: string) { + const node = state.nodes[id]; + if (!node.parent) return; + const parent = state.nodes[node.parent]; + const children = state.children[parent.children]; + const index = children.indexOf(id); + const nextNodeId = children[index + 1]; + return nextNodeId; +} + +export function getPrevNodeId(state: DocumentState, id: string) { + const node = state.nodes[id]; + if (!node.parent) return; + const parent = state.nodes[node.parent]; + const children = state.children[parent.children]; + const index = children.indexOf(id); + const prevNodeId = children[index - 1]; + return prevNodeId; +} + +export function newBlock(type: BlockType, parentId: string, data: BlockData): NestedBlock { + return { + id: generateId(), + type, + parent: parentId, + children: generateId(), + data, + }; +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/utils/document/blocks/code/index.ts b/frontend/appflowy_tauri/src/appflowy_app/utils/document/blocks/code/index.ts deleted file mode 100644 index 5044c31198..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/utils/document/blocks/code/index.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { getPointOfCurrentLineBeginning } from '$app/utils/document/blocks/text/delta'; -import { Editor, Transforms } from 'slate'; - -export function indent(editor: Editor, distance: number) { - const beginPoint = getPointOfCurrentLineBeginning(editor); - const emptyStr = ''.padStart(distance); - - Transforms.insertText(editor, emptyStr, { - at: beginPoint, - }); -} -export function outdent(editor: Editor, distance: number) { - const beginPoint = getPointOfCurrentLineBeginning(editor); - if (!beginPoint) return; - const afterBeginPoint = Editor.after(editor, beginPoint, { - distance, - }); - if (!afterBeginPoint) return; - const deleteChar = Editor.string(editor, { - anchor: beginPoint, - focus: afterBeginPoint, - }); - const emptyStr = ''.padStart(distance); - if (deleteChar !== emptyStr) { - if (distance > 1) { - outdent(editor, distance - 1); - } - return; - } - Transforms.delete(editor, { - at: beginPoint, - distance, - }); -} diff --git a/frontend/appflowy_tauri/src/appflowy_app/utils/document/blocks/common.ts b/frontend/appflowy_tauri/src/appflowy_app/utils/document/blocks/common.ts deleted file mode 100644 index b01c7b4d91..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/utils/document/blocks/common.ts +++ /dev/null @@ -1,220 +0,0 @@ -import { - BlockData, - BlockType, - DocumentState, - NestedBlock, - RangeSelectionState, - TextDelta, - TextSelection, -} from '$app/interfaces/document'; -import { Descendant, Element, Text } from 'slate'; -import { BlockPB } from '@/services/backend'; -import { Log } from '$app/utils/log'; -import { nanoid } from 'nanoid'; -import { clone } from '$app/utils/tool'; - -export function slateValueToDelta(slateNodes: Descendant[]) { - const element = slateNodes[0] as Element; - const children = element.children as Text[]; - return children.map((child) => { - const { text, ...attributes } = child; - return { - insert: text, - attributes, - }; - }); -} - -export function deltaToSlateValue(delta: TextDelta[]) { - const slateNode = { - type: 'paragraph', - children: [{ text: '' }], - }; - const slateNodes = [slateNode]; - if (delta.length > 0) { - slateNode.children = delta.map((d) => { - return { - ...d.attributes, - text: d.insert, - }; - }); - } - return slateNodes; -} - -export function getDeltaFromSlateNodes(slateNodes: Descendant[]) { - const element = slateNodes[0] as Element; - const children = element.children as Text[]; - return children.map((child) => { - const { text, ...attributes } = child; - return { - insert: text, - attributes, - }; - }); -} - -export function blockPB2Node(block: BlockPB) { - let data = {}; - try { - data = JSON.parse(block.data); - } catch { - Log.error('[Document Open] json parse error', block.data); - } - const node = { - id: block.id, - type: block.ty as BlockType, - parent: block.parent_id, - children: block.children_id, - data, - }; - return node; -} - -export function generateId() { - return nanoid(10); -} - -export function getPrevLineId(state: DocumentState, id: string) { - const node = state.nodes[id]; - if (!node.parent) return; - const parent = state.nodes[node.parent]; - const children = state.children[parent.children]; - const index = children.indexOf(id); - const prevNodeId = children[index - 1]; - const prevNode = state.nodes[prevNodeId]; - if (!prevNode) { - return parent.id; - } - // find prev line - let prevLineId = prevNode.id; - while (prevLineId) { - const prevLineChildren = state.children[state.nodes[prevLineId].children]; - if (prevLineChildren.length === 0) break; - prevLineId = prevLineChildren[prevLineChildren.length - 1]; - } - return prevLineId || parent.id; -} - -export function getNextLineId(state: DocumentState, id: string) { - const node = state.nodes[id]; - if (!node.parent) return; - - const firstChild = state.children[node.children][0]; - if (firstChild) return firstChild; - - let nextNodeId = getNextNodeId(state, id); - let parent: NestedBlock | null = state.nodes[node.parent]; - while (!nextNodeId && parent) { - nextNodeId = getNextNodeId(state, parent.id); - parent = parent.parent ? state.nodes[parent.parent] : null; - } - return nextNodeId; -} - -export function getNextNodeId(state: DocumentState, id: string) { - const node = state.nodes[id]; - if (!node.parent) return; - const parent = state.nodes[node.parent]; - const children = state.children[parent.children]; - const index = children.indexOf(id); - const nextNodeId = children[index + 1]; - return nextNodeId; -} - -export function getPrevNodeId(state: DocumentState, id: string) { - const node = state.nodes[id]; - if (!node.parent) return; - const parent = state.nodes[node.parent]; - const children = state.children[parent.children]; - const index = children.indexOf(id); - const prevNodeId = children[index - 1]; - return prevNodeId; -} - -export function newBlock(type: BlockType, parentId: string, data: BlockData): NestedBlock { - return { - id: generateId(), - type, - parent: parentId, - children: generateId(), - data, - }; -} - -export function getCollapsedRange(id: string, selection: TextSelection): RangeSelectionState { - const point = { - id, - selection, - }; - return { - anchor: clone(point), - focus: clone(point), - isDragging: false, - selection: [], - }; -} - -export function iterateNodes( - range: { - startId: string; - endId: string; - }, - isForward: boolean, - document: DocumentState, - callback: (nodeId?: string) => boolean -) { - const { startId, endId } = range; - let currentId = startId; - while (currentId && currentId !== endId) { - if (isForward) { - currentId = getNextLineId(document, currentId) || ''; - } else { - currentId = getPrevLineId(document, currentId) || ''; - } - if (callback(currentId)) { - break; - } - } -} -export function getNodesInRange( - range: { - startId: string; - endId: string; - }, - isForward: boolean, - document: DocumentState -) { - const nodeIds: string[] = []; - nodeIds.push(range.startId); - iterateNodes(range, isForward, document, (nodeId) => { - if (nodeId) { - nodeIds.push(nodeId); - return false; - } else { - return true; - } - }); - nodeIds.push(range.endId); - return nodeIds; -} - -export function nodeInRange( - id: string, - range: { - startId: string; - endId: string; - }, - isForward: boolean, - document: DocumentState -) { - let match = false; - iterateNodes(range, isForward, document, (nodeId) => { - if (nodeId === id) { - match = true; - return true; - } - return false; - }); - return match; -} 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 deleted file mode 100644 index 2aaae70354..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/utils/document/blocks/index.ts +++ /dev/null @@ -1,132 +0,0 @@ -import { Editor } from 'slate'; -import { - BulletListBlockData, - CalloutBlockData, - HeadingBlockData, - NumberedListBlockData, - TodoListBlockData, - ToggleListBlockData, -} from '$app/interfaces/document'; -import { getAfterRangeAt, getBeforeRangeAt, getDeltaAfterSelection } from '$app/utils/document/blocks/text/delta'; - -/** - * 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 delta = getDeltaAfterSelection(editor); - if (!delta) return; - return { - delta, - checked: !!checked, - }; -} - -/** - * get bulleted_list data from editor, only support markdown - * @param editor - */ -export function getBulletedDataFromEditor(editor: Editor): BulletListBlockData | undefined { - const delta = getDeltaAfterSelection(editor); - if (!delta) return; - return { - delta, - format: 'default', - }; -} - -/** - * get numbered_list data from editor, only support markdown - * @param editor - */ -export function getNumberedListDataFromEditor(editor: Editor): NumberedListBlockData | undefined { - const delta = getDeltaAfterSelection(editor); - if (!delta) return; - return { - delta, - format: 'default', - }; -} - -/** - * get toggle_list data from editor, only support markdown - */ -export function getToggleListDataFromEditor(editor: Editor): ToggleListBlockData | undefined { - const delta = getDeltaAfterSelection(editor); - if (!delta) return; - return { - delta, - collapsed: false, - }; -} - -/** - * get callout data from editor, only support markdown - */ -export function getCalloutDataFromEditor(editor: Editor): CalloutBlockData | undefined { - const delta = getDeltaAfterSelection(editor); - if (!delta) return; - const selection = editor.selection; - if (!selection) return; - const hashTags = Editor.string(editor, getBeforeRangeAt(editor, selection)); - const tag = hashTags.match(/(TIP|INFO|WARNING|DANGER)/g)?.[0]; - if (!tag) return; - const iconMap: Record = { - TIP: '💡', - INFO: '❗', - WARNING: '⚠️', - DANGER: '‼️', - }; - return { - delta, - icon: iconMap[tag], - }; -} - -/** - * get code block data from editor, only support markdown - */ -export function getCodeBlockDataFromEditor(editor: Editor) { - const delta = getDeltaAfterSelection(editor); - if (!delta) return; - return { - delta, - language: 'javascript', - wrap: true, - }; -} diff --git a/frontend/appflowy_tauri/src/appflowy_app/utils/document/blocks/selection.ts b/frontend/appflowy_tauri/src/appflowy_app/utils/document/blocks/selection.ts deleted file mode 100644 index 3c8cf5b3da..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/utils/document/blocks/selection.ts +++ /dev/null @@ -1,22 +0,0 @@ -export function isPointInBlock(target: HTMLElement | null) { - let node = target; - while (node) { - if (node.getAttribute('data-block-id')) { - return true; - } - node = node.parentElement; - } - return false; -} - -export function getBlockIdByPoint(target: HTMLElement | null) { - let node = target; - while (node) { - const id = node.getAttribute('data-block-id'); - if (id) { - return id; - } - node = node.parentElement; - } - return null; -} diff --git a/frontend/appflowy_tauri/src/appflowy_app/utils/document/blocks/text/delta.ts b/frontend/appflowy_tauri/src/appflowy_app/utils/document/blocks/text/delta.ts deleted file mode 100644 index 077f06ad67..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/utils/document/blocks/text/delta.ts +++ /dev/null @@ -1,378 +0,0 @@ -import { Editor, Element, Location, Text, Range } from 'slate'; -import { SelectionPoint, TextDelta, TextSelection } from '$app/interfaces/document'; -import * as Y from 'yjs'; -import { getDeltaFromSlateNodes } from '$app/utils/document/blocks/common'; - -export function getDelta(editor: Editor, at: Location): TextDelta[] { - const baseElement = Editor.fragment(editor, at)[0] as Element; - return baseElement.children.map((item) => { - const { text, ...attributes } = item as Text; - return { - insert: text, - attributes, - }; - }); -} - -export function getBeforeRangeDelta(delta: TextDelta[], range: TextSelection): TextDelta[] { - const anchor = Range.start(range); - const sliceNodes = delta.slice(0, anchor.path[1] + 1); - const sliceEnd = sliceNodes[sliceNodes.length - 1]; - const sliceEndText = sliceEnd.insert.slice(0, anchor.offset); - const sliceEndAttributes = sliceEnd.attributes; - const sliceEndNode = - sliceEndText.length > 0 - ? { - insert: sliceEndText, - attributes: sliceEndAttributes, - } - : null; - const sliceMiddleNodes = sliceNodes.slice(0, sliceNodes.length - 1); - - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - return [...sliceMiddleNodes, sliceEndNode].filter((item) => item); -} - -export function getAfterRangeDelta(delta: TextDelta[], range: TextSelection): TextDelta[] { - const focus = Range.end(range); - const sliceNodes = delta.slice(focus.path[1], delta.length); - const sliceStart = sliceNodes[0]; - const sliceStartText = sliceStart.insert.slice(focus.offset); - const sliceStartAttributes = sliceStart.attributes; - const sliceStartNode = - sliceStartText.length > 0 - ? { - insert: sliceStartText, - attributes: sliceStartAttributes, - } - : null; - const sliceMiddleNodes = sliceNodes.slice(1, sliceNodes.length); - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - return [sliceStartNode, ...sliceMiddleNodes].filter((item) => item); -} - -export function getRangeDelta(delta: TextDelta[], range: TextSelection): TextDelta[] { - const anchor = Range.start(range); - const focus = Range.end(range); - const sliceNodes = delta.slice(anchor.path[1], focus.path[1] + 1); - if (anchor.path[1] === focus.path[1]) { - return sliceNodes.map((item) => { - const { insert, attributes } = item; - const text = insert.slice(anchor.offset, focus.offset); - return { - insert: text, - attributes, - }; - }); - } - const sliceStart = sliceNodes[0]; - const sliceEnd = sliceNodes[sliceNodes.length - 1]; - const sliceStartText = sliceStart.insert.slice(anchor.offset); - const sliceEndText = sliceEnd.insert.slice(0, focus.offset); - const sliceStartAttributes = sliceStart.attributes; - const sliceEndAttributes = sliceEnd.attributes; - const sliceStartNode = - sliceStartText.length > 0 - ? { - insert: sliceStartText, - attributes: sliceStartAttributes, - } - : null; - - const sliceEndNode = - sliceEndText.length > 0 - ? { - insert: sliceEndText, - attributes: sliceEndAttributes, - } - : null; - const sliceMiddleNodes = sliceNodes.slice(1, sliceNodes.length - 1); - - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - return [sliceStartNode, ...sliceMiddleNodes, sliceEndNode].filter((item) => item); -} -/** - * get the selection between the beginning of the editor and the point - * form 0 to point - * @param editor - * @param at - */ -export function getBeforeRangeAt(editor: Editor, at: Location) { - const start = Editor.start(editor, at); - return { - anchor: { path: [0, 0], offset: 0 }, - focus: start, - }; -} - -/** - * get the selection between the point and the end of the editor - * from point to end - * @param editor - * @param at - */ -export function getAfterRangeAt(editor: Editor, at: Location) { - const end = Editor.end(editor, at); - const fragment = (editor.children[0] as Element).children; - const lastIndex = fragment.length - 1; - const lastNode = fragment[lastIndex] as Text; - return { - anchor: end, - focus: { path: [0, lastIndex], offset: lastNode.text.length }, - }; -} - -/** - * check if the point is in the beginning of the editor - * @param editor - * @param at - */ -export function pointInBegin(editor: Editor, at: Location) { - const start = Editor.start(editor, at); - return Editor.before(editor, start) === undefined; -} - -/** - * check if the point is in the end of the editor - * @param editor - * @param at - */ -export function pointInEnd(editor: Editor, at: Location) { - const end = Editor.end(editor, at); - return Editor.after(editor, end) === undefined; -} - -/** - * get the selection of the beginning of the node - */ -export function getNodeBeginSelection(): TextSelection { - const point: SelectionPoint = { - path: [0, 0], - offset: 0, - }; - const selection: TextSelection = { - anchor: clonePoint(point), - focus: clonePoint(point), - }; - return selection; -} - -export function getEditorEndPoint(editor: Editor): SelectionPoint { - const fragment = (editor.children[0] as Element).children; - const lastIndex = fragment.length - 1; - const lastNode = fragment[lastIndex] as Text; - return { path: [0, lastIndex], offset: lastNode.text.length }; -} - -/** - * get the selection of the end of the node - * @param delta - */ -export function getNodeEndSelection(delta: TextDelta[]) { - const len = delta.length; - const offset = len > 0 ? delta[len - 1].insert.length : 0; - - const cursorPoint: SelectionPoint = { - path: [0, Math.max(len - 1, 0)], - offset, - }; - - const selection: TextSelection = { - anchor: clonePoint(cursorPoint), - focus: clonePoint(cursorPoint), - }; - return selection; -} - -/** - * get lines by delta - * @param delta - */ -export function getLinesByDelta(delta: TextDelta[]): string[] { - const text = delta.map((item) => item.insert).join(''); - return text.split('\n'); -} - -/** - * get the offset of the last line - * @param delta - */ -export function getLastLineOffsetByDelta(delta: TextDelta[]): number { - const text = delta.map((item) => item.insert).join(''); - const index = text.lastIndexOf('\n'); - return index === -1 ? 0 : index + 1; -} - -/** - * get the offset of per line beginning - * @param editor - */ -export function getOffsetOfPerLineBeginning(editor: Editor): number[] { - const delta = getDeltaFromSlateNodes(editor.children); - const lines = getLinesByDelta(delta); - const offsets: number[] = []; - let offset = 0; - for (let i = 0; i < lines.length; i++) { - const lineText = lines[i] + '\n'; - offsets.push(offset); - offset += lineText.length; - } - return offsets; -} - -/** - * get the selection of the end line by offset - * @param delta - * @param offset relative offset of the end line - */ -export function getEndLineSelectionByOffset(delta: TextDelta[], offset: number) { - const lines = getLinesByDelta(delta); - const endLine = lines[lines.length - 1]; - // if the offset is greater than the length of the end line, set cursor to the end of prev line - if (offset >= endLine.length) { - return getNodeEndSelection(delta); - } - - const textOffset = getLastLineOffsetByDelta(delta) + offset; - return getSelectionByTextOffset(delta, textOffset); -} - -/** - * get the selection of the start line by offset - * @param delta - * @param offset relative offset of the start line - */ -export function getStartLineSelectionByOffset(delta: TextDelta[], offset: number) { - const lines = getLinesByDelta(delta); - if (lines.length === 0) { - return getNodeBeginSelection(); - } - const startLine = lines[0]; - // if the offset is greater than the length of the end line, set cursor to the end of prev line - if (offset >= startLine.length) { - return getSelectionByTextOffset(delta, startLine.length); - } - - return getSelectionByTextOffset(delta, offset); -} - -/** - * get the selection by text offset - * @param delta - * @param offset absolute offset - */ -export function getSelectionByTextOffset(delta: TextDelta[], offset: number) { - const point = getPointByTextOffset(delta, offset); - const selection: TextSelection = { - anchor: clonePoint(point), - focus: clonePoint(point), - }; - return selection; -} - -/** - * get the text offset by selection - * @param delta - * @param point - */ -export function getTextOffsetBySelection(delta: TextDelta[], point: SelectionPoint) { - let textOffset = 0; - for (let i = 0; i < point.path[1]; i++) { - const item = delta[i]; - textOffset += item.insert.length; - } - textOffset += point.offset; - return textOffset; -} - -/** - * get the point by text offset - * @param delta - * @param offset absolute offset - */ -export function getPointByTextOffset(delta: TextDelta[], offset: number): SelectionPoint { - let textOffset = 0; - let path: [number, number] = [0, 0]; - let textLength = 0; - for (let i = 0; i < delta.length; i++) { - const item = delta[i]; - if (textOffset + item.insert.length >= offset) { - path = [0, i]; - textLength = offset - textOffset; - break; - } - textOffset += item.insert.length; - } - - return { - path, - offset: textLength, - }; -} - -export function clonePoint(point: SelectionPoint): SelectionPoint { - return { - path: [...point.path], - offset: point.offset, - }; -} - -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()); -} - -export function getDeltaBeforeSelection(editor: Editor) { - const selection = editor.selection; - if (!selection) return; - const beforeRange = getBeforeRangeAt(editor, selection); - return getDelta(editor, beforeRange); -} - -export function getDeltaAfterSelection(editor: Editor): TextDelta[] | undefined { - const selection = editor.selection; - if (!selection) return; - const afterRange = getAfterRangeAt(editor, selection); - return getDelta(editor, afterRange); -} - -export function getSplitDelta(editor: Editor) { - // get the retain content - const retain = getDeltaBeforeSelection(editor) || []; - // get the insert content - const insert = getDeltaAfterSelection(editor) || []; - return { retain, insert }; -} - -export function getPointOfCurrentLineBeginning(editor: Editor) { - const { selection } = editor; - if (!selection) return; - const delta = getDeltaFromSlateNodes(editor.children); - const textOffset = getTextOffsetBySelection(delta, selection.anchor as SelectionPoint); - const offsets = getOffsetOfPerLineBeginning(editor); - let lineNumber = offsets.findIndex((item) => item > textOffset); - if (lineNumber === -1) { - lineNumber = offsets.length - 1; - } else { - lineNumber -= 1; - } - - const lineBeginOffset = offsets[lineNumber]; - - const beginPoint = getPointByTextOffset(delta, lineBeginOffset); - return beginPoint; -} - -export function selectionIsForward(selection: TextSelection | null) { - if (!selection) return false; - const { anchor, focus } = selection; - if (!anchor || !focus) return false; - return anchor.path[1] < focus.path[1] || (anchor.path[1] === focus.path[1] && anchor.offset < focus.offset); -} diff --git a/frontend/appflowy_tauri/src/appflowy_app/utils/document/blocks/text/hotkey.ts b/frontend/appflowy_tauri/src/appflowy_app/utils/document/blocks/text/hotkey.ts deleted file mode 100644 index 046573a32f..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/utils/document/blocks/text/hotkey.ts +++ /dev/null @@ -1,79 +0,0 @@ -import isHotkey from 'is-hotkey'; -import { Editor, Range } from 'slate'; -import { getAfterRangeAt, getBeforeRangeAt, pointInBegin, pointInEnd } from './delta'; -import { keyBoardEventKeyMap } from '$app/constants/document/text_block'; - -const HOTKEYS: Record = { - 'mod+b': 'bold', - 'mod+i': 'italic', - 'mod+u': 'underline', - 'mod+e': 'code', - 'mod+shift+X': 'strikethrough', - 'mod+shift+S': 'strikethrough', -}; - -export function canHandleBackspaceKey(event: React.KeyboardEvent, editor: Editor) { - const isBackspaceKey = isHotkey('backspace', event); - const selection = editor.selection; - - if (!isBackspaceKey || !selection) { - return false; - } - // It should be handled if the selection is collapsed and the cursor is at the beginning of the block - const isCollapsed = Range.isCollapsed(selection); - return isCollapsed && pointInBegin(editor, selection); -} - -export function canHandleUpKey(event: React.KeyboardEvent, editor: Editor) { - const isUpKey = event.key === keyBoardEventKeyMap.Up; - const selection = editor.selection; - if (!isUpKey || !selection) { - return false; - } - // It should be handled if the selection is collapsed and the cursor is at the first line of the block - const isCollapsed = Range.isCollapsed(selection); - - const beforeString = Editor.string(editor, getBeforeRangeAt(editor, selection)); - const isTopEdge = !beforeString.includes('\n'); - - return isCollapsed && isTopEdge; -} - -export function canHandleDownKey(event: React.KeyboardEvent, editor: Editor) { - const isDownKey = event.key === keyBoardEventKeyMap.Down; - const selection = editor.selection; - if (!isDownKey || !selection) { - return false; - } - // It should be handled if the selection is collapsed and the cursor is at the last line of the block - const isCollapsed = Range.isCollapsed(selection); - - const afterString = Editor.string(editor, getAfterRangeAt(editor, selection)); - const isBottomEdge = !afterString.includes('\n'); - - return isCollapsed && isBottomEdge; -} - -export function canHandleLeftKey(event: React.KeyboardEvent, editor: Editor) { - const isLeftKey = event.key === keyBoardEventKeyMap.Left; - const selection = editor.selection; - if (!isLeftKey || !selection) { - return false; - } - - // It should be handled if the selection is collapsed and the cursor is at the beginning of the block - const isCollapsed = Range.isCollapsed(selection); - - return isCollapsed && pointInBegin(editor, selection); -} - -export function canHandleRightKey(event: React.KeyboardEvent, editor: Editor) { - const isRightKey = event.key === keyBoardEventKeyMap.Right; - const selection = editor.selection; - if (!isRightKey || !selection) { - return false; - } - // It should be handled if the selection is collapsed and the cursor is at the end of the block - const isCollapsed = Range.isCollapsed(selection); - return isCollapsed && pointInEnd(editor, selection); -} diff --git a/frontend/appflowy_tauri/src/appflowy_app/utils/document/delta.ts b/frontend/appflowy_tauri/src/appflowy_app/utils/document/delta.ts new file mode 100644 index 0000000000..5821683373 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/utils/document/delta.ts @@ -0,0 +1,71 @@ +import Delta from "quill-delta"; + +export function getDeltaText(delta: Delta) { + const text = delta + .filter((op) => typeof op.insert === "string") + .map((op) => op.insert) + .join(""); + return text; +} + +export function caretInTopEdgeByDelta(delta: Delta, index: number) { + const text = getDeltaText(delta.slice(0, index)); + if (!text) return true; + + const firstLine = text.split("\n")[0]; + return index <= firstLine.length; +} + +export function caretInBottomEdgeByDelta(delta: Delta, index: number) { + const text = getDeltaText(delta.slice(index)); + + if (!text) return true; + return !text.includes("\n"); +} + +export function getLineByIndex(delta: Delta, index: number) { + const beforeText = getDeltaText(delta.slice(0, index)); + const afterText = getDeltaText(delta.slice(index)); + const beforeLines = beforeText.split("\n"); + const afterLines = afterText.split("\n"); + + const startLineText = beforeLines[beforeLines.length - 1]; + const currentLineText = startLineText + afterLines[0]; + return { + text: currentLineText, + index: beforeText.length - startLineText.length, + }; +} + +export function transformIndexToPrevLine(delta: Delta, index: number) { + const text = getDeltaText(delta.slice(0, index)); + const lines = text.split("\n"); + if (lines.length < 2) return 0; + const prevLineText = lines[lines.length - 2]; + const transformedIndex = index - prevLineText.length - 1; + return transformedIndex > 0 ? transformedIndex : 0; +} + +function getCurrentLineText(delta: Delta, index: number) { + return getLineByIndex(delta, index).text; +} + +export function transformIndexToNextLine(delta: Delta, index: number) { + const text = getDeltaText(delta); + const currentLineText = getCurrentLineText(delta, index); + const transformedIndex = index + currentLineText.length + 1; + return transformedIndex > text.length ? text.length : transformedIndex; +} + +export function getIndexRelativeEnter(delta: Delta, index: number) { + const text = getDeltaText(delta.slice(0, index)); + const beforeLines = text.split("\n"); + const beforeLineText = beforeLines[beforeLines.length - 1]; + return beforeLineText.length; +} + +export function getLastLineIndex(delta: Delta) { + const text = getDeltaText(delta); + const lastIndex = text.lastIndexOf("\n"); + return lastIndex === -1 ? 0 : lastIndex + 1; +} \ No newline at end of file diff --git a/frontend/appflowy_tauri/src/appflowy_app/utils/document/node.ts b/frontend/appflowy_tauri/src/appflowy_app/utils/document/node.ts new file mode 100644 index 0000000000..90e31085c1 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/utils/document/node.ts @@ -0,0 +1,232 @@ +function isTextNode(node: Node): boolean { + return node.nodeType === Node.TEXT_NODE; +} + +export function exclude(node: Element) { + let isPlaceholder = false; + try { + isPlaceholder = !!node.getAttribute('data-slate-placeholder'); + } catch (e) { + // ignore + } + return isPlaceholder; +} + +function findFirstTextNode(node: Node): Node | null { + if (isTextNode(node)) { + return node; + } + if (exclude && exclude(node as Element)) { + return null; + } + + const children = node.childNodes; + for (let i = 0; i < children.length; i++) { + const textNode = findFirstTextNode(children[i]); + if (textNode) { + return textNode; + } + } + + return null; +} + +export function setCursorAtStartOfNode(node: Node): void { + const range = document.createRange(); + const textNode = findFirstTextNode(node); + + if (textNode) { + range.setStart(textNode, 0); + range.collapse(true); // 将选区折叠到起始位置 + } + + const selection = window.getSelection(); + selection?.removeAllRanges(); + selection?.addRange(range); +} + +function findLastTextNode(node: Node): Node | null { + if (isTextNode(node)) { + return node; + } + + if (exclude && exclude(node as Element)) { + return null; + } + + const children = node.childNodes; + for (let i = children.length - 1; i >= 0; i--) { + const textNode = findLastTextNode(children[i]); + if (textNode) { + return textNode; + } + } + + return null; +} + +export function setCursorAtEndOfNode(node: Node): void { + const range = document.createRange(); + const textNode = findLastTextNode(node); + + if (textNode) { + const textLength = textNode.textContent?.length || 0; + range.setStart(textNode, textLength); + range.setEnd(textNode, textLength); + } + + const selection = window.getSelection(); + selection?.removeAllRanges(); + selection?.addRange(range); +} + +export function setFullRangeAtNode(node: Node): void { + const range = document.createRange(); + const firstTextNode = findFirstTextNode(node); + const lastTextNode = findLastTextNode(node); + if (!firstTextNode || !lastTextNode) return; + range.setStart(firstTextNode, 0); + range.setEnd(lastTextNode, lastTextNode.textContent?.length || 0); + const selection = window.getSelection(); + selection?.removeAllRanges(); + selection?.addRange(range); +} + +export function getBlockIdByPoint(target: HTMLElement | null) { + let node = target; + while (node) { + const id = node.getAttribute('data-block-id'); + if (id) { + return id; + } + node = node.parentElement; + } + return null; +} + +export function findTextBoxParent(target: HTMLElement | null) { + let node = target; + while (node) { + if (node.getAttribute('role') === 'textbox') { + return node; + } + node = node.parentElement; + } + return null; +} + +export function isFocused(blockId: string) { + const selection = window.getSelection(); + if (!selection) return false; + const { anchorNode, focusNode } = selection; + if (!anchorNode || !focusNode) return false; + const anchorElement = anchorNode.parentElement; + const focusElement = focusNode.parentElement; + if (!anchorElement || !focusElement) return false; + const anchorBlockId = getBlockIdByPoint(anchorElement); + const focusBlockId = getBlockIdByPoint(focusElement); + return anchorBlockId === blockId || focusBlockId === blockId; +} + +export function getNode(id: string) { + return document.querySelector(`[data-block-id="${id}"]`); +} + +export function isPointInBlock(target: HTMLElement | null) { + let node = target; + while (node) { + if (node.getAttribute('data-block-id')) { + return true; + } + node = node.parentElement; + } + return false; +} + +export function findTextNode( + node: Element, + index: number, +): { + node?: Node; + offset?: number; + remainingIndex?: number; +} { + if (isTextNode(node)) { + const textLength = node.textContent?.length || 0; + if (index <= textLength) { + return { node, offset: index }; + } + return { remainingIndex: index - textLength }; + } + + if (exclude && exclude(node)) { + return { remainingIndex: index }; + } + let remainingIndex = index; + for (const childNode of node.childNodes) { + const result = findTextNode(childNode as Element, remainingIndex); + if (result.node) { + return result; + } + remainingIndex = result.remainingIndex || index; + } + + return { remainingIndex }; +} + +export function focusNodeByIndex(node: Element, index: number, length: number) { + const textBoxNode = node.querySelector(`[role="textbox"]`); + if (!textBoxNode) return; + const anchorNode = findTextNode(textBoxNode, index); + const focusNode = findTextNode(textBoxNode, index + length); + + if (!anchorNode?.node || !focusNode?.node) return; + + const range = document.createRange(); + range.setStart(anchorNode.node, anchorNode.offset || 0); + range.setEnd(focusNode.node, focusNode.offset || 0); + + const selection = window.getSelection(); + selection?.removeAllRanges(); + selection?.addRange(range); +} + + +export function getNodeTextBoxByBlockId(blockId: string) { + const node = getNode(blockId); + return node?.querySelector(`[role="textbox"]`); +} + +export function getNodeText(node: Element) { + if (isTextNode(node)) { + return node.textContent || ''; + } + if (exclude && exclude(node)) { + return ''; + } + let text = ''; + for (const childNode of node.childNodes) { + text += getNodeText(childNode as Element); + } + return replaceZeroWidthSpace(text); +} + +export function replaceZeroWidthSpace(text: string) { + // Unicode has the following characters that are invisible and have no width: + // \u200B - zero width space + // \u200C - zero width non-joiner + // \u200D - zero width joiner + // \uFEFF - zero width no-break space + return text.replace(/[\u200B-\u200D\uFEFF]/g, ''); +} + +export function findParent(node: Element, parentSelector: string) { + let parentNode: Element | null = node; + while (parentNode) { + if (parentNode.matches(parentSelector)) { + return parentNode; + } + parentNode = parentNode.parentElement; + } + return null; +} \ No newline at end of file diff --git a/frontend/appflowy_tauri/src/appflowy_app/utils/document/quill_editor.ts b/frontend/appflowy_tauri/src/appflowy_app/utils/document/quill_editor.ts new file mode 100644 index 0000000000..e29c0fd72d --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/utils/document/quill_editor.ts @@ -0,0 +1,59 @@ +import { Op } from 'quill-delta'; +import { TextAction } from '$app/interfaces/document'; + +export function adaptDeltaForQuill(inputOps: Op[], isOutput = false): Op[] { + if (inputOps.length === 0) { + return inputOps; + } + + // quill attribute -> custom attribute + const attributeMapping = { + strike: TextAction.Strikethrough, + }; + + const newOps = inputOps.map((op) => { + if (!op.attributes) return op; + const newOpAttributes = { ...op.attributes }; + + Object.entries(attributeMapping).forEach(([attribute, customAttribute]) => { + if (isOutput) { + if (attribute in newOpAttributes) { + newOpAttributes[customAttribute] = newOpAttributes[attribute]; + delete newOpAttributes[attribute]; + } + } else { + if (customAttribute in newOpAttributes) { + newOpAttributes[attribute] = newOpAttributes[customAttribute]; + delete newOpAttributes[customAttribute]; + } + } + }); + + return { + ...op, + attributes: newOpAttributes, + }; + }); + + const lastOpIndex = newOps.length - 1; + const lastOp = newOps[lastOpIndex]; + const text = lastOp.insert as string; + const endsWithNewline = text.endsWith('\n'); + + if (isOutput && !endsWithNewline) { + return newOps; + } + + if (isOutput) { + const newText = text.slice(0, -1); + if (newText !== '') { + newOps[lastOpIndex] = { ...lastOp, insert: newText }; + } else { + newOps.pop(); + } + } else { + newOps.push({ insert: '\n' }); + } + + return newOps; +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/utils/document/slate_editor.ts b/frontend/appflowy_tauri/src/appflowy_app/utils/document/slate_editor.ts new file mode 100644 index 0000000000..1002ff39ce --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/utils/document/slate_editor.ts @@ -0,0 +1,134 @@ +import { BaseElement, BasePoint, Descendant, Editor, Element, Selection, Text } from "slate"; +import Delta from "quill-delta"; +import { getLineByIndex } from "$app/utils/document/delta"; + +export function convertToSlateSelection(index: number, length: number, slateValue: Descendant[]){ + if (!slateValue || slateValue.length === 0) return null; + const texts = (slateValue[0] as BaseElement).children.map((child) => (child as Text).text); + const anchorIndex = index; + const focusIndex = index + length; + let anchorPath: number[] = []; + let focusPath: number[] = []; + let anchorOffset = 0; + let focusOffset = 0; + let charCount = 0; + texts.forEach((text, i) => { + const endOffset = charCount + text.length; + if (anchorIndex >= charCount && anchorIndex <= endOffset) { + anchorPath = [0, i]; + anchorOffset = anchorIndex - charCount; + } + if (focusIndex >= charCount && focusIndex <= endOffset) { + focusPath = [0, i]; + focusOffset = focusIndex - charCount; + } + charCount += text.length; + }); + return { + anchor: { + path: anchorPath, + offset: anchorOffset, + }, + focus: { + path: focusPath, + offset: focusOffset, + }, + }; +} + +export function converToIndexLength(editor: Editor, range: Selection) { + if (!range) return null; + const start = Editor.start(editor, [0, 0]); + const before = Editor.start(editor, range); + const after = Editor.end(editor, range); + const index = Editor.string(editor, { + anchor: start, + focus: before, + }).length; + const focusIndex = Editor.string(editor, { + anchor: start, + focus: after, + }).length; + const length = focusIndex - index; + return { index, length }; +} + +export function convertToSlateValue(delta: Delta): Descendant[] { + const ops = delta.ops; + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + const children: Text[] = + ops.length === 0 + ? [ + { + text: '', + }, + ] + : ops.map((op) => ({ + text: op.insert || '', + ...op.attributes, + })); + + return [ + { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + type: 'paragraph', + children, + }, + ]; +} + +export function convertToDelta(slateValue: Descendant[]) { + const ops = (slateValue[0] as Element).children.map((child) => { + const { text, ...attributes } = child as Text; + return { + insert: text, + attributes, + }; + }); + return new Delta(ops); +} + +function getBreakLineBeginPoint(editor: Editor, at: Selection): BasePoint | undefined { + const delta = convertToDelta(editor.children); + const currentSelection = converToIndexLength(editor, at); + if (!currentSelection) return; + const { index } = getLineByIndex(delta, currentSelection.index); + const selection = convertToSlateSelection(index, 0, editor.children); + return selection?.anchor; +} + +export function indent(editor: Editor, distance: number) { + const beginPoint = getBreakLineBeginPoint(editor, editor.selection); + if (!beginPoint) return; + const emptyStr = "".padStart(distance); + + editor.insertText(emptyStr, { + at: beginPoint + }); +} + +export function outdent(editor: Editor, distance: number) { + const beginPoint = getBreakLineBeginPoint(editor, editor.selection); + if (!beginPoint) return; + const afterBeginPoint = Editor.after(editor, beginPoint, { + distance + }); + if (!afterBeginPoint) return; + const deleteChar = Editor.string(editor, { + anchor: beginPoint, + focus: afterBeginPoint + }); + const emptyStr = "".padStart(distance); + if (deleteChar !== emptyStr) { + if (distance > 1) { + outdent(editor, distance - 1); + } + return; + } + editor.delete({ + at: beginPoint, + distance + }); +} \ No newline at end of file diff --git a/frontend/appflowy_tauri/src/appflowy_app/utils/document/toolbar.ts b/frontend/appflowy_tauri/src/appflowy_app/utils/document/toolbar.ts index 1ce16134f6..a024bfd208 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/utils/document/toolbar.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/utils/document/toolbar.ts @@ -1,4 +1,4 @@ -export function calcToolbarPosition(toolbarDom: HTMLDivElement) { +export function calcToolbarPosition(toolbarDom: HTMLDivElement, node: Element, container: HTMLDivElement) { const domSelection = window.getSelection(); let domRange; if (domSelection?.rangeCount === 0) { @@ -7,13 +7,27 @@ export function calcToolbarPosition(toolbarDom: HTMLDivElement) { domRange = domSelection?.getRangeAt(0); } + const nodeRect = node.getBoundingClientRect(); const rect = domRange?.getBoundingClientRect() || { top: 0, left: 0, width: 0, height: 0 }; - let top = rect.top - toolbarDom.offsetHeight; - let left = rect.left - toolbarDom.offsetWidth / 2 + rect.width / 2; + const top = rect.top - nodeRect.top - toolbarDom.offsetHeight; + let left = rect.left - nodeRect.left - toolbarDom.offsetWidth / 2 + rect.width / 2; + + // fix toolbar position when it is out of the container + const containerRect = container.getBoundingClientRect(); + const leftBound = containerRect.left - nodeRect.left; + const rightBound = containerRect.right; + + const rightThreshold = 20; + if (left < leftBound) { + left = leftBound; + } else if (left + nodeRect.left + toolbarDom.offsetWidth > rightBound) { + left = rightBound - toolbarDom.offsetWidth - nodeRect.left - rightThreshold; + } + return { - top: top + 'px', - left: left + 'px', + top, + left, }; } diff --git a/frontend/appflowy_tauri/tailwind.config.cjs b/frontend/appflowy_tauri/tailwind.config.cjs index cc80504561..f72417dcdb 100644 --- a/frontend/appflowy_tauri/tailwind.config.cjs +++ b/frontend/appflowy_tauri/tailwind.config.cjs @@ -46,6 +46,10 @@ module.exports = { 3: '#E2E4EB', fiol: '#2C144B', }, + custom: { + code: 'rgba(221, 221, 221, 0.4)', + caret: 'rgb(55, 53, 47)' + } }, boxShadow: { md: '0px 0px 20px rgba(0, 0, 0, 0.1);',