diff --git a/frontend/appflowy_tauri/.eslintrc.cjs b/frontend/appflowy_tauri/.eslintrc.cjs index 430fe259d7..a1160f0bd3 100644 --- a/frontend/appflowy_tauri/.eslintrc.cjs +++ b/frontend/appflowy_tauri/.eslintrc.cjs @@ -11,6 +11,7 @@ module.exports = { project: 'tsconfig.json', sourceType: 'module', tsconfigRootDir: __dirname, + extraFileExtensions: ['.json'], }, plugins: ['@typescript-eslint', "react-hooks"], rules: { @@ -68,5 +69,5 @@ module.exports = { ] }, - ignorePatterns: ['src/**/*.test.ts', 'package.json'], + ignorePatterns: ['src/**/*.test.ts', '**/__tests__/**/*.json', 'package.json'] }; diff --git a/frontend/appflowy_tauri/jest.config.cjs b/frontend/appflowy_tauri/jest.config.cjs index 4d71088823..4939478165 100644 --- a/frontend/appflowy_tauri/jest.config.cjs +++ b/frontend/appflowy_tauri/jest.config.cjs @@ -1,5 +1,6 @@ const { compilerOptions } = require('./tsconfig.json'); const { pathsToModuleNameMapper } = require("ts-jest"); +const esModules = ["lodash-es", "nanoid"].join("|"); /** @type {import('ts-jest').JestConfigWithTsJest} */ module.exports = { @@ -7,12 +8,14 @@ module.exports = { testEnvironment: 'node', roots: [''], modulePaths: [compilerOptions.baseUrl], - moduleNameMapper: pathsToModuleNameMapper(compilerOptions.paths), + moduleNameMapper: { + ...pathsToModuleNameMapper(compilerOptions.paths), + "^lodash-es(/(.*)|$)": "lodash$1", + "^nanoid(/(.*)|$)": "nanoid$1", + }, "transform": { "(.*)/node_modules/nanoid/.+\\.(j|t)sx?$": "ts-jest" }, - "transformIgnorePatterns": [ - "node_modules/(?!nanoid/.*)" - ], + "transformIgnorePatterns": [`/node_modules/(?!${esModules})`], "testRegex": "(/__tests__/.*\.(test|spec))\\.(jsx?|tsx?)$", }; \ No newline at end of file diff --git a/frontend/appflowy_tauri/package.json b/frontend/appflowy_tauri/package.json index a42c3691fe..50383a257e 100644 --- a/frontend/appflowy_tauri/package.json +++ b/frontend/appflowy_tauri/package.json @@ -26,8 +26,8 @@ "@mui/material": "^5.11.12", "@mui/system": "^5.14.4", "@mui/x-date-pickers-pro": "^6.18.2", - "@reduxjs/toolkit": "^1.9.2", - "@slate-yjs/core": "^1.0.0", + "@reduxjs/toolkit": "2.0.0", + "@slate-yjs/core": "^1.0.2", "@tanstack/react-virtual": "3.0.0-beta.54", "@tauri-apps/api": "^1.2.0", "@types/react-swipeable-views": "^0.13.4", @@ -41,6 +41,7 @@ "i18next-resources-to-backend": "^1.1.4", "is-hotkey": "^0.2.0", "jest": "^29.5.0", + "js-base64": "^3.7.5", "katex": "^0.16.7", "lodash-es": "^4.17.21", "nanoid": "^4.0.0", @@ -55,6 +56,7 @@ "react-datepicker": "^4.23.0", "react-dom": "^18.2.0", "react-error-boundary": "^3.1.4", + "react-hot-toast": "^2.4.1", "react-i18next": "^12.2.0", "react-katex": "^3.0.1", "react-redux": "^8.0.5", @@ -67,8 +69,9 @@ "react18-input-otp": "^1.1.2", "redux": "^4.2.1", "rxjs": "^7.8.0", - "slate": "^0.94.1", - "slate-react": "^0.94.2", + "slate": "^0.101.4", + "slate-history": "^0.100.0", + "slate-react": "^0.101.3", "ts-results": "^3.3.0", "utf8": "^3.0.0", "valtio": "^1.12.1", diff --git a/frontend/appflowy_tauri/pnpm-lock.yaml b/frontend/appflowy_tauri/pnpm-lock.yaml index 695c178cde..1e64b1db12 100644 --- a/frontend/appflowy_tauri/pnpm-lock.yaml +++ b/frontend/appflowy_tauri/pnpm-lock.yaml @@ -26,11 +26,11 @@ dependencies: specifier: ^6.18.2 version: 6.18.2(@emotion/react@11.11.0)(@emotion/styled@11.11.0)(@mui/material@5.13.0)(@mui/system@5.14.4)(@types/react@18.2.6)(dayjs@1.11.9)(react-dom@18.2.0)(react@18.2.0) '@reduxjs/toolkit': - specifier: ^1.9.2 - version: 1.9.5(react-redux@8.0.5)(react@18.2.0) + specifier: 2.0.0 + version: 2.0.0(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) + specifier: ^1.0.2 + version: 1.0.2(slate@0.101.4)(yjs@13.6.1) '@tanstack/react-virtual': specifier: 3.0.0-beta.54 version: 3.0.0-beta.54(react@18.2.0) @@ -70,6 +70,9 @@ dependencies: jest: specifier: ^29.5.0 version: 29.5.0(@types/node@18.16.9) + js-base64: + specifier: ^3.7.5 + version: 3.7.5 katex: specifier: ^0.16.7 version: 0.16.7 @@ -112,6 +115,9 @@ dependencies: react-error-boundary: specifier: ^3.1.4 version: 3.1.4(react@18.2.0) + react-hot-toast: + specifier: ^2.4.1 + version: 2.4.1(csstype@3.1.2)(react-dom@18.2.0)(react@18.2.0) react-i18next: specifier: ^12.2.0 version: 12.2.2(i18next@22.4.15)(react-dom@18.2.0)(react@18.2.0) @@ -149,11 +155,14 @@ dependencies: specifier: ^7.8.0 version: 7.8.1 slate: - specifier: ^0.94.1 - version: 0.94.1 + specifier: ^0.101.4 + version: 0.101.4 + slate-history: + specifier: ^0.100.0 + version: 0.100.0(slate@0.101.4) slate-react: - specifier: ^0.94.2 - version: 0.94.2(react-dom@18.2.0)(react@18.2.0)(slate@0.94.1) + specifier: ^0.101.3 + version: 0.101.3(react-dom@18.2.0)(react@18.2.0)(slate@0.101.4) ts-results: specifier: ^3.3.0 version: 3.3.0 @@ -1780,23 +1789,23 @@ packages: /@popperjs/core@2.11.8: resolution: {integrity: sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==} - /@reduxjs/toolkit@1.9.5(react-redux@8.0.5)(react@18.2.0): - resolution: {integrity: sha512-Rt97jHmfTeaxL4swLRNPD/zV4OxTes4la07Xc4hetpUW/vc75t5m1ANyxG6ymnEQ2FsLQsoMlYB2vV1sO3m8tQ==} + /@reduxjs/toolkit@2.0.0(react-redux@8.0.5)(react@18.2.0): + resolution: {integrity: sha512-Kq/a+aO28adYdPoNEu9p800MYPKoUc0tlkYfv035Ief9J7MPq8JvmT7UdpYhvXsoMtOdt567KwZjc9H3Rf8yjg==} peerDependencies: react: ^16.9.0 || ^17.0.0 || ^18 - react-redux: ^7.2.1 || ^8.0.2 + react-redux: ^7.2.1 || ^8.1.3 || ^9.0.0 peerDependenciesMeta: react: optional: true react-redux: optional: true dependencies: - immer: 9.0.21 + immer: 10.0.3 react: 18.2.0 react-redux: 8.0.5(@types/react-dom@18.2.4)(@types/react@18.2.6)(react-dom@18.2.0)(react@18.2.0)(redux@4.2.1) - redux: 4.2.1 - redux-thunk: 2.4.2(redux@4.2.1) - reselect: 4.1.8 + redux: 5.0.0 + redux-thunk: 3.1.0(redux@5.0.0) + reselect: 5.0.1 dev: false /@remix-run/router@1.6.1: @@ -1834,14 +1843,14 @@ packages: dependencies: '@sinonjs/commons': 3.0.0 - /@slate-yjs/core@1.0.0(slate@0.94.1)(yjs@13.6.1): - resolution: {integrity: sha512-G83+qvXtsMTP3kWu216GjhyeHlvKHX5kWaPf2JiG2uF5/YShUqjAVjDr/htKoKJsOl+IqK679lvLKeBYh7SYZQ==} + /@slate-yjs/core@1.0.2(slate@0.101.4)(yjs@13.6.1): + resolution: {integrity: sha512-X0hLFJbQu9c1ItWBaNuEn0pqcXYK76KCp8C4Gvy/VaTQVMo1VgAb2WiiJ0Je/AyuIYEPPSTNVOcyrGHwgA7e6Q==} peerDependencies: slate: '>=0.70.0' yjs: ^13.5.29 dependencies: - slate: 0.94.1 - y-protocols: 1.0.5 + slate: 0.101.4 + y-protocols: 1.0.6(yjs@13.6.1) yjs: 13.6.1 dev: false @@ -2164,8 +2173,13 @@ packages: hoist-non-react-statics: 3.3.2 dev: false + /@types/is-hotkey@0.1.10: + resolution: {integrity: sha512-RvC8KMw5BCac1NvRRyaHgMMEtBaZ6wh0pyPTBu7izn4Sj/AX9Y4aXU5c7rX8PnM/knsuUpC1IeoBkANtxBypsQ==} + dev: false + /@types/is-hotkey@0.1.7: resolution: {integrity: sha512-yB5C7zcOM7idwYZZ1wKQ3pTfjA9BbvFqRWvKB46GFddxnJtHwi/b9y84ykQtxQPg5qhdpg4Q/kWU3EGoCTmLzQ==} + dev: true /@types/istanbul-lib-coverage@2.0.4: resolution: {integrity: sha512-z/QT1XN4K4KYuslS23k62yDIDLwLFkzxOuMplDtObz0+y7VqJCaO2o+SPwHCvLFZh7xazvvoor2tA/hPz9ee7g==} @@ -2218,6 +2232,10 @@ packages: /@types/lodash@4.14.194: resolution: {integrity: sha512-r22s9tAS7imvBt2lyHC9B8AGwWnXaYb1tY09oyLkXDs4vArpYJzw09nj8MLx5VfciBPGIb+ZwG0ssYnEPJxn/g==} + /@types/lodash@4.14.202: + resolution: {integrity: sha512-OvlIYQK9tNneDlS0VN54LLd5uiPCBOp7gS5Z0f1mjoJYBrtStzgmJBxONW3U6OZqdtNzZPmn9BS/7WI7BFFcFQ==} + dev: false + /@types/node@18.16.9: resolution: {integrity: sha512-IeB32oIV4oGArLrd7znD2rkHQ6EDCM+2Sr76dJnrHwv9OHBTTM6nuDLK9bmikXzPa0ZlWMWtRGo/Uw4mrzQedA==} @@ -3004,8 +3022,8 @@ packages: resolution: {integrity: sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==} engines: {node: '>= 12'} - /compute-scroll-into-view@1.0.20: - resolution: {integrity: sha512-UCB0ioiyj8CRjtrvaceBLqqhZCVP+1B8+NWQhmdsm0VXOJtobBCf1dBQmebCCo34qZmUwZfIH2MZLqNHazrfjg==} + /compute-scroll-into-view@3.1.0: + resolution: {integrity: sha512-rj8l8pD4bJ1nx+dAkMhV1xB5RuZEyVysfxJqB1pRchh1KVvwOv9b7CGB8ZfjTImVv2oF+sYMUkMZq6Na5Ftmbg==} dev: false /concat-map@0.0.1: @@ -3891,6 +3909,14 @@ packages: slash: 3.0.0 dev: true + /goober@2.1.13(csstype@3.1.2): + resolution: {integrity: sha512-jFj3BQeleOoy7t93E9rZ2de+ScC4lQICLwiAQmKMg9F6roKGaLSHoCDYKkWlSafg138jejvq/mTdvmnwDQgqoQ==} + peerDependencies: + csstype: ^3.0.10 + dependencies: + csstype: 3.1.2 + dev: false + /google-protobuf@3.21.2: resolution: {integrity: sha512-3MSOYFO5U9mPGikIYCzK0SaThypfGgS6bHqrUGXG3DPHCrb+txNqeEcns1W0lkGfk0rCyNXm7xB9rMxnCiZOoA==} dev: false @@ -4029,8 +4055,8 @@ packages: engines: {node: '>= 4'} dev: true - /immer@9.0.21: - resolution: {integrity: sha512-bc4NBHqOqSfRW7POMkHd51LvClaeMXpm8dx0e8oE2GORbq5aRK7Bxl4FyzVLdGtLmvLKL7BTDBG5ACQm4HWjTA==} + /immer@10.0.3: + resolution: {integrity: sha512-pwupu3eWfouuaowscykeckFmVTpqbzW+rXFCX8rQLkZzM9ftBmU/++Ra+o+L27mz03zJTlyV4UUr+fdKNffo4A==} dev: false /import-fresh@3.3.0: @@ -4146,10 +4172,6 @@ packages: is-extglob: 2.1.1 dev: true - /is-hotkey@0.1.8: - resolution: {integrity: sha512-qs3NZ1INIS+H+yeo7cD9pDfwYV/jqRh1JG9S9zYrNudkoUQg7OL7ziXqRKu+InFjUIDoP2o6HIkLYMh1pcWgyQ==} - dev: false - /is-hotkey@0.2.0: resolution: {integrity: sha512-UknnZK4RakDmTgz4PI1wIph5yxSs/mvChWs9ifnlXsKuXgWmOkY/hAE0H/k2MIqH0RlRye0i1oC07MCRSD28Mw==} dev: false @@ -4755,6 +4777,10 @@ packages: hasBin: true dev: true + /js-base64@3.7.5: + resolution: {integrity: sha512-3MEt5DTINKqfScXKfJFrRbxkrnk2AxPWGBL/ycjz4dK8iqiSJ06UxD8jh8xuh6p10TX4t2+7FsBYVxxQbMg+qA==} + dev: false + /js-sdsl@4.4.0: resolution: {integrity: sha512-FfVSdx6pJ41Oa+CF7RDaFmTnCaFhua+SNYQX74riGOpl96x+2jQCqEfQ2bnXu/5DPCqlRuiqyvTJM0Qjz26IVg==} dev: true @@ -4893,6 +4919,14 @@ packages: isomorphic.js: 0.2.5 dev: false + /lib0@0.2.88: + resolution: {integrity: sha512-KyroiEvCeZcZEMx5Ys+b4u4eEBbA1ch7XUaBhYpwa/nPMrzTjUhI4RfcytmQfYoTBPcdyx+FX6WFNIoNuJzJfQ==} + engines: {node: '>=16'} + hasBin: true + dependencies: + isomorphic.js: 0.2.5 + dev: false + /lilconfig@2.1.0: resolution: {integrity: sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==} engines: {node: '>=10'} @@ -5658,6 +5692,20 @@ packages: /react-fast-compare@3.2.2: resolution: {integrity: sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ==} + /react-hot-toast@2.4.1(csstype@3.1.2)(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-j8z+cQbWIM5LY37pR6uZR6D4LfseplqnuAO4co4u8917hBUvXlEqyP1ZzqVLcqoyUesZZv/ImreoCeHVDpE5pQ==} + engines: {node: '>=10'} + peerDependencies: + react: '>=16' + react-dom: '>=16' + dependencies: + goober: 2.1.13(csstype@3.1.2) + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + transitivePeerDependencies: + - csstype + dev: false + /react-i18next@12.2.2(i18next@22.4.15)(react-dom@18.2.0)(react@18.2.0): resolution: {integrity: sha512-KBB6buBmVKXUWNxXHdnthp+38gPyBT46hJCAIQ8rX19NFL/m2ahte2KARfIDf2tMnSAL7wwck6eDOd/9zn6aFg==} peerDependencies: @@ -5932,12 +5980,12 @@ packages: picomatch: 2.3.1 dev: true - /redux-thunk@2.4.2(redux@4.2.1): - resolution: {integrity: sha512-+P3TjtnP0k/FEjcBL5FZpoovtvrTNT/UXd4/sluaSyrURlSlhLSzEdfsTBW7WsKB6yPvgd7q/iZPICFjW4o57Q==} + /redux-thunk@3.1.0(redux@5.0.0): + resolution: {integrity: sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==} peerDependencies: - redux: ^4 + redux: ^5.0.0 dependencies: - redux: 4.2.1 + redux: 5.0.0 dev: false /redux@4.2.1: @@ -5946,6 +5994,10 @@ packages: '@babel/runtime': 7.21.5 dev: false + /redux@5.0.0: + resolution: {integrity: sha512-blLIYmYetpZMET6Q6uCY7Jtl/Im5OBldy+vNPauA8vvsdqyt66oep4EUpAMWNHauTC6xa9JuRPhRB72rY82QGA==} + dev: false + /regenerator-runtime@0.12.1: resolution: {integrity: sha512-odxIc1/vDlo4iZcfXqRYFj0vpXFNoGdKMAUieAlFYO6m/nl5e9KR/beGf41z4a1FI+aQgtjhuaSlDxQ0hmkrHg==} dev: false @@ -5973,8 +6025,8 @@ packages: resolution: {integrity: sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==} dev: true - /reselect@4.1.8: - resolution: {integrity: sha512-ab9EmR80F/zQTMNeneUr4cv+jSwPJgIlvEmVwLerwrWVbpLlBuls9XHzIeTFy4cegU2NHBp3va0LKOzU5qFEYQ==} + /reselect@5.0.1: + resolution: {integrity: sha512-D72j2ubjgHpvuCiORWkOUxndHJrxDaSolheiz5CO+roz8ka97/4msh2E8F5qay4GawR5vzBt5MkbDHT+Rdy/Wg==} dev: false /resolve-cwd@3.0.0: @@ -6075,10 +6127,10 @@ packages: dependencies: loose-envify: 1.4.0 - /scroll-into-view-if-needed@2.2.31: - resolution: {integrity: sha512-dGCXy99wZQivjmjIqihaBQNjryrz5rueJY7eHfTdyWEiR4ttYpsajb14rn9s5d4DY4EcY6+4+U/maARBXJedkA==} + /scroll-into-view-if-needed@3.1.0: + resolution: {integrity: sha512-49oNpRjWRvnU8NyGVmUaYG4jtTkNonFZI86MmGRDqBphEK2EXT9gdEUoQPZhuBM8yWHxCWbobltqYO5M4XrUvQ==} dependencies: - compute-scroll-into-view: 1.0.20 + compute-scroll-into-view: 3.1.0 dev: false /semver@6.3.0: @@ -6140,31 +6192,40 @@ packages: resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} engines: {node: '>=8'} - /slate-react@0.94.2(react-dom@18.2.0)(react@18.2.0)(slate@0.94.1): - resolution: {integrity: sha512-4wDSuTuGBkdQ609CS55uc2Yhfa5but21usBgAtCVhPJQazL85kzN2vUUYTmGb7d/mpP9tdnJiVPopIyhqlRJ8Q==} + /slate-history@0.100.0(slate@0.101.4): + resolution: {integrity: sha512-x5rUuWLNtH97hs9PrFovGgt3Qc5zkTm/5mcUB+0NR/TK923eLax4HsL6xACLHMs245nI6aJElyM1y6hN0y5W/Q==} peerDependencies: - react: '>=16.8.0' - react-dom: '>=16.8.0' slate: '>=0.65.3' + dependencies: + is-plain-object: 5.0.0 + slate: 0.101.4 + dev: false + + /slate-react@0.101.3(react-dom@18.2.0)(react@18.2.0)(slate@0.101.4): + resolution: {integrity: sha512-KMXK9FLeS7HYhhoVcI8SUi4Qp1I9C1lTQ2EgbPH95sVXfH/vq+hbhurEGIGCe0VQ9Opj4rSKJIv/g7De1+nJMA==} + peerDependencies: + react: '>=18.2.0' + react-dom: '>=18.2.0' + slate: '>=0.99.0' dependencies: '@juggle/resize-observer': 3.4.0 - '@types/is-hotkey': 0.1.7 - '@types/lodash': 4.14.194 + '@types/is-hotkey': 0.1.10 + '@types/lodash': 4.14.202 direction: 1.0.4 - is-hotkey: 0.1.8 + is-hotkey: 0.2.0 is-plain-object: 5.0.0 lodash: 4.17.21 react: 18.2.0 react-dom: 18.2.0(react@18.2.0) - scroll-into-view-if-needed: 2.2.31 - slate: 0.94.1 - tiny-invariant: 1.0.6 + scroll-into-view-if-needed: 3.1.0 + slate: 0.101.4 + tiny-invariant: 1.3.1 dev: false - /slate@0.94.1: - resolution: {integrity: sha512-GH/yizXr1ceBoZ9P9uebIaHe3dC/g6Plpf9nlUwnvoyf6V1UOYrRwkabtOCd3ZfIGxomY4P7lfgLr7FPH8/BKA==} + /slate@0.101.4: + resolution: {integrity: sha512-8LazZrNDsYFKDg1wpb0HouAfX5Pw/UmOZ/vIrtqD2GSCDZvraOkV2nVJ9Ery8kIlsU1jeybwgcaCy4KkVwfvEg==} dependencies: - immer: 9.0.21 + immer: 10.0.3 is-plain-object: 5.0.0 tiny-warning: 1.0.3 dev: false @@ -6420,10 +6481,6 @@ packages: any-promise: 1.3.0 dev: true - /tiny-invariant@1.0.6: - resolution: {integrity: sha512-FOyLWWVjG+aC0UqG76V53yAWdXfH8bO6FNmyZOuUrzDzK8DI3/JRY25UD7+g49JWM1LXwymsKERB+DzI0dTEQA==} - dev: false - /tiny-invariant@1.3.1: resolution: {integrity: sha512-AD5ih2NlSssTCwsMznbvwMZpJ1cbhkGd2uueNxzv2jDlEeZdU04JQfRnggJQ8DrcVBGjAsCKwFBbDlVNtEMlzw==} dev: false @@ -6922,10 +6979,14 @@ packages: engines: {node: '>=0.4'} dev: true - /y-protocols@1.0.5: - resolution: {integrity: sha512-Wil92b7cGk712lRHDqS4T90IczF6RkcvCwAD0A2OPg+adKmOe+nOiT/N2hvpQIWS3zfjmtL4CPaH5sIW1Hkm/A==} + /y-protocols@1.0.6(yjs@13.6.1): + resolution: {integrity: sha512-vHRF2L6iT3rwj1jub/K5tYcTT/mEYDUppgNPXwp8fmLpui9f7Yeq3OEtTLVF012j39QnV+KEQpNqoN7CWU7Y9Q==} + engines: {node: '>=16.0.0', npm: '>=8.0.0'} + peerDependencies: + yjs: ^13.0.0 dependencies: - lib0: 0.2.74 + lib0: 0.2.88 + yjs: 13.6.1 dev: false /y18n@5.0.8: diff --git a/frontend/appflowy_tauri/src/appflowy_app/AppMain.hooks.ts b/frontend/appflowy_tauri/src/appflowy_app/AppMain.hooks.ts index 00a6e5a21c..ea052d869a 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/AppMain.hooks.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/AppMain.hooks.ts @@ -2,7 +2,7 @@ import { useAppDispatch, useAppSelector } from '$app/stores/store'; import { useCallback, useEffect, useMemo } from 'react'; import { UserSettingController } from '$app/stores/effects/user/user_setting_controller'; import { currentUserActions } from '$app_reducers/current-user/slice'; -import { Theme as ThemeType, ThemeMode } from '$app/interfaces'; +import { Theme as ThemeType, ThemeMode } from '$app/stores/reducers/current-user/slice'; import { createTheme } from '@mui/material/styles'; import { getDesignTokens } from '$app/utils/mui'; import { useTranslation } from 'react-i18next'; diff --git a/frontend/appflowy_tauri/src/appflowy_app/AppMain.tsx b/frontend/appflowy_tauri/src/appflowy_app/AppMain.tsx index bde13f7b17..f5e5359888 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/AppMain.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/AppMain.tsx @@ -4,7 +4,6 @@ import { ProtectedRoutes } from '$app/components/auth/ProtectedRoutes'; import { AllIcons } from '$app/components/tests/AllIcons'; import { ColorPalette } from '$app/components/tests/ColorPalette'; import { TestAPI } from '$app/components/tests/TestAPI'; -import { DocumentPage } from '$app/views/DocumentPage'; import { BoardPage } from '$app/views/BoardPage'; import { DatabasePage } from '$app/views/DatabasePage'; import { LoginPage } from '$app/views/LoginPage'; @@ -15,6 +14,7 @@ import { ThemeProvider } from '@mui/material'; import { useUserSetting } from '$app/AppMain.hooks'; import { UserSettingControllerContext } from '$app/components/_shared/app-hooks/useUserSettingControllerContext'; import TrashPage from '$app/views/TrashPage'; +import DocumentPage from '$app/views/DocumentPage'; function AppMain() { const { muiTheme, userSettingController } = useUserSetting(); diff --git a/frontend/appflowy_tauri/src/appflowy_app/application/document/document.types.ts b/frontend/appflowy_tauri/src/appflowy_app/application/document/document.types.ts new file mode 100644 index 0000000000..33d56d4acb --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/application/document/document.types.ts @@ -0,0 +1,225 @@ +import { Op } from 'quill-delta'; +import { HTMLAttributes, MutableRefObject } from 'react'; +import { Element } from 'slate'; +import { ViewIconTypePB, ViewLayoutPB } from '@/services/backend'; +import { YXmlText } from 'yjs/dist/src/types/YXmlText'; + +export interface EditorNode { + id: string; + type: EditorNodeType; + parent?: string | null; + data?: unknown; + children?: string; + externalId?: string; + externalType?: string; +} + +export interface ParagraphNode extends Element { + type: EditorNodeType.Paragraph; +} + +export interface HeadingNode extends Element { + type: EditorNodeType.HeadingBlock; + data: { + level: number; + }; +} + +export interface GridNode extends Element { + type: EditorNodeType.GridBlock; + data: { + viewId?: string; + }; +} + +export interface TodoListNode extends Element { + type: EditorNodeType.TodoListBlock; + data: { + checked: boolean; + }; +} + +export interface CodeNode extends Element { + type: EditorNodeType.CodeBlock; + data: { + language: string; + }; +} + +export interface QuoteNode extends Element { + type: EditorNodeType.QuoteBlock; +} + +export interface NumberedListNode extends Element { + type: EditorNodeType.NumberedListBlock; +} + +export interface BulletedListNode extends Element { + type: EditorNodeType.BulletedListBlock; +} + +export interface ToggleListNode extends Element { + type: EditorNodeType.ToggleListBlock; + data: { + collapsed: boolean; + }; +} + +export interface DividerNode extends Element { + type: EditorNodeType.DividerBlock; +} + +export interface CalloutNode extends Element { + type: EditorNodeType.CalloutBlock; + data: { + icon: string; + }; +} + +export interface MathEquationNode extends Element { + type: EditorNodeType.EquationBlock; + data: { + formula?: string; + }; +} + +export interface FormulaNode extends Element { + type: EditorInlineNodeType.Formula; +} + +export interface MentionNode extends Element { + type: EditorInlineNodeType.Mention; + data: Mention; +} + +export interface EditorData { + viewId: string; + rootId: string; + // key: block's id, value: block + nodeMap: Record; + // key: block's children id, value: block's id + childrenMap: Record; + // key: block's children id, value: block's id + relativeMap: Record; + // key: block's externalId, value: delta + deltaMap: Record; + // key: block's externalId, value: block's id + externalIdMap: Record; +} + +export interface MentionPage { + id: string; + name: string; + layout: ViewLayoutPB; + icon?: { + ty: ViewIconTypePB; + value: string; + }; +} + +export interface EditorProps { + id: string; + sharedType?: YXmlText; + appendTextRef?: MutableRefObject<((text: string) => void) | null>; +} + +export enum EditorNodeType { + Paragraph = 'paragraph', + HeadingBlock = 'heading', + TodoListBlock = 'todo_list', + BulletedListBlock = 'bulleted_list', + NumberedListBlock = 'numbered_list', + ToggleListBlock = 'toggle_list', + CodeBlock = 'code', + EquationBlock = 'math_equation', + QuoteBlock = 'quote', + CalloutBlock = 'callout', + DividerBlock = 'divider', + ImageBlock = 'image', + GridBlock = 'grid', +} + +export enum EditorInlineNodeType { + Mention = 'mention', + Formula = 'formula', +} + +export const inlineNodeTypes: (string | EditorInlineNodeType)[] = [ + EditorInlineNodeType.Mention, + EditorInlineNodeType.Formula, +]; + +export interface EditorElementProps extends HTMLAttributes { + node: T; +} + +export interface EditorInlineAttributes { + bold?: boolean; + italic?: boolean; + underline?: boolean; + strikethrough?: boolean; + font_color?: string; + bg_color?: string; + href?: string; + code?: boolean; + formula?: boolean; + prism_token?: string; + mention?: { + type: string; + // inline page ref id + page?: string; + // reminder date ref id + date?: string; + }; +} + +export enum EditorMarkFormat { + Bold = 'bold', + Italic = 'italic', + Underline = 'underline', + StrikeThrough = 'strikethrough', + Code = 'code', + Formula = 'formula', +} + +export enum EditorStyleFormat { + FontColor = 'font_color', + BackgroundColor = 'bg_color', + Href = 'href', +} + +export const markTypes: string[] = [ + EditorMarkFormat.Bold, + EditorMarkFormat.Italic, + EditorMarkFormat.Underline, + EditorMarkFormat.StrikeThrough, + EditorMarkFormat.Code, + EditorMarkFormat.Formula, + EditorStyleFormat.Href, + EditorStyleFormat.FontColor, + EditorStyleFormat.BackgroundColor, +]; + +export enum EditorTurnFormat { + Paragraph = 'paragraph', + Heading1 = 'heading1', // 'heading1' is a special format, it's not a slate node type, but a slate node type's data + Heading2 = 'heading2', + Heading3 = 'heading3', + TodoList = 'todo_list', + BulletedList = 'bulleted_list', + NumberedList = 'numbered_list', + Quote = 'quote', + ToggleList = 'toggle_list', +} + +export enum MentionType { + PageRef = 'page', + Date = 'date', +} + +export interface Mention { + // inline page ref id + page?: string; + // reminder date ref id + date?: string; +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/application/document/document_service.ts b/frontend/appflowy_tauri/src/appflowy_app/application/document/document_service.ts new file mode 100644 index 0000000000..ff53a2651b --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/application/document/document_service.ts @@ -0,0 +1,127 @@ +import { + ApplyActionPayloadPB, + BlockActionPB, + BlockPB, + OpenDocumentPayloadPB, + TextDeltaPayloadPB, +} from '@/services/backend'; +import { + DocumentEventApplyAction, + DocumentEventApplyTextDeltaEvent, + DocumentEventOpenDocument, +} from '@/services/backend/events/flowy-document2'; +import get from 'lodash-es/get'; +import { EditorData, EditorNodeType } from '$app/application/document/document.types'; +import { Log } from '$app/utils/log'; + +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 EditorNodeType, + parent: block.parent_id, + children: block.children_id, + data, + externalId: block.external_id, + externalType: block.external_type, + }; + + return node; +} + +export const BLOCK_MAP_NAME = 'blocks'; +export const META_NAME = 'meta'; +export const CHILDREN_MAP_NAME = 'children_map'; + +export const TEXT_MAP_NAME = 'text_map'; +export const EQUATION_PLACEHOLDER = '$'; +export async function openDocument(docId: string): Promise { + const payload = OpenDocumentPayloadPB.fromObject({ + document_id: docId, + }); + + const result = await DocumentEventOpenDocument(payload); + + if (!result.ok) { + return Promise.reject(result.val); + } + + const documentDataPB = result.val; + + if (!documentDataPB) { + return Promise.reject('documentDataPB is null'); + } + + const data: EditorData = { + viewId: docId, + rootId: documentDataPB.page_id, + nodeMap: {}, + childrenMap: {}, + relativeMap: {}, + deltaMap: {}, + externalIdMap: {}, + }; + + get(documentDataPB, BLOCK_MAP_NAME).forEach((block) => { + Object.assign(data.nodeMap, { + [block.id]: blockPB2Node(block), + }); + data.relativeMap[block.children_id] = block.id; + if (block.external_id) { + data.externalIdMap[block.external_id] = block.id; + } + }); + + get(documentDataPB, [META_NAME, CHILDREN_MAP_NAME]).forEach((child, key) => { + const blockId = data.relativeMap[key]; + + data.childrenMap[blockId] = child.children; + }); + + get(documentDataPB, [META_NAME, TEXT_MAP_NAME]).forEach((delta, key) => { + const blockId = data.externalIdMap[key]; + + data.deltaMap[blockId] = delta ? JSON.parse(delta) : []; + }); + + return data; +} + +export async function applyActions(docId: string, actions: ReturnType[]) { + if (actions.length === 0) return; + const payload = ApplyActionPayloadPB.fromObject({ + document_id: docId, + actions: actions, + }); + + const result = await DocumentEventApplyAction(payload); + + if (!result.ok) { + return Promise.reject(result.val); + } + + return result.val; +} + +export async function applyText(docId: string, textId: string, delta: string) { + const payload = TextDeltaPayloadPB.fromObject({ + document_id: docId, + text_id: textId, + delta: delta, + }); + + const res = await DocumentEventApplyTextDeltaEvent(payload); + + if (!res.ok) { + return Promise.reject(res.val); + } + + return res.val; +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/application/notification.ts b/frontend/appflowy_tauri/src/appflowy_app/application/notification.ts new file mode 100644 index 0000000000..d76c6c8db9 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/application/notification.ts @@ -0,0 +1,129 @@ +import { listen } from '@tauri-apps/api/event'; +import { SubscribeObject } from '@/services/backend/models/flowy-notification'; +import { + DatabaseFieldChangesetPB, + DatabaseNotification, + DocEventPB, + DocumentNotification, + FieldPB, + FieldSettingsPB, + FilterChangesetNotificationPB, + GroupChangesPB, + GroupRowsNotificationPB, + ReorderAllRowsPB, + ReorderSingleRowPB, + RowsChangePB, + RowsVisibilityChangePB, + SortChangesetNotificationPB, +} from '@/services/backend'; + +const Notification = { + [DatabaseNotification.DidUpdateViewRowsVisibility]: RowsVisibilityChangePB, + [DatabaseNotification.DidUpdateViewRows]: RowsChangePB, + [DatabaseNotification.DidReorderRows]: ReorderAllRowsPB, + [DatabaseNotification.DidReorderSingleRow]: ReorderSingleRowPB, + [DatabaseNotification.DidUpdateFields]: DatabaseFieldChangesetPB, + [DatabaseNotification.DidGroupByField]: GroupChangesPB, + [DatabaseNotification.DidUpdateNumOfGroups]: GroupChangesPB, + [DatabaseNotification.DidUpdateGroupRow]: GroupRowsNotificationPB, + [DatabaseNotification.DidUpdateField]: FieldPB, + [DatabaseNotification.DidUpdateCell]: null, + [DatabaseNotification.DidUpdateSort]: SortChangesetNotificationPB, + [DatabaseNotification.DidUpdateFieldSettings]: FieldSettingsPB, + [DatabaseNotification.DidUpdateFilter]: FilterChangesetNotificationPB, + [DocumentNotification.DidReceiveUpdate]: DocEventPB, +}; + +type NotificationMap = typeof Notification; +export type NotificationEnum = keyof NotificationMap; +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type NullableInstanceType any) | null> = K extends abstract new ( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ...args: any +) => // eslint-disable-next-line @typescript-eslint/no-explicit-any +any + ? InstanceType + : void; +export type NotificationHandler = (result: NullableInstanceType) => void; + +/** + * Subscribes to a set of notifications. + * + * This function subscribes to notifications defined by the `NotificationEnum` and + * calls the appropriate `NotificationHandler` when each type of notification is received. + * + * @param {Object} callbacks - An object containing handlers for various notification types. + * Each key is a `NotificationEnum` value, and the corresponding value is a `NotificationHandler` function. + * + * @param {Object} [options] - Optional settings for the subscription. + * @param {string} [options.id] - An optional ID. If provided, only notifications with a matching ID will be processed. + * + * @returns {Promise<() => void>} A Promise that resolves to an unsubscribe function. + * + * @example + * subscribeNotifications({ + * [DatabaseNotification.DidUpdateField]: (result) => { + * if (result.err) { + * // process error + * return; + * } + * + * console.log(result.val); // result.val is FieldPB + * }, + * [DatabaseNotification.DidReorderRows]: (result) => { + * if (result.err) { + * // process error + * return; + * } + * + * console.log(result.val); // result.val is ReorderAllRowsPB + * }, + * }, { id: '123' }) + * .then(unsubscribe => { + * // Do something + * // ... + * // To unsubscribe, call `unsubscribe()` + * }); + * + * @throws {Error} Throws an error if unable to subscribe. + */ +export function subscribeNotifications( + callbacks: { + [K in NotificationEnum]?: NotificationHandler; + }, + options?: { id?: string } +): Promise<() => void> { + return listen>('af-notification', (event) => { + const subject = SubscribeObject.fromObject(event.payload); + const { id, ty } = subject; + + if (options?.id !== undefined && id !== options.id) { + return; + } + + const notification = ty as NotificationEnum; + const pb = Notification[notification]; + const callback = callbacks[notification] as NotificationHandler; + + if (pb === undefined || !callback) { + return; + } + + if (subject.has_error) { + // const error = FlowyError.deserialize(subject.error); + return; + } else { + const { payload } = subject; + + pb ? callback(pb.deserialize(payload)) : callback(); + } + }); +} + +export function subscribeNotification( + notification: K, + callback: NotificationHandler, + options?: { id?: string } +): Promise<() => void> { + return subscribeNotifications({ [notification]: callback }, options); +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/assets/Icons 16/Page.svg b/frontend/appflowy_tauri/src/appflowy_app/assets/Icons 16/Page.svg new file mode 100644 index 0000000000..f0fb8ce9ce --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/assets/Icons 16/Page.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/appflowy_tauri/src/appflowy_app/assets/align-center.svg b/frontend/appflowy_tauri/src/appflowy_app/assets/align-center.svg new file mode 100644 index 0000000000..f4f4999514 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/assets/align-center.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend/appflowy_tauri/src/appflowy_app/assets/align-left.svg b/frontend/appflowy_tauri/src/appflowy_app/assets/align-left.svg new file mode 100644 index 0000000000..23957285c7 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/assets/align-left.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend/appflowy_tauri/src/appflowy_app/assets/align-right.svg b/frontend/appflowy_tauri/src/appflowy_app/assets/align-right.svg new file mode 100644 index 0000000000..bca2d14fc7 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/assets/align-right.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend/appflowy_tauri/src/appflowy_app/assets/bold.svg b/frontend/appflowy_tauri/src/appflowy_app/assets/bold.svg new file mode 100644 index 0000000000..878b6329b3 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/assets/bold.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_tauri/src/appflowy_app/assets/date.svg b/frontend/appflowy_tauri/src/appflowy_app/assets/date.svg new file mode 100644 index 0000000000..78243f1e75 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/assets/date.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/frontend/appflowy_tauri/src/appflowy_app/assets/h1.svg b/frontend/appflowy_tauri/src/appflowy_app/assets/h1.svg new file mode 100644 index 0000000000..b33bd52135 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/assets/h1.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/appflowy_tauri/src/appflowy_app/assets/h2.svg b/frontend/appflowy_tauri/src/appflowy_app/assets/h2.svg new file mode 100644 index 0000000000..7449c57391 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/assets/h2.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/appflowy_tauri/src/appflowy_app/assets/h3.svg b/frontend/appflowy_tauri/src/appflowy_app/assets/h3.svg new file mode 100644 index 0000000000..0976945974 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/assets/h3.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/appflowy_tauri/src/appflowy_app/assets/hide-menu.svg b/frontend/appflowy_tauri/src/appflowy_app/assets/hide-menu.svg new file mode 100644 index 0000000000..ce88af8ea7 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/assets/hide-menu.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/frontend/appflowy_tauri/src/appflowy_app/assets/inline-code.svg b/frontend/appflowy_tauri/src/appflowy_app/assets/inline-code.svg new file mode 100644 index 0000000000..3585603096 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/assets/inline-code.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/appflowy_tauri/src/appflowy_app/assets/italic.svg b/frontend/appflowy_tauri/src/appflowy_app/assets/italic.svg new file mode 100644 index 0000000000..b295c230f0 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/assets/italic.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_tauri/src/appflowy_app/assets/link.svg b/frontend/appflowy_tauri/src/appflowy_app/assets/link.svg new file mode 100644 index 0000000000..5fbcc8d787 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/assets/link.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/appflowy_tauri/src/appflowy_app/assets/list-dropdown.svg b/frontend/appflowy_tauri/src/appflowy_app/assets/list-dropdown.svg new file mode 100644 index 0000000000..4a8424c5f8 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/assets/list-dropdown.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/appflowy_tauri/src/appflowy_app/assets/list.svg b/frontend/appflowy_tauri/src/appflowy_app/assets/list.svg new file mode 100644 index 0000000000..97a2e9c434 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/assets/list.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/frontend/appflowy_tauri/src/appflowy_app/assets/mention.svg b/frontend/appflowy_tauri/src/appflowy_app/assets/mention.svg new file mode 100644 index 0000000000..b98318132c --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/assets/mention.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_tauri/src/appflowy_app/assets/numbers.svg b/frontend/appflowy_tauri/src/appflowy_app/assets/numbers.svg new file mode 100644 index 0000000000..9d8b98d10d --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/assets/numbers.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_tauri/src/appflowy_app/assets/quote.svg b/frontend/appflowy_tauri/src/appflowy_app/assets/quote.svg new file mode 100644 index 0000000000..57839231ff --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/assets/quote.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/appflowy_tauri/src/appflowy_app/assets/database/select-check.svg b/frontend/appflowy_tauri/src/appflowy_app/assets/select-check.svg similarity index 100% rename from frontend/appflowy_tauri/src/appflowy_app/assets/database/select-check.svg rename to frontend/appflowy_tauri/src/appflowy_app/assets/select-check.svg diff --git a/frontend/appflowy_tauri/src/appflowy_app/assets/show-menu.svg b/frontend/appflowy_tauri/src/appflowy_app/assets/show-menu.svg new file mode 100644 index 0000000000..8baf55bffd --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/assets/show-menu.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/frontend/appflowy_tauri/src/appflowy_app/assets/strikethrough.svg b/frontend/appflowy_tauri/src/appflowy_app/assets/strikethrough.svg new file mode 100644 index 0000000000..c118422a15 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/assets/strikethrough.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/appflowy_tauri/src/appflowy_app/assets/text.svg b/frontend/appflowy_tauri/src/appflowy_app/assets/text.svg new file mode 100644 index 0000000000..7befa5080f --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/assets/text.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/appflowy_tauri/src/appflowy_app/assets/todo-list.svg b/frontend/appflowy_tauri/src/appflowy_app/assets/todo-list.svg new file mode 100644 index 0000000000..37f52c47ed --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/assets/todo-list.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/appflowy_tauri/src/appflowy_app/assets/underline.svg b/frontend/appflowy_tauri/src/appflowy_app/assets/underline.svg new file mode 100644 index 0000000000..f5d53f0ec2 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/assets/underline.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/BlockDraggable/BlockDragDropContext.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/BlockDraggable/BlockDragDropContext.tsx deleted file mode 100644 index 735a5cdded..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/BlockDraggable/BlockDragDropContext.tsx +++ /dev/null @@ -1,124 +0,0 @@ -import React, { useCallback, useEffect, useRef } from 'react'; -import { blockDraggableActions, DraggableContext, DragInsertType } from '$app_reducers/block-draggable/slice'; -import { useAppDispatch, useAppSelector } from '$app/stores/store'; -import { collisionNode, getDragDropContext, scrollIntoViewIfNeeded } from '$app/utils/draggable'; -import { onDragEndThunk } from '$app_reducers/block-draggable/async_actions'; -import { getBlock } from '$app/components/document/_shared/SubscribeNode.hooks'; -import { blockConfig } from '$app/constants/document/config'; - -function BlockDragDropContext({ children }: { children: React.ReactNode }) { - const shadowRef = useRef(null); - const dispatch = useAppDispatch(); - const { dragging, draggingId, dragShadowVisible, draggingPosition } = useAppSelector((state) => state.blockDraggable); - - const registerDraggableEvents = useCallback( - (id: string) => { - const onDrag = (event: MouseEvent) => { - const data = collisionNode(event, id); - - let dropContext: DraggableContext | undefined; - const dropId = data?.id; - let insertType = data?.insertType; - - if (dropId) { - const context = getDragDropContext(dropId); - const contextId = context?.contextId; - const container = context?.container; - - if (container) { - dropContext = { - type: context.type, - contextId: context.contextId, - }; - - scrollIntoViewIfNeeded(event, container as HTMLDivElement); - } - - if (contextId) { - const block = getBlock(contextId, dropId); - - if (block) { - const config = blockConfig[block.type]; - - if (!config.canAddChild && insertType === DragInsertType.CHILD) { - insertType = DragInsertType.AFTER; - } - } - } - } - - dispatch( - blockDraggableActions.drag({ - draggingPosition: { - x: event.clientX, - y: event.clientY, - }, - insertType, - dropId, - dropContext, - }) - ); - }; - - const unlisten = () => { - document.removeEventListener('mousemove', onDrag); - document.removeEventListener('mouseup', onDragEnd); - }; - - const onDragEnd = () => { - void dispatch(onDragEndThunk()); - unlisten(); - }; - - document.addEventListener('mousemove', onDrag); - document.addEventListener('mouseup', onDragEnd); - return unlisten; - }, - [dispatch] - ); - - useEffect(() => { - if (!dragging || !draggingId) return; - return registerDraggableEvents(draggingId); - }, [dragging, draggingId, registerDraggableEvents]); - - useEffect(() => { - if (!shadowRef.current) return; - if (!dragShadowVisible) { - shadowRef.current.innerHTML = ''; - return; - } - - const shadow = shadowRef.current; - - const draggingNode = document.querySelector(`[data-draggable-id="${draggingId}"]`); - - if (!draggingNode) return; - const nodeWidth = draggingNode.clientWidth; - const nodeHeight = draggingNode.clientHeight; - const clone = draggingNode.cloneNode(true); - - shadow.style.width = `${nodeWidth}px`; - shadow.style.height = `${nodeHeight}px`; - shadow.appendChild(clone); - }, [dragShadowVisible, draggingId]); - - return ( - <> - {children} -
- - ); -} - -export default BlockDragDropContext; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/BlockDraggable/BlockDraggable.hooks.ts b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/BlockDraggable/BlockDraggable.hooks.ts deleted file mode 100644 index 17057b3a61..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/BlockDraggable/BlockDraggable.hooks.ts +++ /dev/null @@ -1,79 +0,0 @@ -import React, { useCallback, useMemo } from 'react'; -import { useAppDispatch, useAppSelector } from '$app/stores/store'; -import { blockDraggableActions, BlockDraggableType, DragInsertType } from '$app_reducers/block-draggable/slice'; -import { getDragDropContext } from '$app/utils/draggable'; - -export function useDraggableState(id: string, type: BlockDraggableType) { - const dispatch = useAppDispatch(); - const { dropState, isDragging } = useAppSelector((state) => { - const draggableState = state.blockDraggable; - const isDragging = draggableState.dragging && draggableState.draggingId === id; - - if (draggableState.dropId === id) { - return { - dropState: { - dropId: draggableState.dropId, - insertType: draggableState.insertType, - }, - isDragging, - }; - } - - return { - dropState: null, - isDragging, - }; - }); - - const onDragStart = useCallback( - (event: React.MouseEvent | MouseEvent) => { - if (event.button !== 0) return; - - event.preventDefault(); - event.stopPropagation(); - const { clientY: y, clientX: x } = event; - - const context = getDragDropContext(id); - - if (!context) return; - - dispatch( - blockDraggableActions.startDrag({ - startDraggingPosition: { - x, - y, - }, - draggingId: id, - draggingContext: { - type, - contextId: context.contextId, - }, - }) - ); - }, - [dispatch, id, type] - ); - - const beforeDropping = useMemo(() => { - if (!dropState) return false; - return dropState.insertType === DragInsertType.BEFORE; - }, [dropState]); - - const afterDropping = useMemo(() => { - if (!dropState) return false; - return dropState.insertType === DragInsertType.AFTER; - }, [dropState]); - - const childDropping = useMemo(() => { - if (!dropState) return false; - return dropState.insertType === DragInsertType.CHILD; - }, [dropState]); - - return { - onDragStart, - beforeDropping, - afterDropping, - childDropping, - isDragging, - }; -} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/BlockDraggable/index.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/BlockDraggable/index.tsx deleted file mode 100644 index 9ae0ebc545..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/BlockDraggable/index.tsx +++ /dev/null @@ -1,76 +0,0 @@ -import React, { HTMLAttributes, useEffect } from 'react'; -import { useDraggableState } from '$app/components/_shared/BlockDraggable/BlockDraggable.hooks'; -import { BlockDraggableType } from '$app_reducers/block-draggable/slice'; - -function BlockDraggable( - { - id, - type, - children, - getAnchorEl, - className, - ...props - }: { - id: string; - type: BlockDraggableType; - children: React.ReactNode; - getAnchorEl?: () => HTMLElement | null; - } & HTMLAttributes, - ref: React.Ref -) { - const { onDragStart, beforeDropping, afterDropping, childDropping } = useDraggableState(id, type); - - const commonCls = 'pointer-events-none absolute z-10 w-[100%] bg-fill-hover transition-all duration-200'; - - useEffect(() => { - if (!getAnchorEl) return; - const el = getAnchorEl(); - - if (!el) return; - el.addEventListener('mousedown', onDragStart); - return () => { - el.removeEventListener('mousedown', onDragStart); - }; - }, [getAnchorEl, onDragStart]); - return ( - <> -
- { -
- } - - {children} - { -
- } - { -
- } -
- - ); -} - -export default React.memo(React.forwardRef(BlockDraggable)); diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/EmojiPicker/EmojiPickerHeader.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/EmojiPicker/EmojiPickerHeader.tsx index 89d80c6064..06f96e7397 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/EmojiPicker/EmojiPickerHeader.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/EmojiPicker/EmojiPickerHeader.tsx @@ -3,7 +3,7 @@ import { Box, IconButton } from '@mui/material'; import { Circle, DeleteOutlineRounded, SearchOutlined } from '@mui/icons-material'; import TextField from '@mui/material/TextField'; import Tooltip from '@mui/material/Tooltip'; -import { randomEmoji } from '$app/utils/document/emoji'; +import { randomEmoji } from '$app/utils/emoji'; import ShuffleIcon from '@mui/icons-material/Shuffle'; import Popover from '@mui/material/Popover'; import { useSelectSkinPopoverProps } from '$app/components/_shared/EmojiPicker/EmojiPicker.hooks'; @@ -58,6 +58,7 @@ function EmojiPickerHeader({ onEmojiSelect, onSkinSelect, searchValue, onSearchC onChange={(e) => { onSearchChange(e.target.value); }} + autoFocus={true} label={t('search.label')} variant='standard' /> diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/KatexMath/index.css b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/KatexMath/index.css similarity index 100% rename from frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/KatexMath/index.css rename to frontend/appflowy_tauri/src/appflowy_app/components/_shared/KatexMath/index.css diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/KatexMath/index.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/KatexMath/index.tsx similarity index 100% rename from frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/KatexMath/index.tsx rename to frontend/appflowy_tauri/src/appflowy_app/components/_shared/KatexMath/index.tsx diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/ViewTitle/ViewIconGroup.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/ViewTitle/ViewIconGroup.tsx index 33c5e3cf37..68377951e8 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/ViewTitle/ViewIconGroup.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/ViewTitle/ViewIconGroup.tsx @@ -1,7 +1,7 @@ import { useTranslation } from 'react-i18next'; import { PageIcon } from '$app_reducers/pages/slice'; import React, { useCallback } from 'react'; -import { randomEmoji } from '$app/utils/document/emoji'; +import { randomEmoji } from '$app/utils/emoji'; import { EmojiEmotionsOutlined } from '@mui/icons-material'; import Button from '@mui/material/Button'; @@ -13,7 +13,7 @@ interface Props { function ViewIconGroup({ icon, onUpdateIcon }: Props) { const { t } = useTranslation(); - const showAddIcon = !icon; + const showAddIcon = !icon?.value; const onAddIcon = useCallback(() => { const emoji = randomEmoji(); diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/ViewTitle/ViewTitleInput.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/ViewTitle/ViewTitleInput.tsx new file mode 100644 index 0000000000..5e5c4d06d2 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/ViewTitle/ViewTitleInput.tsx @@ -0,0 +1,55 @@ +import React, { FormEventHandler, memo, useCallback, useRef } from 'react'; +import { TextareaAutosize } from '@mui/material'; +import { useTranslation } from 'react-i18next'; + +function ViewTitleInput({ + value, + onChange, + onSplitTitle, +}: { + value: string; + onChange: (value: string) => void; + onSplitTitle?: (splitText: string) => void; +}) { + const { t } = useTranslation(); + const textareaRef = useRef(null); + + const onTitleChange: FormEventHandler = (e) => { + const value = e.currentTarget.value; + + onChange(value); + }; + + const handleBreakLine = useCallback(() => { + if (!onSplitTitle) return; + const selectionStart = textareaRef.current?.selectionStart; + + if (value) { + const newValue = value.slice(0, selectionStart); + + onChange(newValue); + onSplitTitle(value.slice(selectionStart)); + } + }, [onSplitTitle, onChange, value]); + + return ( + { + if (e.key === 'Enter') { + e.preventDefault(); + e.stopPropagation(); + handleBreakLine(); + } + }} + /> + ); +} + +export default memo(ViewTitleInput); diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/ViewTitle/index.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/ViewTitle/index.tsx index 7ad0fb86e7..71216f129f 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/ViewTitle/index.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/ViewTitle/index.tsx @@ -1,28 +1,23 @@ -import React, { FormEventHandler, useCallback, useRef, useState } from 'react'; +import React, { useCallback, useEffect, useState } from 'react'; import ViewBanner from '$app/components/_shared/ViewTitle/ViewBanner'; import { Page, PageIcon } from '$app_reducers/pages/slice'; import { ViewIconTypePB } from '@/services/backend'; -import { TextareaAutosize } from '@mui/material'; -import { useTranslation } from 'react-i18next'; +import ViewTitleInput from '$app/components/_shared/ViewTitle/ViewTitleInput'; interface Props { view: Page; onTitleChange: (title: string) => void; onUpdateIcon: (icon: PageIcon) => void; + onSplitTitle?: (splitText: string) => void; } -function ViewTitle({ view, onTitleChange: onTitleChangeProp, onUpdateIcon: onUpdateIconProp }: Props) { - const { t } = useTranslation(); - const textareaRef = useRef(null); +function ViewTitle({ view, onTitleChange, onUpdateIcon: onUpdateIconProp, onSplitTitle }: Props) { const [hover, setHover] = useState(false); const [icon, setIcon] = useState(view.icon); - const defaultValue = useRef(view.name); - const onTitleChange: FormEventHandler = (e) => { - const value = e.currentTarget.value; - - onTitleChangeProp(value); - }; + useEffect(() => { + setIcon(view.icon); + }, [view.icon]); const onUpdateIcon = useCallback( (icon: string) => { @@ -38,17 +33,14 @@ function ViewTitle({ view, onTitleChange: onTitleChangeProp, onUpdateIcon: onUpd ); return ( -
setHover(true)} onMouseLeave={() => setHover(false)}> +
setHover(true)} + onMouseLeave={() => setHover(false)} + >
- +
); diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/drag-block/drag.hooks.ts b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/drag-block/drag.hooks.ts new file mode 100644 index 0000000000..b7607616de --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/drag-block/drag.hooks.ts @@ -0,0 +1,79 @@ +import React from 'react'; + +interface Props { + dragId?: string; + onEnd?: (result: { dragId: string; position: 'before' | 'after' | 'inside' }) => void; +} + +function calcPosition(targetRect: DOMRect, clientY: number) { + const top = targetRect.top + targetRect.height / 3; + const bottom = targetRect.bottom - targetRect.height / 3; + + if (clientY < top) return 'before'; + if (clientY > bottom) return 'after'; + return 'inside'; +} + +export function useDrag(props: Props) { + const { dragId, onEnd } = props; + const [isDraggingOver, setIsDraggingOver] = React.useState(false); + const [isDragging, setIsDragging] = React.useState(false); + const [dropPosition, setDropPosition] = React.useState<'before' | 'after' | 'inside'>(); + const onDrop = (e: React.DragEvent) => { + e.stopPropagation(); + setIsDraggingOver(false); + setIsDragging(false); + setDropPosition(undefined); + const dragId = e.dataTransfer.getData('dragId'); + const targetRect = e.currentTarget.getBoundingClientRect(); + const { clientY } = e; + + const position = calcPosition(targetRect, clientY); + + onEnd && onEnd({ dragId, position }); + }; + + const onDragOver = (e: React.DragEvent) => { + e.stopPropagation(); + e.preventDefault(); + if (isDragging) return; + setIsDraggingOver(true); + const targetRect = e.currentTarget.getBoundingClientRect(); + const { clientY } = e; + const position = calcPosition(targetRect, clientY); + + setDropPosition(position); + }; + + const onDragLeave = (e: React.DragEvent) => { + e.stopPropagation(); + setIsDraggingOver(false); + setDropPosition(undefined); + }; + + const onDragStart = (e: React.DragEvent) => { + if (!dragId) return; + e.stopPropagation(); + e.dataTransfer.setData('dragId', dragId); + e.dataTransfer.effectAllowed = 'move'; + setIsDragging(true); + }; + + const onDragEnd = (e: React.DragEvent) => { + e.stopPropagation(); + setIsDragging(false); + setIsDraggingOver(false); + setDropPosition(undefined); + }; + + return { + onDrop, + onDragOver, + onDragLeave, + onDragStart, + isDraggingOver, + isDragging, + onDragEnd, + dropPosition, + }; +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/drag-block/index.ts b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/drag-block/index.ts new file mode 100644 index 0000000000..e0cb540f75 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/drag-block/index.ts @@ -0,0 +1 @@ +export * from './drag.hooks'; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/Database.hooks.ts b/frontend/appflowy_tauri/src/appflowy_app/components/database/Database.hooks.ts index 5a8daddabf..36ab3a385a 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/Database.hooks.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/Database.hooks.ts @@ -3,7 +3,7 @@ import { useSearchParams } from 'react-router-dom'; import { proxy, useSnapshot } from 'valtio'; import { DatabaseLayoutPB, DatabaseNotification, FieldVisibility } from '@/services/backend'; -import { subscribeNotifications } from '$app/hooks'; +import { subscribeNotifications } from '$app/application/notification'; import { Cell, Database, diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/_shared/VirtualizedList.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/_shared/VirtualizedList.tsx deleted file mode 100644 index c27bcc43c5..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/_shared/VirtualizedList.tsx +++ /dev/null @@ -1,52 +0,0 @@ -import { Virtualizer } from '@tanstack/react-virtual'; -import React, { CSSProperties, FC } from 'react'; - -export interface VirtualizedListProps { - className?: string; - style?: CSSProperties | undefined; - virtualizer: Virtualizer; - itemClassName?: string; - renderItem: (index: number) => React.ReactNode; - getItemStyle?: (index: number) => CSSProperties | undefined; -} - -export const VirtualizedList: FC = ({ - className, - style, - itemClassName, - virtualizer, - renderItem, - getItemStyle, -}) => { - const virtualItems = virtualizer.getVirtualItems(); - const { horizontal } = virtualizer.options; - const sizeProp = horizontal ? 'width' : 'height'; - const before = virtualItems.at(0)?.start ?? 0; - const after = virtualizer.getTotalSize() - (virtualItems.at(-1)?.end ?? 0); - - return ( -
- {before > 0 &&
} - {virtualItems.map((virtualItem) => { - const { key, index, size } = virtualItem; - - return ( -
- {renderItem(index)} -
- ); - })} - {after > 0 &&
} -
- ); -}; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/_shared/dnd/utils.ts b/frontend/appflowy_tauri/src/appflowy_app/components/database/_shared/dnd/utils.ts index 8c3f8a5783..3aafa6f77c 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/_shared/dnd/utils.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/_shared/dnd/utils.ts @@ -61,7 +61,7 @@ export const calculateLeaveEdge = ( { x: mouseX, y: mouseY }: { x: number; y: number }, rect: DOMRect, gaps: EdgeGap, - direction: ScrollDirection, + direction: ScrollDirection ) => { if (direction === ScrollDirection.Horizontal) { if (mouseX - rect.left < gaps.left) { @@ -111,26 +111,22 @@ export interface AutoScrollOnEdgeOptions { const defaultEdgeGap = 30; -export const autoScrollOnEdge = ({ - element, - direction, - edgeGap, - step = 8, -}: AutoScrollOnEdgeOptions) => { - const gaps = typeof edgeGap === 'number' - ? { - top: edgeGap, - bottom: edgeGap, - left: edgeGap, - right: edgeGap, - } - : { - top: defaultEdgeGap, - bottom: defaultEdgeGap, - left: defaultEdgeGap, - right: defaultEdgeGap, - ...edgeGap, - }; +export const autoScrollOnEdge = ({ element, direction, edgeGap, step = 8 }: AutoScrollOnEdgeOptions) => { + const gaps = + typeof edgeGap === 'number' + ? { + top: edgeGap, + bottom: edgeGap, + left: edgeGap, + right: edgeGap, + } + : { + top: defaultEdgeGap, + bottom: defaultEdgeGap, + left: defaultEdgeGap, + right: defaultEdgeGap, + ...edgeGap, + }; const keepScroll = interval(scrollElement, 8); @@ -139,12 +135,7 @@ export const autoScrollOnEdge = ({ const onDragOver = (event: DragEvent) => { const rect = element.getBoundingClientRect(); - leaveEdge = calculateLeaveEdge( - { x: event.clientX, y: event.clientY }, - rect, - gaps, - direction, - ); + leaveEdge = calculateLeaveEdge({ x: event.clientX, y: event.clientY }, rect, gaps, direction); if (leaveEdge) { keepScroll(element, leaveEdge, step); diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/_shared/index.ts b/frontend/appflowy_tauri/src/appflowy_app/components/database/_shared/index.ts index 8d63e9f3ce..6bfa1f812b 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/_shared/index.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/_shared/index.ts @@ -2,5 +2,4 @@ export * from './constants'; export * from './dnd'; -export * from './VirtualizedList'; export * from './CellText'; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/edit_record/EditRecord.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/edit_record/EditRecord.tsx index 40d403873f..3d60ad71b2 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/edit_record/EditRecord.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/edit_record/EditRecord.tsx @@ -50,16 +50,13 @@ function EditRecord({ rowId }: Props) { void loadPage(); }, [loadPage]); - const getDocumentTitle = useCallback(() => { - return row ? : null; - }, [row, page]); - - if (!id) return null; + if (!id || !page) return null; return ( -
- {page && } -
+ <> + + + ); } diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/edit_record/ExpandRecordModal.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/edit_record/ExpandRecordModal.tsx index 1367e042d3..362e616cf7 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/edit_record/ExpandRecordModal.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/edit_record/ExpandRecordModal.tsx @@ -22,7 +22,7 @@ function ExpandRecordModal({ open, onClose, rowId }: Props) { open={open} onClose={onClose} PaperProps={{ - className: 'h-[calc(100%-144px)] w-[80%] max-w-[960px]', + className: 'h-[calc(100%-144px)] w-[80%] max-w-[960px] overflow-visible', }} > - + diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/edit_record/RecordDocument.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/edit_record/RecordDocument.tsx index ad50dd2894..1365ba95b4 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/edit_record/RecordDocument.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/edit_record/RecordDocument.tsx @@ -1,18 +1,12 @@ import React from 'react'; -import Document from '$app/components/document'; -import { ContainerType } from '$app/hooks/document.hooks'; +import Editor from '$app/components/editor/Editor'; interface Props { documentId: string; - getDocumentTitle?: () => React.ReactNode; } -function RecordDocument({ documentId, getDocumentTitle }: Props) { - return ( -
- -
- ); +function RecordDocument({ documentId }: Props) { + return ; } export default React.memo(RecordDocument); diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/edit_record/RecordHeader.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/edit_record/RecordHeader.tsx index 01473d5b1f..e6fda86e38 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/edit_record/RecordHeader.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/edit_record/RecordHeader.tsx @@ -28,7 +28,7 @@ function RecordHeader({ page, row }: Props) { }, []); return ( -
+
diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/edit_record/record_properties/PropertyList.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/edit_record/record_properties/PropertyList.tsx index aa93b25179..395f772f9e 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/edit_record/record_properties/PropertyList.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/edit_record/record_properties/PropertyList.tsx @@ -13,7 +13,7 @@ interface Props extends HTMLAttributes { } function PropertyList( - { documentId, properties, rowId, placeholderNode, openMenuPropertyId, setOpenMenuPropertyId, ...props }: Props, + { properties, rowId, placeholderNode, openMenuPropertyId, setOpenMenuPropertyId, ...props }: Props, ref: React.ForwardedRef ) { const [hoverId, setHoverId] = useState(null); @@ -24,24 +24,11 @@ function PropertyList( return ( {(provided) => { - let top; - - if (provided.draggableProps.style && 'top' in provided.draggableProps.style) { - const scrollContainer = document.querySelector(`#appflowy-scroller_${documentId}`); - - top = provided.draggableProps.style.top - 113 + (scrollContainer?.scrollTop || 0); - } - return ( ; + const { id: rowId, icon: rowIcon } = row.data.meta; + const renderRowCell = ; return (
{field.isPrimary ? ( - + {renderRowCell} ) : ( diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/GridField/GridField.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/GridField/GridField.tsx index 07f6cf67cc..0b6ecbe766 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/GridField/GridField.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/GridField/GridField.tsx @@ -47,6 +47,7 @@ export const GridField: FC = memo( scrollOnEdge: { direction: ScrollDirection.Horizontal, getScrollElement, + edgeGap: 80, }, }); diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/GridRowActions/GridRowActions.hooks.ts b/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/GridRowActions/GridRowActions.hooks.ts index 768c4ff7bb..d49ab6addc 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/GridRowActions/GridRowActions.hooks.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/GridRowActions/GridRowActions.hooks.ts @@ -34,14 +34,14 @@ function createVirtualDragElement(rowId: string, container: HTMLDivElement) { if (!cell) return null; - const rect = cell.getBoundingClientRect(); - const row = document.createElement('div'); row.style.display = 'flex'; row.style.position = 'absolute'; - row.style.top = `${rect.top}px`; - row.style.left = `${rect.left + 64}px`; + row.style.top = cell.style.top; + const left = Number(cell.style.left.split('px')[0]) + 64; + + row.style.left = `${left}px`; row.style.background = 'var(--content-blue-50)'; cells.forEach((cell) => { const node = cell.cloneNode(true) as HTMLDivElement; @@ -53,11 +53,11 @@ function createVirtualDragElement(rowId: string, container: HTMLDivElement) { node.style.left = ''; node.style.width = (cell as HTMLDivElement).style.width; node.style.height = (cell as HTMLDivElement).style.height; - node.className = 'flex items-center'; + node.className = 'flex items-center border-r border-b border-divider-line opacity-50'; row.appendChild(node); }); - document.body.appendChild(row); + cell.parentElement?.appendChild(row); return row; } @@ -91,6 +91,7 @@ export function useDraggableGridRow( autoScrollOnEdge({ element: scrollParent, direction: ScrollDirection.Vertical, + edgeGap: 20, }); } @@ -119,7 +120,6 @@ export function useDraggableGridRow( const onDragOver = (e: DragEvent) => { e.preventDefault(); - e.stopPropagation(); const target = e.target as HTMLElement; const cell = target.closest('[data-key]'); const rowId = cell?.getAttribute('data-key')?.split(':')[1]; @@ -154,7 +154,6 @@ export function useDraggableGridRow( const onDrop = async (e: DragEvent) => { e.preventDefault(); e.stopPropagation(); - const dropRowId = dropRowIdRef.current; if (dropRowId) { diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/BlockPortal/index.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/document/BlockPortal/index.tsx deleted file mode 100644 index 49ede75648..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/BlockPortal/index.tsx +++ /dev/null @@ -1,9 +0,0 @@ -import ReactDOM from 'react-dom'; - -const BlockPortal = ({ blockId, children }: { blockId: string; children: JSX.Element }) => { - const root = document.querySelectorAll(`[data-block-id="${blockId}"] > .block-overlay`)[0]; - - return typeof document === 'object' && root ? ReactDOM.createPortal(children, root) : null; -}; - -export default BlockPortal; 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 deleted file mode 100644 index 0c58871661..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSelection/BlockRangeSelection.hooks.ts +++ /dev/null @@ -1,226 +0,0 @@ -import { useCallback, useEffect, useRef, useState } from 'react'; -import { rangeActions } from '$app_reducers/document/slice'; -import { useAppDispatch } from '$app/stores/store'; -import { - getBlockIdByPoint, - getNodeTextBoxByBlockId, - isFocused, - setCursorAtEndOfNode, - setCursorAtStartOfNode, -} from '$app/utils/document/node'; -import { useRangeKeyDown } from '$app/components/document/BlockSelection/RangeKeyDown.hooks'; -import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks'; -import { useSubscribeRanges } from '$app/components/document/_shared/SubscribeSelection.hooks'; -import { isApple } from '$app/utils/env'; - -const onFrameTime = 1000 / 60; - -export function useBlockRangeSelection(container: HTMLDivElement) { - const timeStampRef = useRef(0); - - const dispatch = useAppDispatch(); - const onKeyDown = useRangeKeyDown(); - const { docId } = useSubscribeDocument(); - - const range = useSubscribeRanges(); - const isDragging = range?.isDragging; - - const anchorRef = useRef<{ - id: string; - point: { x: number; y: number }; - } | null>(null); - - const [focus, setFocus] = useState<{ - id: string; - point: { x: number; y: number }; - } | null>(null); - - const [isForward, setForward] = useState(true); - - // display caret color - useEffect(() => { - if (!range) return; - 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({ - focusPoint: focus, - docId, - }) - ); - - 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, docId, focus, isForward]); - - const handleDragEnd = useCallback(() => { - timeStampRef.current = Date.now(); - if (!isDragging) return; - setFocus(null); - anchorRef.current = null; - dispatch( - rangeActions.setDragging({ - isDragging: false, - docId, - }) - ); - }, [docId, dispatch, isDragging]); - - const handleMouseDown = useCallback( - (e: MouseEvent) => { - if (e.button !== 0) return; - const isTapToClick = isApple() && timeStampRef.current > 0 && Date.now() - timeStampRef.current < onFrameTime; - - const isTextBox = (e.target as HTMLElement).closest(`[role="textbox"]`); - - if (!isTextBox) return; - // skip if the target is not a block - const blockId = getBlockIdByPoint(e.target as HTMLElement); - - if (!blockId) { - dispatch(rangeActions.initialState(docId)); - return; - } - - setForward(true); - - dispatch(rangeActions.clearRanges({ docId, exclude: blockId })); - const startX = e.clientX + container.scrollLeft; - const startY = e.clientY + container.scrollTop; - - const anchor = { - id: blockId, - point: { - x: startX, - y: startY, - }, - }; - - anchorRef.current = { - ...anchor, - }; - // set the anchor point and focus point - dispatch(rangeActions.setAnchorPoint({ docId, anchorPoint: anchor })); - dispatch(rangeActions.setFocusPoint({ docId, focusPoint: anchor })); - - // This is a workaround for a bug in Safari where the mouseup event is not fired - if (isTapToClick) { - handleDragEnd(); - return; - } - - dispatch( - rangeActions.setDragging({ - isDragging: true, - docId, - }) - ); - }, - [container.scrollLeft, container.scrollTop, dispatch, docId, handleDragEnd] - ); - - const handleDragging = 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 startX = anchorRef.current.point.x; - - setForward(startX < endX); - return; - } - - const startY = anchorRef.current.point.y; - - setForward(startY < endY); - }, - [container.scrollLeft, container.scrollTop, isDragging] - ); - - useEffect(() => { - container.addEventListener('mousedown', handleMouseDown); - document.addEventListener('mousemove', handleDragging); - document.addEventListener('mouseup', handleDragEnd); - - container.addEventListener('keydown', onKeyDown, true); - return () => { - container.removeEventListener('mousedown', handleMouseDown); - document.removeEventListener('mousemove', handleDragging); - document.removeEventListener('mouseup', handleDragEnd); - - container.removeEventListener('keydown', onKeyDown, true); - }; - }, [handleMouseDown, handleDragEnd, handleDragging, 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 deleted file mode 100644 index b56e9358dd..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSelection/BlockRectSelection.hooks.ts +++ /dev/null @@ -1,163 +0,0 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { useAppDispatch } from '$app/stores/store'; -import { rectSelectionActions } from '@/appflowy_app/stores/reducers/document/slice'; -import { setRectSelectionThunk } from '$app_reducers/document/async-actions/rect_selection'; - -import { isPointInBlock } from '$app/utils/document/node'; -import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks'; - -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 { docId } = useSubscribeDocument(); - - const [isDragging, setDragging] = useState(false); - const startPointRef = useRef([]); - - useEffect(() => { - dispatch( - rectSelectionActions.setDragging({ - docId, - isDragging, - }) - ); - }, [docId, dispatch, isDragging]); - - const [rect, setRect] = useState<{ - startX: number; - startY: number; - endX: number; - endY: number; - } | null>(null); - - const style = useMemo(() => { - if (!rect) return; - const { startX, endX, startY, endY } = rect; - const x = Math.min(startX, endX); - const y = Math.min(startY, endY); - const width = Math.abs(endX - startX); - const height = Math.abs(endY - startY); - - return { - left: x - container.scrollLeft + 'px', - top: y - container.scrollTop + 'px', - width: width + 'px', - height: height + 'px', - }; - }, [container.scrollLeft, container.scrollTop, rect]); - - const handleDragStart = useCallback( - (e: MouseEvent) => { - if (e.button !== 0) return; - - if (isPointInBlock(e.target as HTMLElement)) { - return; - } - - e.preventDefault(); - setDragging(true); - - const startX = e.clientX + container.scrollLeft; - const startY = e.clientY + container.scrollTop; - - startPointRef.current = [startX, startY]; - setRect({ - startX, - startY, - endX: startX, - endY: startY, - }); - }, - [container.scrollLeft, container.scrollTop] - ); - - const updateSelctionsByPoint = useCallback( - (clientX: number, clientY: number) => { - if (!isDragging) return; - const [startX, startY] = startPointRef.current; - const endX = clientX + container.scrollLeft; - const endY = clientY + container.scrollTop; - - const newRect = { - startX, - startY, - endX, - endY, - }; - const blockIds = getIntersectedBlockIds(newRect); - - setRect(newRect); - void dispatch( - setRectSelectionThunk({ - selection: blockIds, - docId, - }) - ); - }, - [container.scrollLeft, container.scrollTop, dispatch, docId, getIntersectedBlockIds, isDragging] - ); - - const handleDraging = useCallback( - (e: MouseEvent) => { - if (!isDragging) return; - e.preventDefault(); - e.stopPropagation(); - updateSelctionsByPoint(e.clientX, e.clientY); - - const { top, bottom } = container.getBoundingClientRect(); - - if (e.clientY >= bottom) { - const delta = e.clientY - bottom; - - container.scrollBy(0, delta); - } else if (e.clientY <= top) { - const delta = e.clientY - top; - - container.scrollBy(0, delta); - } - }, - [container, isDragging, updateSelctionsByPoint] - ); - - const handleDragEnd = useCallback( - (e: MouseEvent) => { - if (isPointInBlock(e.target as HTMLElement) && !isDragging) { - dispatch( - rectSelectionActions.updateSelections({ - docId, - selection: [], - }) - ); - return; - } - - if (!isDragging) return; - e.preventDefault(); - updateSelctionsByPoint(e.clientX, e.clientY); - setDragging(false); - setRect(null); - }, - [dispatch, docId, isDragging, updateSelctionsByPoint] - ); - - useEffect(() => { - container.addEventListener('mousedown', handleDragStart); - document.addEventListener('mousemove', handleDraging); - document.addEventListener('mouseup', handleDragEnd); - - return () => { - container.removeEventListener('mousedown', handleDragStart); - document.removeEventListener('mousemove', handleDraging); - document.removeEventListener('mouseup', handleDragEnd); - }; - }, [container, handleDragStart, handleDragEnd, handleDraging]); - - return { - isDragging, - style, - }; -} 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 deleted file mode 100644 index a202b4e647..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSelection/BlockRectSelection.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import React from 'react'; -import { - BlockRectSelectionProps, - useBlockRectSelection, -} from '$app/components/document/BlockSelection/BlockRectSelection.hooks'; - -function BlockRectSelection(props: BlockRectSelectionProps) { - const { isDragging, style } = useBlockRectSelection(props); - - if (!isDragging) return null; - return
; -} - -export default BlockRectSelection; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSelection/NodesRect.hooks.ts b/frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSelection/NodesRect.hooks.ts deleted file mode 100644 index a0824836bd..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSelection/NodesRect.hooks.ts +++ /dev/null @@ -1,91 +0,0 @@ -import { useCallback, useEffect, useMemo, useState } from 'react'; -import { RegionGrid } from '$app/utils/region_grid'; -import { useSubscribeDocument, useSubscribeDocumentData } from '$app/components/document/_shared/SubscribeDoc.hooks'; - -export function useNodesRect(container: HTMLDivElement) { - const { controller } = useSubscribeDocument(); - - const version = useVersionUpdate(); - - const regionGrid = useMemo(() => { - if (!controller) return null; - return new RegionGrid(300); - }, [controller]); - - const updateNodeRect = useCallback( - (node: Element) => { - const { x, y, width, height } = node.getBoundingClientRect(); - const id = node.getAttribute('data-block-id'); - - if (!id) return; - const rect = { - id, - x: x + container.scrollLeft, - y: y + container.scrollTop, - width, - height, - }; - - regionGrid?.updateBlock(rect); - }, - [container.scrollLeft, container.scrollTop, regionGrid] - ); - - const updateViewPortNodesRect = useCallback(() => { - const nodes = container.querySelectorAll('[data-block-id]'); - - nodes.forEach(updateNodeRect); - }, [container, updateNodeRect]); - - // update nodes rect when data changed - useEffect(() => { - updateViewPortNodesRect(); - }, [version, updateViewPortNodesRect]); - - // update nodes rect when scroll - useEffect(() => { - container.addEventListener('scroll', updateViewPortNodesRect); - return () => { - container.removeEventListener('scroll', updateViewPortNodesRect); - }; - }, [container, updateViewPortNodesRect]); - - const getIntersectedBlockIds = useCallback( - (rect: { startX: number; startY: number; endX: number; endY: number }) => { - if (!regionGrid) return []; - const { startX, startY, endX, endY } = rect; - const x = Math.min(startX, endX); - const y = Math.min(startY, endY); - const width = Math.abs(endX - startX); - const height = Math.abs(endY - startY); - - return regionGrid - .getIntersectingBlocks({ - x, - y, - width, - height, - }) - .map((block) => block.id); - }, - [regionGrid] - ); - - return { - getIntersectedBlockIds, - }; -} - -function useVersionUpdate() { - const [version, setVersion] = useState(0); - const data = useSubscribeDocumentData(); - - useEffect(() => { - setVersion((v) => { - if (v < Number.MAX_VALUE) return v + 1; - return 0; - }); - }, [data]); - - return version; -} 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 deleted file mode 100644 index 21d18b07b6..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSelection/RangeKeyDown.hooks.ts +++ /dev/null @@ -1,149 +0,0 @@ -import { useCallback, useMemo } from 'react'; -import { Keyboard } from '$app/constants/document/keyboard'; -import { useAppDispatch } from '$app/stores/store'; -import { arrowActionForRangeThunk, deleteRangeAndInsertThunk } from '$app_reducers/document/async-actions'; -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'; -import { toggleFormatThunk } from '$app_reducers/document/async-actions/format'; -import { isFormatHotkey, parseFormat } from '$app/utils/document/format'; -import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks'; - -export function useRangeKeyDown() { - const rangeRef = useRangeRef(); - - const dispatch = useAppDispatch(); - const { docId, controller } = useSubscribeDocument(); - - 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; - void dispatch( - deleteRangeAndInsertThunk({ - controller, - }) - ); - }, - }, - { - // handle char input - canHandle: (e: KeyboardEvent) => { - return isPrintableKeyEvent(e) && !e.shiftKey && !e.ctrlKey && !e.metaKey; - }, - handler: (e: KeyboardEvent) => { - if (!controller) return; - void dispatch( - deleteRangeAndInsertThunk({ - controller, - insertChar: e.key, - }) - ); - }, - }, - { - // handle shift + enter - canHandle: (e: KeyboardEvent) => { - return isHotkey(Keyboard.keys.SHIFT_ENTER, e); - }, - handler: () => { - if (!controller) return; - void dispatch( - deleteRangeAndInsertEnterThunk({ - controller, - shiftKey: true, - }) - ); - }, - }, - { - // handle enter - canHandle: (e: KeyboardEvent) => { - return isHotkey(Keyboard.keys.ENTER, e); - }, - handler: () => { - if (!controller) return; - void 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) => { - void dispatch( - arrowActionForRangeThunk({ - key: e.key, - docId, - }) - ); - }, - }, - { - // handle format shortcuts - canHandle: isFormatHotkey, - handler: (e: KeyboardEvent) => { - if (!controller) return; - const format = parseFormat(e); - - if (!format) return; - void dispatch( - toggleFormatThunk({ - format, - controller, - }) - ); - }, - }, - ], - [controller, dispatch, docId] - ); - - return useCallback( - (e: KeyboardEvent) => { - if (!rangeRef.current) { - return; - } - - const { anchor, focus } = rangeRef.current; - - if (!anchor || !focus) return; - - if (anchor.id === focus.id) { - return; - } - - e.stopPropagation(); - const filteredEvents = interceptEvents.filter((event) => event.canHandle(e)); - const lastIndex = filteredEvents.length - 1; - - if (lastIndex < 0) { - return; - } - - const lastEvent = filteredEvents[lastIndex]; - - if (!lastEvent) return; - e.preventDefault(); - lastEvent.handler(e); - }, - [interceptEvents, rangeRef] - ); -} 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 deleted file mode 100644 index 1361b46597..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSelection/index.tsx +++ /dev/null @@ -1,17 +0,0 @@ -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 ( -
- -
- ); -} - -export default React.memo(BlockSelection); diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSideToolbar/BlockMenu.hooks.ts b/frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSideToolbar/BlockMenu.hooks.ts deleted file mode 100644 index ce848bbcd2..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSideToolbar/BlockMenu.hooks.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { useAppDispatch } from '$app/stores/store'; -import { useCallback } from 'react'; -import { duplicateBelowNodeThunk } from '$app_reducers/document/async-actions/blocks/duplicate'; -import { deleteNodeThunk } from '$app_reducers/document/async-actions'; -import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks'; - -export function useBlockMenu(id: string) { - const dispatch = useAppDispatch(); - const { controller } = useSubscribeDocument(); - - const handleDuplicate = useCallback(async () => { - if (!controller) return; - await dispatch( - duplicateBelowNodeThunk({ - id, - controller, - }) - ); - }, [controller, dispatch, id]); - - const handleDelete = useCallback(async () => { - if (!controller) return; - await dispatch( - deleteNodeThunk({ - id, - controller, - }) - ); - }, [controller, dispatch, id]); - - return { - handleDuplicate, - handleDelete, - }; -} 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 deleted file mode 100644 index 932a3ad330..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSideToolbar/BlockMenu.tsx +++ /dev/null @@ -1,178 +0,0 @@ -import React, { useCallback, useEffect, useMemo, useState } from 'react'; -import { ContentCopy, Delete } from '@mui/icons-material'; -import MenuItem from '../_shared/MenuItem'; -import { useBlockMenu } from '$app/components/document/BlockSideToolbar/BlockMenu.hooks'; -import BlockMenuTurnInto from '$app/components/document/BlockSideToolbar/BlockMenuTurnInto'; -import TextField from '@mui/material/TextField'; -import { Keyboard } from '$app/constants/document/keyboard'; -import { selectOptionByUpDown } from '$app/utils/document/menu'; -import { useSubscribeNode } from '$app/components/document/_shared/SubscribeNode.hooks'; -import { BlockType } from '$app/interfaces/document'; -import { useTranslation } from 'react-i18next'; - -enum BlockMenuOption { - Duplicate = 'Duplicate', - Delete = 'Delete', - TurnInto = 'TurnInto', -} - -interface Option { - operate?: () => Promise; - title?: string; - icon?: React.ReactNode; - key: BlockMenuOption; -} - -function BlockMenu({ id, onClose }: { id: string; onClose: () => void }) { - const { handleDelete, handleDuplicate } = useBlockMenu(id); - const { node } = useSubscribeNode(id); - const [subMenuOpened, setSubMenuOpened] = useState(false); - const [hovered, setHovered] = useState(null); - const { t } = useTranslation(); - - useEffect(() => { - if (hovered !== BlockMenuOption.TurnInto) { - setSubMenuOpened(false); - } - }, [hovered]); - - const handleClick = useCallback( - async ({ operate }: { operate: () => Promise }) => { - await operate(); - onClose(); - }, - [onClose] - ); - - const excludeTurnIntoBlock = useMemo(() => { - return [BlockType.DividerBlock].includes(node.type); - }, [node.type]); - - const options: Option[] = useMemo( - () => - [ - { - operate: () => { - return handleClick({ operate: handleDelete }); - }, - title: t('document.plugins.optionAction.delete'), - icon: , - key: BlockMenuOption.Delete, - }, - { - operate: () => { - return handleClick({ operate: handleDuplicate }); - }, - title: t('document.plugins.optionAction.duplicate'), - icon: , - key: BlockMenuOption.Duplicate, - }, - excludeTurnIntoBlock - ? null - : { - key: BlockMenuOption.TurnInto, - title: t('document.plugins.optionAction.turnInto'), - }, - ].filter((item) => item !== null) as Option[], - [excludeTurnIntoBlock, handleClick, handleDelete, handleDuplicate, t] - ); - - const onKeyDown = useCallback( - (e: React.KeyboardEvent) => { - const isUp = e.key === Keyboard.keys.UP; - const isDown = e.key === Keyboard.keys.DOWN; - const isLeft = e.key === Keyboard.keys.LEFT; - const isRight = e.key === Keyboard.keys.RIGHT; - const isEnter = e.key === Keyboard.keys.ENTER; - - const isArrow = isUp || isDown || isLeft || isRight; - - if (!isArrow && !isEnter) return; - e.stopPropagation(); - if (isEnter) { - if (hovered) { - const option = options.find((option) => option.key === hovered); - - void option?.operate?.(); - } else { - onClose(); - } - - return; - } - - const optionsKeys = options.map((option) => option.key); - - if (isUp || isDown) { - const nextKey = selectOptionByUpDown(isUp, hovered, optionsKeys); - const nextOption = options.find((option) => option.key === nextKey); - - setHovered(nextOption?.key ?? null); - return; - } - - if (isLeft || isRight) { - if (hovered === BlockMenuOption.TurnInto) { - setSubMenuOpened(isRight); - } - } - }, - [hovered, onClose, options] - ); - - return ( -
{ - e.stopPropagation(); - }} - > -
- -
- {options.map((option) => { - if (option.key === BlockMenuOption.TurnInto) { - return ( - { - setHovered(BlockMenuOption.TurnInto); - setSubMenuOpened(true); - }} - menuOpened={subMenuOpened} - isHovered={hovered === BlockMenuOption.TurnInto} - onClose={() => { - setSubMenuOpened(false); - onClose(); - }} - id={id} - /> - ); - } - - return ( - { - setHovered(option.key); - setSubMenuOpened(false); - }} - /> - ); - })} -
- ); -} - -export default BlockMenu; 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 deleted file mode 100644 index 74710e30d3..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSideToolbar/BlockMenuTurnInto.tsx +++ /dev/null @@ -1,78 +0,0 @@ -import React, { MouseEvent, useEffect, useRef } from 'react'; -import { ArrowRight, Transform } from '@mui/icons-material'; -import MenuItem from '$app/components/document/_shared/MenuItem'; -import TurnIntoPopover from '$app/components/document/_shared/TurnInto'; - -function BlockMenuTurnInto({ - id, - onClose, - onHovered, - isHovered, - menuOpened, - label, -}: { - id: string; - onClose: () => void; - onHovered: (e: MouseEvent) => void; - isHovered: boolean; - menuOpened: boolean; - label?: string; -}) { - const ref = useRef(null); - const [anchorPosition, setAnchorPosition] = React.useState<{ top: number; left: number }>(); - const open = Boolean(anchorPosition); - - useEffect(() => { - if (isHovered && menuOpened) { - const rect = ref.current?.getBoundingClientRect(); - - if (!rect) return; - setAnchorPosition({ - top: rect.top + rect.height / 2, - left: rect.left + rect.width, - }); - } else { - setAnchorPosition(undefined); - } - }, [isHovered, menuOpened]); - return ( - <> - } - extra={} - onHover={(e) => { - onHovered(e); - }} - /> - onClose()} - onClose={() => { - setAnchorPosition(undefined); - }} - anchorReference={'anchorPosition'} - anchorPosition={anchorPosition} - transformOrigin={{ - vertical: 'center', - horizontal: 'left', - }} - /> - - ); -} - -export default BlockMenuTurnInto; 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 deleted file mode 100644 index 9ccd263899..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSideToolbar/BlockSideToolbar.hooks.tsx +++ /dev/null @@ -1,137 +0,0 @@ -import { BlockType, HeadingBlockData } from '@/appflowy_app/interfaces/document'; -import { useAppSelector } from '@/appflowy_app/stores/store'; -import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { PopoverOrigin } from '@mui/material/Popover/Popover'; -import { getBlock } from '$app/components/document/_shared/SubscribeNode.hooks'; -import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks'; -import { RANGE_NAME, RECT_RANGE_NAME } from '$app/constants/document/name'; -import { getNode } from '$app/utils/document/node'; -import { get } from '$app/utils/tool'; - -const headingBlockTopOffset: Record = { - 1: '0.4rem', - 2: '0.35rem', - 3: '0.15rem', -}; - -export function useBlockSideToolbar(id: string) { - const { docId } = useSubscribeDocument(); - - const isDragging = useAppSelector((state) => { - return ( - get(state, [RECT_RANGE_NAME, docId, 'isDragging'], false) || - get(state, [RANGE_NAME, docId, 'isDragging'], false) || - get(state, ['blockDraggable', 'dragging'], false) - ); - }); - const ref = useRef(null); - const [opacity, setOpacity] = useState(0); - - const topOffset = useMemo(() => { - const block = getBlock(docId, id); - - if (!block) return 0; - if (block.type === BlockType.HeadingBlock) { - return headingBlockTopOffset[(block.data as HeadingBlockData).level]; - } - - if (block.type === BlockType.DividerBlock) { - return -6; - } - - if (block.type === BlockType.GridBlock) { - return 16; - } - - return 0; - }, [docId, id]); - - const onMouseMove = useCallback( - (e: Event) => { - if (isDragging) { - setOpacity(0); - return; - } - - const target = (e.target as HTMLElement).closest('[data-block-id]'); - - if (!target) return; - const targetId = target.getAttribute('data-block-id'); - - if (targetId !== id) { - setOpacity(0); - return; - } - - setOpacity(1); - }, - [id, isDragging] - ); - - const onMouseLeave = useCallback(() => { - setOpacity(0); - }, []); - - useEffect(() => { - const node = getNode(id); - - if (!node) return; - node.addEventListener('mousemove', onMouseMove); - node.addEventListener('mouseleave', onMouseLeave); - return () => { - node.removeEventListener('mousemove', onMouseMove); - node.removeEventListener('mouseleave', onMouseLeave); - }; - }, [id, onMouseMove, onMouseLeave]); - - return { - ref, - opacity, - topOffset, - }; -} - -const transformOrigin: PopoverOrigin = { - vertical: 'bottom', - horizontal: 'left', -}; - -export function usePopover() { - const [anchorPosition, setAnchorPosition] = React.useState<{ - top: number; - left: number; - }>(); - - const onClose = useCallback(() => { - setAnchorPosition(undefined); - }, []); - - const handleOpen = useCallback((e: React.MouseEvent) => { - e.preventDefault(); - const rect = e.currentTarget.getBoundingClientRect(); - - setAnchorPosition({ - top: rect.top + rect.height, - left: rect.left + rect.width, - }); - }, []); - - const open = Boolean(anchorPosition); - - const onMouseDown = useCallback((e: React.MouseEvent) => { - e.stopPropagation(); - }, []); - - return { - anchorPosition, - onClose, - open, - handleOpen, - anchorReference: 'anchorPosition' as const, - transformOrigin, - onMouseDown, - disableRestoreFocus: true, - disableAutoFocus: true, - disableEnforceFocus: true, - }; -} 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 deleted file mode 100644 index ed7114f0d8..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSideToolbar/index.tsx +++ /dev/null @@ -1,105 +0,0 @@ -import React from 'react'; -import { useBlockSideToolbar, usePopover } from './BlockSideToolbar.hooks'; -import { useAppDispatch } from '$app/stores/store'; -import Popover from '@mui/material/Popover'; -import DragIndicatorRoundedIcon from '@mui/icons-material/DragIndicatorRounded'; -import AddSharpIcon from '@mui/icons-material/AddSharp'; -import BlockMenu from './BlockMenu'; -import { addBlockBelowClickThunk } from '$app_reducers/document/async-actions/menu'; -import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks'; -import { setRectSelectionThunk } from '$app_reducers/document/async-actions/rect_selection'; -import { useTranslation } from 'react-i18next'; -import { IconButton } from '@mui/material'; -import Tooltip from '@mui/material/Tooltip'; - -export default function BlockSideToolbar({ id }: { id: string }) { - const dispatch = useAppDispatch(); - const { docId, controller } = useSubscribeDocument(); - const { t } = useTranslation(); - - const { handleOpen, open, ...popoverProps } = usePopover(); - const { opacity, topOffset } = useBlockSideToolbar(id); - - const show = opacity === 1 || open; - - return ( - <> -
- {/** Add Block below */} - - ) => { - void dispatch( - addBlockBelowClickThunk({ - id, - controller, - }) - ); - }} - sx={{ - height: 24, - width: 24, - }} - onMouseDown={(e) => { - e.preventDefault(); - e.stopPropagation(); - }} - > - - - - - {/** Open menu or drag */} - -
{t('blockActions.dragTooltip')}
-
{t('blockActions.openMenuTooltip')}
-
- } - placement={'top-start'} - > - ) => { - handleOpen(e); - await dispatch( - setRectSelectionThunk({ - docId, - selection: [id], - }) - ); - }} - sx={{ - height: 24, - width: 24, - }} - onMouseDown={(e) => { - e.preventDefault(); - e.stopPropagation(); - }} - > - - - -
- - - - - - ); -} 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 deleted file mode 100644 index fcee83dc25..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSlash/BlockSlashMenu.tsx +++ /dev/null @@ -1,270 +0,0 @@ -import React, { useCallback, useMemo } from 'react'; -import MenuItem from '$app/components/document/_shared/MenuItem'; -import { - ArrowRight, - Check, - DataObject, - FormatListBulleted, - FormatListNumbered, - FormatQuote, - Lightbulb, - TextFields, - Title, - SafetyDivider, - Image, - Functions, - BackupTableOutlined, -} from '@mui/icons-material'; -import { - BlockData, - BlockType, - SlashCommandGroup, - SlashCommandOption, - SlashCommandOptionKey, -} from '$app/interfaces/document'; -import { useAppDispatch } from '$app/stores/store'; -import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks'; -import { turnToBlockThunk } from '$app_reducers/document/async-actions'; -import { useTranslation } from 'react-i18next'; -import { useKeyboardShortcut } from '$app/components/document/BlockSlash/index.hooks'; - -function BlockSlashMenu({ - id, - onClose, - searchText, - hoverOption, - onHoverOption, - container, -}: { - id: string; - onClose?: () => void; - searchText?: string; - hoverOption?: SlashCommandOption; - onHoverOption: (option: SlashCommandOption, target: HTMLElement) => void; - container: HTMLDivElement; -}) { - const dispatch = useAppDispatch(); - const { t } = useTranslation(); - const { controller } = useSubscribeDocument(); - - const handleInsert = useCallback( - async (type: BlockType, data?: BlockData) => { - if (!controller) return; - await dispatch( - turnToBlockThunk({ - controller, - id, - type, - data, - }) - ); - onClose?.(); - }, - [controller, dispatch, id, onClose] - ); - - const options: (SlashCommandOption & { - title: string; - icon: React.ReactNode; - group: SlashCommandGroup; - })[] = useMemo( - () => - [ - { - key: SlashCommandOptionKey.TEXT, - type: BlockType.TextBlock, - title: t('editor.text'), - icon: , - group: SlashCommandGroup.BASIC, - }, - { - key: SlashCommandOptionKey.HEADING_1, - type: BlockType.HeadingBlock, - title: t('editor.heading1'), - icon: , - data: { - level: 1, - }, - group: SlashCommandGroup.BASIC, - }, - { - key: SlashCommandOptionKey.HEADING_2, - type: BlockType.HeadingBlock, - title: t('editor.heading2'), - icon: <Title />, - data: { - level: 2, - }, - group: SlashCommandGroup.BASIC, - }, - { - key: SlashCommandOptionKey.HEADING_3, - type: BlockType.HeadingBlock, - title: t('editor.heading3'), - icon: <Title />, - data: { - level: 3, - }, - group: SlashCommandGroup.BASIC, - }, - { - key: SlashCommandOptionKey.TODO, - type: BlockType.TodoListBlock, - title: t('editor.checkbox'), - icon: <Check />, - group: SlashCommandGroup.BASIC, - }, - { - key: SlashCommandOptionKey.BULLET, - type: BlockType.BulletedListBlock, - title: t('editor.bulletedList'), - icon: <FormatListBulleted />, - group: SlashCommandGroup.BASIC, - }, - { - key: SlashCommandOptionKey.NUMBER, - type: BlockType.NumberedListBlock, - title: t('editor.numberedList'), - icon: <FormatListNumbered />, - group: SlashCommandGroup.BASIC, - }, - { - key: SlashCommandOptionKey.TOGGLE, - type: BlockType.ToggleListBlock, - title: t('document.plugins.toggleList'), - icon: <ArrowRight />, - group: SlashCommandGroup.BASIC, - }, - { - key: SlashCommandOptionKey.QUOTE, - type: BlockType.QuoteBlock, - title: t('toolbar.quote'), - icon: <FormatQuote />, - group: SlashCommandGroup.BASIC, - }, - { - key: SlashCommandOptionKey.CALLOUT, - type: BlockType.CalloutBlock, - title: 'Callout', - icon: <Lightbulb />, - group: SlashCommandGroup.BASIC, - }, - { - key: SlashCommandOptionKey.DIVIDER, - type: BlockType.DividerBlock, - title: t('editor.divider'), - icon: <SafetyDivider />, - group: SlashCommandGroup.BASIC, - }, - { - key: SlashCommandOptionKey.CODE, - type: BlockType.CodeBlock, - title: t('document.selectionMenu.codeBlock'), - icon: <DataObject />, - group: SlashCommandGroup.MEDIA, - }, - { - key: SlashCommandOptionKey.IMAGE, - type: BlockType.ImageBlock, - title: t('editor.image'), - icon: <Image />, - group: SlashCommandGroup.MEDIA, - }, - { - key: SlashCommandOptionKey.EQUATION, - type: BlockType.EquationBlock, - title: t('document.plugins.mathEquation.addMathEquation'), - icon: <Functions />, - group: SlashCommandGroup.ADVANCED, - }, - { - key: SlashCommandOptionKey.GRID_REFERENCE, - type: BlockType.GridBlock, - title: t('document.plugins.referencedGrid'), - icon: <BackupTableOutlined />, - group: SlashCommandGroup.ADVANCED, - onClick: () => { - // do nothing - }, - }, - ].filter((option) => { - if (!searchText) return true; - const match = (text: string) => { - return text.toLowerCase().includes(searchText.toLowerCase()); - }; - - return match(option.title) || match(option.type); - }), - [searchText, t] - ); - - const { ref } = useKeyboardShortcut({ - container, - options, - handleInsert, - hoverOption, - }); - - const optionsByGroup = useMemo(() => { - return options.reduce((acc, option) => { - if (!acc[option.group]) { - acc[option.group] = []; - } - - acc[option.group].push(option); - return acc; - }, {} as Record<SlashCommandGroup, typeof options>); - }, [options]); - - const renderEmptyContent = useCallback(() => { - return ( - <div className={'m-5 flex items-center justify-center text-text-caption'}>{t('findAndReplace.noResult')}</div> - ); - }, [t]); - - return ( - <div - onMouseDown={(e) => { - e.preventDefault(); - e.stopPropagation(); - }} - className={'flex h-[100%] max-h-[40vh] w-[324px] min-w-[180px] max-w-[calc(100vw-32px)] flex-col p-1'} - > - <div ref={ref} className={'min-h-0 flex-1 overflow-y-auto overflow-x-hidden'}> - {options.length === 0 - ? renderEmptyContent() - : Object.entries(optionsByGroup).map(([group, options]) => ( - <div key={group}> - <div className={'px-2 py-2 text-sm text-text-caption'}>{group}</div> - <div> - {options.map((option) => { - return ( - <MenuItem - id={`slash-item-${option.key}`} - key={option.key} - title={option.title} - icon={option.icon} - onHover={(e) => { - onHoverOption(option, e.currentTarget as HTMLElement); - }} - isHovered={hoverOption?.key === option.key} - onClick={() => { - if (!option.onClick) { - void handleInsert(option.type, option.data); - return; - } - - option.onClick(); - }} - /> - ); - })} - </div> - </div> - ))} - </div> - </div> - ); -} - -export default BlockSlashMenu; 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 deleted file mode 100644 index fc9ccd719e..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSlash/index.hooks.ts +++ /dev/null @@ -1,201 +0,0 @@ -import { useAppDispatch } from '$app/stores/store'; -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { slashCommandActions } from '$app_reducers/document/slice'; -import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks'; -import { useSubscribeSlashState } from '$app/components/document/_shared/SubscribeSlash.hooks'; -import { useSubscribePanelSearchText } from '$app/components/document/_shared/usePanelSearchText'; -import { BlockData, BlockType, SlashCommandOption, SlashCommandOptionKey } from '$app/interfaces/document'; -import { selectOptionByUpDown } from '$app/utils/document/menu'; -import { Keyboard } from '$app/constants/document/keyboard'; - -export function useKeyboardShortcut({ - container, - options, - handleInsert, - hoverOption, -}: { - container: HTMLElement; - options: SlashCommandOption[]; - handleInsert: (type: BlockType, data?: BlockData) => Promise<void>; - hoverOption?: SlashCommandOption; -}) { - const ref = useRef<HTMLDivElement | null>(null); - const dispatch = useAppDispatch(); - const { docId } = useSubscribeDocument(); - const scrollIntoOption = useCallback( - (option: SlashCommandOption) => { - if (!ref.current) return; - const containerRect = ref.current.getBoundingClientRect(); - const optionElement = document.querySelector(`#slash-item-${option.key}`); - - if (!optionElement) return; - const itemRect = optionElement?.getBoundingClientRect(); - - if (!itemRect) return; - - if (itemRect.top < containerRect.top || itemRect.bottom > containerRect.bottom) { - optionElement.scrollIntoView({ behavior: 'smooth' }); - } - }, - [ref] - ); - - const selectOptionByArrow = useCallback( - ({ isUp = false, isDown = false }: { isUp?: boolean; isDown?: boolean }) => { - if (!isUp && !isDown) return; - const optionsKeys = options.map((option) => String(option.key)); - const nextKey = selectOptionByUpDown(isUp, String(hoverOption?.key), optionsKeys); - const nextOption = options.find((option) => String(option.key) === nextKey); - - if (!nextOption) return; - - scrollIntoOption(nextOption); - dispatch( - slashCommandActions.setHoverOption({ - option: nextOption, - docId, - }) - ); - }, - [dispatch, docId, hoverOption?.key, options, scrollIntoOption] - ); - - useEffect(() => { - const handleKeyDownCapture = (e: KeyboardEvent) => { - const isUp = e.key === Keyboard.keys.UP; - const isDown = e.key === Keyboard.keys.DOWN; - const isEnter = e.key === Keyboard.keys.ENTER; - - // if any arrow key is pressed, prevent default behavior and stop propagation - if (isUp || isDown || isEnter) { - e.stopPropagation(); - e.preventDefault(); - if (isEnter) { - if (hoverOption) { - void handleInsert(hoverOption.type, hoverOption.data); - } - - return; - } - - selectOptionByArrow({ - isUp, - isDown, - }); - } - }; - - // intercept keydown event in capture phase before it reaches the editor - container.addEventListener('keydown', handleKeyDownCapture, true); - return () => { - container.removeEventListener('keydown', handleKeyDownCapture, true); - }; - }, [container, handleInsert, hoverOption, selectOptionByArrow]); - - return { - ref, - }; -} - -export function useBlockSlash() { - const dispatch = useAppDispatch(); - const { docId } = useSubscribeDocument(); - const { blockId, visible, slashText, hoverOption } = useSubscribeSlash(); - const [anchorPosition, setAnchorPosition] = useState<{ - top: number; - left: number; - }>(); - const [subMenuAnchorPosition, setSubMenuAnchorPosition] = useState<{ - top: number; - left: number; - }>(); - - useEffect(() => { - if (blockId && visible) { - const blockEl = document.querySelector(`[data-block-id="${blockId}"]`) as HTMLElement; - const el = blockEl.querySelector(`[role="textbox"]`) as HTMLElement; - - if (el) { - const rect = el.getBoundingClientRect(); - - setAnchorPosition({ - top: rect.top + rect.height, - left: rect.left, - }); - return; - } - } - - setAnchorPosition(undefined); - }, [blockId, visible]); - - const searchText = useMemo(() => { - if (!slashText) return ''; - if (slashText[0] !== '/') return slashText; - - return slashText.slice(1, slashText.length); - }, [slashText]); - - const onClose = useCallback(() => { - setSubMenuAnchorPosition(undefined); - dispatch(slashCommandActions.closeSlashCommand(docId)); - }, [dispatch, docId]); - - const open = Boolean(anchorPosition); - - const onHoverOption = useCallback( - (option: SlashCommandOption, target: HTMLElement) => { - setSubMenuAnchorPosition(undefined); - dispatch( - slashCommandActions.setHoverOption({ - option: { - key: option.key, - type: option.type, - data: option.data, - }, - docId, - }) - ); - - if (option.key === SlashCommandOptionKey.GRID_REFERENCE) { - const rect = target.getBoundingClientRect(); - - setSubMenuAnchorPosition({ - top: rect.top, - left: rect.right, - }); - } - }, - [dispatch, docId] - ); - - const onCloseSubMenu = useCallback(() => { - setSubMenuAnchorPosition(undefined); - }, []); - - return { - open, - anchorPosition, - onClose, - blockId, - searchText, - hoverOption, - onHoverOption, - onCloseSubMenu, - subMenuAnchorPosition, - }; -} - -export function useSubscribeSlash() { - const slashCommandState = useSubscribeSlashState(); - const visible = slashCommandState.isSlashCommand; - const blockId = slashCommandState.blockId; - const { searchText } = useSubscribePanelSearchText({ blockId: blockId || '', open: visible }); - - return { - visible, - blockId, - slashText: searchText, - hoverOption: slashCommandState.hoverOption, - }; -} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSlash/index.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSlash/index.tsx deleted file mode 100644 index 1566b1156b..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSlash/index.tsx +++ /dev/null @@ -1,79 +0,0 @@ -import React, { useCallback } from 'react'; -import Popover from '@mui/material/Popover'; -import BlockSlashMenu from '$app/components/document/BlockSlash/BlockSlashMenu'; -import { useBlockSlash } from '$app/components/document/BlockSlash/index.hooks'; -import { SlashCommandOptionKey } from '$app/interfaces/document'; -import DatabaseList from '$app/components/document/_shared/DatabaseList'; -import { ViewLayoutPB } from '@/services/backend'; - -function BlockSlash({ container }: { container: HTMLDivElement }) { - const { - blockId, - open, - onClose, - anchorPosition, - searchText, - hoverOption, - onHoverOption, - subMenuAnchorPosition, - onCloseSubMenu, - } = useBlockSlash(); - - const renderSubMenu = useCallback(() => { - if (!blockId) return null; - switch (hoverOption?.key) { - case SlashCommandOptionKey.GRID_REFERENCE: - return <DatabaseList onClose={onClose} blockId={blockId} layout={ViewLayoutPB.Grid} searchText={searchText} />; - default: - return null; - } - }, [blockId, hoverOption?.key, onClose, searchText]); - - if (!blockId) return null; - - return ( - <Popover - open={open} - anchorReference={'anchorPosition'} - anchorPosition={anchorPosition} - transformOrigin={{ - vertical: 'top', - horizontal: 'left', - }} - disableAutoFocus - onClose={onClose} - > - <BlockSlashMenu - container={container} - hoverOption={hoverOption} - id={blockId} - onClose={onClose} - searchText={searchText} - onHoverOption={onHoverOption} - /> - <Popover - transformOrigin={{ - vertical: 'top', - horizontal: 'left', - }} - disableAutoFocus - sx={{ - pointerEvents: 'none', - }} - PaperProps={{ - style: { - pointerEvents: 'auto', - }, - }} - open={!!subMenuAnchorPosition} - anchorReference={'anchorPosition'} - anchorPosition={subMenuAnchorPosition} - onClose={onCloseSubMenu} - > - {renderSubMenu()} - </Popover> - </Popover> - ); -} - -export default BlockSlash; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/BulletedListBlock/index.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/document/BulletedListBlock/index.tsx deleted file mode 100644 index 7747039265..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/BulletedListBlock/index.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import React from 'react'; -import { BlockType, NestedBlock } from '$app/interfaces/document'; -import { Circle } from '@mui/icons-material'; -import TextBlock from '$app/components/document/TextBlock'; -import NodeChildren from '$app/components/document/Node/NodeChildren'; - -function BulletedListBlock({ node, childIds }: { node: NestedBlock<BlockType.BulletedListBlock>; childIds?: string[] }) { - return ( - <> - <div className={'flex'}> - <div className={`relative flex h-[calc(1.5em_+_2px)] min-w-[1.5em] select-none items-center px-1`}> - <Circle sx={{ width: 8, height: 8 }} /> - </div> - <div className={'flex-1'}> - <TextBlock node={node} /> - </div> - </div> - <NodeChildren className='pl-[1.5em]' childIds={childIds} /> - </> - ); -} - -export default BulletedListBlock; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/CalloutBlock/CalloutBlock.hooks.ts b/frontend/appflowy_tauri/src/appflowy_app/components/document/CalloutBlock/CalloutBlock.hooks.ts deleted file mode 100644 index c7c26717f2..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/CalloutBlock/CalloutBlock.hooks.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { useCallback, useMemo, useState } from 'react'; -import { useAppDispatch } from '$app/stores/store'; -import { updateNodeDataThunk } from '$app_reducers/document/async-actions'; -import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks'; - -export function useCalloutBlock(nodeId: string) { - const [anchorEl, setAnchorEl] = useState<HTMLButtonElement | null>(null); - const open = useMemo(() => Boolean(anchorEl), [anchorEl]); - const id = useMemo(() => (open ? 'emoji-popover' : undefined), [open]); - const dispatch = useAppDispatch(); - const { controller } = useSubscribeDocument(); - - const closeEmojiSelect = useCallback(() => { - setAnchorEl(null); - }, []); - - const openEmojiSelect = useCallback((event: React.MouseEvent<HTMLButtonElement>) => { - setAnchorEl(event.currentTarget); - }, []); - - const onEmojiSelect = useCallback( - (emoji: string) => { - if (!controller) return; - void dispatch( - updateNodeDataThunk({ - id: nodeId, - controller, - data: { - icon: emoji, - }, - }) - ); - closeEmojiSelect(); - }, - [controller, dispatch, nodeId, closeEmojiSelect] - ); - - return { - anchorEl, - closeEmojiSelect, - openEmojiSelect, - open, - id, - onEmojiSelect, - }; -} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/CalloutBlock/index.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/document/CalloutBlock/index.tsx deleted file mode 100644 index 82a6e5c87d..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/CalloutBlock/index.tsx +++ /dev/null @@ -1,52 +0,0 @@ -import { BlockType, NestedBlock } from '$app/interfaces/document'; -import TextBlock from '$app/components/document/TextBlock'; -import NodeChildren from '$app/components/document/Node/NodeChildren'; -import { IconButton } from '@mui/material'; -import { useCalloutBlock } from '$app/components/document/CalloutBlock/CalloutBlock.hooks'; -import Popover from '@mui/material/Popover'; -import EmojiPicker from '$app/components/_shared/EmojiPicker'; - -export default function CalloutBlock({ - node, - childIds, -}: { - node: NestedBlock<BlockType.CalloutBlock>; - childIds?: string[]; -}) { - const { openEmojiSelect, open, closeEmojiSelect, id, anchorEl, onEmojiSelect } = useCalloutBlock(node.id); - - return ( - <div className={'my-1 flex rounded border border-solid border-line-divider bg-content-blue-50 p-4'}> - <div className={'w-[1.5em]'} onMouseDown={(e) => e.stopPropagation()}> - <div className={'flex h-[calc(1.5em_+_2px)] w-[24px] select-none items-center justify-start'}> - <IconButton - aria-describedby={id} - onClick={openEmojiSelect} - className={`m-0 h-[100%] w-[100%] rounded-full p-0 transition`} - > - {node.data.icon} - </IconButton> - <Popover - className={'border-none bg-transparent shadow-none'} - anchorEl={anchorEl} - disableAutoFocus={true} - open={open} - onClose={closeEmojiSelect} - anchorOrigin={{ - vertical: 'bottom', - horizontal: 'left', - }} - > - <EmojiPicker onEmojiSelect={onEmojiSelect} /> - </Popover> - </div> - </div> - <div className={'flex-1'}> - <div> - <TextBlock node={node} /> - </div> - <NodeChildren childIds={childIds} /> - </div> - </div> - ); -} 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 deleted file mode 100644 index 01a96028df..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/CodeBlock/SelectLanguage.tsx +++ /dev/null @@ -1,52 +0,0 @@ -import React, { useCallback } from 'react'; -import MenuItem from '@mui/material/MenuItem'; -import FormControl from '@mui/material/FormControl'; -import Select, { SelectChangeEvent } from '@mui/material/Select'; -import { updateNodeDataThunk } from '$app_reducers/document/async-actions'; -import { useAppDispatch } from '$app/stores/store'; -import { supportLanguage } from '$app/constants/document/code'; -import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks'; -import { useTranslation } from 'react-i18next'; - -function SelectLanguage({ id, language }: { id: string; language: string }) { - const dispatch = useAppDispatch(); - const { controller } = useSubscribeDocument(); - const { t } = useTranslation(); - const onLanguageSelect = useCallback( - (event: SelectChangeEvent) => { - if (!controller) return; - const language = event.target.value; - - void dispatch( - updateNodeDataThunk({ - id, - controller, - data: { - language, - }, - }) - ); - }, - [controller, dispatch, id] - ); - - return ( - <FormControl variant='standard'> - <Select - className={'h-[28px] w-[150px]'} - value={language || 'javascript'} - onChange={onLanguageSelect} - placeholder={t('document.codeBlock.language.placeholder')} - label={t('document.codeBlock.language.label')} - > - {supportLanguage.map((item) => ( - <MenuItem key={item.id} value={item.id}> - {item.title} - </MenuItem> - ))} - </Select> - </FormControl> - ); -} - -export default SelectLanguage; 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 deleted file mode 100644 index 907c671c7b..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/CodeBlock/index.tsx +++ /dev/null @@ -1,43 +0,0 @@ -import { BlockType, NestedBlock } from '$app/interfaces/document'; -import React from 'react'; -import SelectLanguage from './SelectLanguage'; -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'; -import { useAppSelector } from '$app/stores/store'; -import { ThemeMode } from '$app/interfaces'; - -export default React.memo(function CodeBlock({ - node, - placeholder, - ...props -}: { node: NestedBlock<BlockType.CodeBlock>; placeholder?: string } & React.HTMLAttributes<HTMLDivElement>) { - 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 selectionProps = useSelection(id); - const isDark = useAppSelector((state) => state.currentUser.userSetting.themeMode === ThemeMode.Dark); - - return ( - <div - {...props} - className={`my-1 rounded border border-solid border-line-divider bg-content-blue-50 p-6 ${className}`} - > - <div className={'mb-2 w-[100%]'}> - <SelectLanguage id={id} language={language} /> - </div> - <CodeEditor - isDark={isDark} - value={value} - onChange={onChange} - placeholder={placeholder} - language={language} - onKeyDown={onKeyDown} - {...selectionProps} - /> - </div> - ); -}); 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 deleted file mode 100644 index a24413a69b..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/CodeBlock/useKeyDown.ts +++ /dev/null @@ -1,52 +0,0 @@ -import isHotkey from 'is-hotkey'; -import { useCallback, useMemo } from 'react'; -import { useAppDispatch } from '$app/stores/store'; -import { Keyboard } from '$app/constants/document/keyboard'; -import { useCommonKeyEvents } from '$app/components/document/_shared/EditorHooks/useCommonKeyEvents'; -import { enterActionForBlockThunk } from '$app_reducers/document/async-actions'; -import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks'; - -export function useKeyDown(id: string) { - const dispatch = useAppDispatch(); - const { controller } = useSubscribeDocument(); - - const commonKeyEvents = useCommonKeyEvents(id); - const customEvents = useMemo(() => { - return [ - ...commonKeyEvents, - { - // rewrite only shift + enter key and no other key is pressed - canHandle: (e: React.KeyboardEvent<HTMLDivElement>) => { - return isHotkey(Keyboard.keys.SHIFT_ENTER, e); - }, - handler: (e: React.KeyboardEvent<HTMLDivElement>) => { - e.preventDefault(); - if (!controller) return; - void dispatch( - enterActionForBlockThunk({ - id, - controller, - }) - ); - }, - }, - ]; - }, [commonKeyEvents, controller, dispatch, id]); - - const onKeyDown = useCallback<React.KeyboardEventHandler<HTMLDivElement>>( - (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/DividerBlock/index.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/document/DividerBlock/index.tsx deleted file mode 100644 index 255afed9c3..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/DividerBlock/index.tsx +++ /dev/null @@ -1,7 +0,0 @@ -export default function DividerBlock() { - return ( - <div className={`flex h-[1em] w-[100%] items-center justify-center`}> - <div className={'h-[1px] w-[100%] bg-line-divider'} /> - </div> - ); -} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/Document.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/document/Document.tsx new file mode 100644 index 0000000000..2362f1c071 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/Document.tsx @@ -0,0 +1,33 @@ +import React, { useCallback, useEffect, useRef } from 'react'; +import { Editor } from 'src/appflowy_app/components/editor'; +import { DocumentHeader } from 'src/appflowy_app/components/document/document_header'; + +export function Document({ id }: { id: string }) { + const appendTextRef = useRef<((text: string) => void) | null>(null); + + const onSplitTitle = useCallback((splitText: string) => { + if (appendTextRef.current === null) { + return; + } + + const windowSelection = window.getSelection(); + + windowSelection?.removeAllRanges(); + appendTextRef.current(splitText); + }, []); + + useEffect(() => { + return () => { + appendTextRef.current = null; + }; + }, []); + + return ( + <div className={'relative'}> + <DocumentHeader onSplitTitle={onSplitTitle} pageId={id} /> + <Editor appendTextRef={appendTextRef} id={id} /> + </div> + ); +} + +export default Document; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/DocumentBanner/DocumentBanner.hooks.ts b/frontend/appflowy_tauri/src/appflowy_app/components/document/DocumentBanner/DocumentBanner.hooks.ts deleted file mode 100644 index 200816463a..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/DocumentBanner/DocumentBanner.hooks.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { useAppDispatch, useAppSelector } from '$app/stores/store'; -import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks'; -import { updatePageIcon } from '$app_reducers/pages/async_actions'; -import { useCallback, useMemo } from 'react'; -import { ViewIconTypePB } from '@/services/backend'; -import { CoverType } from '$app/interfaces/document'; -import { updateNodeDataThunk } from '$app_reducers/document/async-actions'; -import { useSubscribeNode } from '$app/components/document/_shared/SubscribeNode.hooks'; - -export const heightCls = { - cover: 'h-[220px]', - icon: 'h-[80px]', - coverAndIcon: 'h-[250px]', - none: 'h-0', -}; - -export function useDocumentBanner(id: string) { - const dispatch = useAppDispatch(); - const { docId, controller } = useSubscribeDocument(); - const icon = useAppSelector((state) => state.pages.pageMap[docId]?.icon); - const { node } = useSubscribeNode(id); - const { cover, coverType } = node.data; - - const onUpdateIcon = useCallback( - (icon: string) => { - void dispatch( - updatePageIcon({ - id: docId, - icon: icon - ? { - ty: ViewIconTypePB.Emoji, - value: icon, - } - : undefined, - }) - ); - }, - [dispatch, docId] - ); - - const onUpdateCover = useCallback( - (coverType: CoverType | null, cover: string | null) => { - void dispatch( - updateNodeDataThunk({ - id, - data: { - coverType: coverType || '', - cover: cover || '', - }, - controller, - }) - ); - }, - [controller, dispatch, id] - ); - - const className = useMemo(() => { - if (cover && icon) return heightCls.coverAndIcon; - if (cover) return heightCls.cover; - if (icon) return heightCls.icon; - return heightCls.none; - }, [cover, icon]); - - return { - onUpdateCover, - onUpdateIcon, - className, - icon, - cover, - coverType, - node, - }; -} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/DocumentBanner/DocumentIcon.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/document/DocumentBanner/DocumentIcon.tsx deleted file mode 100644 index 6d56602ce7..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/DocumentBanner/DocumentIcon.tsx +++ /dev/null @@ -1,62 +0,0 @@ -import React, { useCallback, useState } from 'react'; -import Popover from '@mui/material/Popover'; -import EmojiPicker from '$app/components/_shared/EmojiPicker'; -import { PageIcon } from '$app_reducers/pages/slice'; - -function DocumentIcon({ - icon, - className, - onUpdateIcon, -}: { - icon?: PageIcon; - className?: string; - onUpdateIcon: (icon: string) => void; -}) { - const [anchorPosition, setAnchorPosition] = useState< - | undefined - | { - top: number; - left: number; - } - >(undefined); - const open = Boolean(anchorPosition); - const onOpen = useCallback((event: React.MouseEvent<HTMLDivElement>) => { - const rect = event.currentTarget.getBoundingClientRect(); - - setAnchorPosition({ - top: rect.top + rect.height, - left: rect.left, - }); - }, []); - - const onEmojiSelect = useCallback( - (emoji: string) => { - onUpdateIcon(emoji); - setAnchorPosition(undefined); - }, - [onUpdateIcon] - ); - - if (!icon) return null; - return ( - <> - <div className={`absolute bottom-0 left-0 pt-[20px] ${className}`}> - <div onClick={onOpen} className={'h-full w-full cursor-pointer rounded text-6xl hover:text-7xl'}> - {icon.value} - </div> - </div> - <Popover - open={open} - anchorReference='anchorPosition' - anchorPosition={anchorPosition} - disableAutoFocus - disableRestoreFocus - onClose={() => setAnchorPosition(undefined)} - > - <EmojiPicker onEmojiSelect={onEmojiSelect} /> - </Popover> - </> - ); -} - -export default DocumentIcon; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/DocumentBanner/TitleButtonGroup.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/document/DocumentBanner/TitleButtonGroup.tsx deleted file mode 100644 index f8ce25b0a7..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/DocumentBanner/TitleButtonGroup.tsx +++ /dev/null @@ -1,52 +0,0 @@ -import React, { useCallback } from 'react'; -import Button from '@mui/material/Button'; -import { useTranslation } from 'react-i18next'; -import { EmojiEmotionsOutlined, ImageOutlined } from '@mui/icons-material'; -import { BlockType, CoverType, NestedBlock } from '$app/interfaces/document'; -import { randomColor } from '$app/components/document/DocumentBanner/cover/config'; -import { randomEmoji } from '$app/utils/document/emoji'; -import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks'; -import { useAppSelector } from '$app/stores/store'; - -interface Props { - node: NestedBlock<BlockType.PageBlock>; - onUpdateCover: (coverType: CoverType, cover: string) => void; - onUpdateIcon: (icon: string) => void; -} -function TitleButtonGroup({ onUpdateIcon, onUpdateCover, node }: Props) { - const { t } = useTranslation(); - const { docId } = useSubscribeDocument(); - const icon = useAppSelector((state) => state.pages.pageMap[docId]?.icon); - - const showAddIcon = !icon; - const showAddCover = !node.data.cover; - - const onAddIcon = useCallback(() => { - const emoji = randomEmoji(); - - onUpdateIcon(emoji); - }, [onUpdateIcon]); - - const onAddCover = useCallback(() => { - const color = randomColor(); - - onUpdateCover(CoverType.Color, color); - }, [onUpdateCover]); - - return ( - <div className={'flex items-center py-2'}> - {showAddIcon && ( - <Button onClick={onAddIcon} color={'inherit'} startIcon={<EmojiEmotionsOutlined />}> - {t('document.plugins.cover.addIcon')} - </Button> - )} - {showAddCover && ( - <Button onClick={onAddCover} color={'inherit'} startIcon={<ImageOutlined />}> - {t('document.plugins.cover.addCover')} - </Button> - )} - </div> - ); -} - -export default TitleButtonGroup; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/DocumentBanner/cover/ChangeColors.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/document/DocumentBanner/cover/ChangeColors.tsx deleted file mode 100644 index 198325eb73..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/DocumentBanner/cover/ChangeColors.tsx +++ /dev/null @@ -1,35 +0,0 @@ -import React from 'react'; -import { useTranslation } from 'react-i18next'; -import { colors } from './config'; - -function ChangeColors({ cover, onChange }: { cover: string; onChange: (color: string) => void }) { - const { t } = useTranslation(); - - return ( - <div className={'flex flex-col'}> - <div className={'p-2 pb-4 text-text-caption'}>{t('document.plugins.cover.colors')}</div> - <div className={'flex flex-wrap'}> - {colors.map((color) => ( - <div - onClick={() => onChange(color)} - key={color} - style={{ backgroundColor: color }} - className={`m-1 flex h-[20px] w-[20px] cursor-pointer items-center justify-center rounded-[50%]`} - > - {cover === color && ( - <div - style={{ - borderColor: '#fff', - backgroundColor: color, - }} - className={'h-[16px] w-[calc(16px)] rounded-[50%] border-[2px] border-solid'} - /> - )} - </div> - ))} - </div> - </div> - ); -} - -export default ChangeColors; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/DocumentBanner/cover/ChangeCoverButton.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/document/DocumentBanner/cover/ChangeCoverButton.tsx deleted file mode 100644 index b4caf9b6f3..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/DocumentBanner/cover/ChangeCoverButton.tsx +++ /dev/null @@ -1,71 +0,0 @@ -import React, { useCallback, useState } from 'react'; -import { DeleteOutlineRounded } from '@mui/icons-material'; -import { useTranslation } from 'react-i18next'; -import ChangeCoverPopover from '$app/components/document/DocumentBanner/cover/ChangeCoverPopover'; -import { CoverType } from '$app/interfaces/document'; - -function ChangeCoverButton({ - visible, - cover, - coverType, - onUpdateCover, -}: { - visible: boolean; - cover: string; - coverType: CoverType; - onUpdateCover: (coverType: CoverType | null, cover: string | null) => void; -}) { - const { t } = useTranslation(); - const [anchorPosition, setAnchorPosition] = useState<undefined | { top: number; left: number }>(undefined); - const open = Boolean(anchorPosition); - const onClose = useCallback(() => { - setAnchorPosition(undefined); - }, []); - const onOpen = useCallback((event: React.MouseEvent<HTMLButtonElement>) => { - const rect = event.currentTarget.getBoundingClientRect(); - - setAnchorPosition({ - top: rect.top + rect.height, - left: rect.left + rect.width + 40, - }); - }, []); - - const onDeleteCover = useCallback(() => { - onUpdateCover(null, null); - }, [onUpdateCover]); - - return ( - <> - {visible && ( - <div className={'absolute bottom-4 right-6 flex text-[0.7rem]'}> - <button - onClick={onOpen} - className={ - 'flex items-center rounded-md border border-line-divider bg-bg-body p-1 px-2 opacity-70 hover:opacity-100' - } - > - {t('document.plugins.cover.changeCover')} - </button> - <button - className={ - 'ml-2 flex items-center rounded-md border border-line-divider bg-bg-body p-1 opacity-70 hover:opacity-100' - } - onClick={onDeleteCover} - > - <DeleteOutlineRounded /> - </button> - </div> - )} - <ChangeCoverPopover - cover={cover} - coverType={coverType} - open={open} - anchorPosition={anchorPosition} - onClose={onClose} - onUpdateCover={onUpdateCover} - /> - </> - ); -} - -export default ChangeCoverButton; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/DocumentBanner/cover/ChangeCoverPopover.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/document/DocumentBanner/cover/ChangeCoverPopover.tsx deleted file mode 100644 index 9c37cbe775..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/DocumentBanner/cover/ChangeCoverPopover.tsx +++ /dev/null @@ -1,63 +0,0 @@ -import React, { useRef } from 'react'; -import Popover from '@mui/material/Popover'; -import ChangeColors from '$app/components/document/DocumentBanner/cover/ChangeColors'; -import ChangeImages from '$app/components/document/DocumentBanner/cover/ChangeImages'; -import { CoverType } from '$app/interfaces/document'; - -function ChangeCoverPopover({ - open, - anchorPosition, - onClose, - cover, - onUpdateCover, -}: { - open: boolean; - anchorPosition?: { top: number; left: number }; - onClose: () => void; - coverType: CoverType; - cover: string; - onUpdateCover: (coverType: CoverType, cover: string) => void; -}) { - const ref = useRef<HTMLDivElement>(null); - - return ( - <Popover - open={open} - disableAutoFocus - disableRestoreFocus - anchorReference={'anchorPosition'} - anchorPosition={anchorPosition} - onClose={onClose} - transformOrigin={{ - vertical: 'top', - horizontal: 'right', - }} - PaperProps={{ - sx: { - height: 'auto', - overflow: 'visible', - }, - elevation: 0, - }} - > - <div - style={{ - boxShadow: - "var(--shadow-resize-popover)", - }} - className={'flex flex-col rounded-md bg-bg-body p-4 '} - ref={ref} - > - <ChangeColors - onChange={(color) => { - onUpdateCover(CoverType.Color, color); - }} - cover={cover} - /> - <ChangeImages cover={cover} onChange={(url) => onUpdateCover(CoverType.Image, url)} /> - </div> - </Popover> - ); -} - -export default ChangeCoverPopover; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/DocumentBanner/cover/ChangeImages.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/document/DocumentBanner/cover/ChangeImages.tsx deleted file mode 100644 index 0a3b9d42e7..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/DocumentBanner/cover/ChangeImages.tsx +++ /dev/null @@ -1,80 +0,0 @@ -import React, { useCallback, useEffect, useState } from 'react'; -import { useTranslation } from 'react-i18next'; - -import GalleryList from '$app/components/document/DocumentBanner/cover/GalleryList'; -import Button from '@mui/material/Button'; -import { readCoverImageUrls, readImage, writeCoverImageUrls } from '$app/utils/document/image'; -import { Log } from '$app/utils/log'; -import { Image } from '$app/components/document/DocumentBanner/cover/GalleryItem'; - -function ChangeImages({ onChange }: { onChange: (url: string) => void; cover: string }) { - const { t } = useTranslation(); - const [images, setImages] = useState<Image[]>([]); - const loadImageUrls = useCallback(async () => { - try { - const { images } = await readCoverImageUrls(); - const newImages = []; - - for (const image of images) { - try { - const src = await readImage(image.url); - - newImages.push({ ...image, src }); - } catch (e) { - Log.error(e); - } - } - - setImages(newImages); - } catch (e) { - Log.error(e); - } - }, [setImages]); - - const onAddImage = useCallback( - async (url: string) => { - const { images } = await readCoverImageUrls(); - - await writeCoverImageUrls([...images, { url }]); - await loadImageUrls(); - }, - [loadImageUrls] - ); - - const onDelete = useCallback( - async (image: Image) => { - const { images } = await readCoverImageUrls(); - const newImages = images.filter((i) => i.url !== image.url); - - await writeCoverImageUrls(newImages); - await loadImageUrls(); - }, - [loadImageUrls] - ); - - const onClearAll = useCallback(async () => { - await writeCoverImageUrls([]); - await loadImageUrls(); - }, [loadImageUrls]); - - useEffect(() => { - void loadImageUrls(); - }, [loadImageUrls]); - - return ( - <div className={'flex w-[500px] flex-col'}> - <div className={'flex justify-between pb-2 pl-2 pt-4 text-text-caption'}> - <div>{t('document.plugins.cover.images')}</div> - <Button onClick={onClearAll}>{t('document.plugins.cover.clearAll')}</Button> - </div> - <GalleryList - images={images} - onDelete={onDelete} - onAddImage={onAddImage} - onSelected={(image) => onChange(image.url)} - /> - </div> - ); -} - -export default ChangeImages; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/DocumentBanner/cover/DocumentCover.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/document/DocumentBanner/cover/DocumentCover.tsx deleted file mode 100644 index c5d72f20fa..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/DocumentBanner/cover/DocumentCover.tsx +++ /dev/null @@ -1,91 +0,0 @@ -import React, { useCallback, useEffect, useState } from 'react'; -import ChangeCoverButton from '$app/components/document/DocumentBanner/cover/ChangeCoverButton'; -import { readImage } from '$app/utils/document/image'; -import { CoverType } from '$app/interfaces/document'; -import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks'; - -function DocumentCover({ - cover, - coverType, - className, - onUpdateCover, -}: { - cover?: string; - coverType?: CoverType; - className?: string; - onUpdateCover: (coverType: CoverType | null, cover: string | null) => void; -}) { - const { docId } = useSubscribeDocument(); - const [hover, setHover] = useState(false); - const [leftOffset, setLeftOffset] = useState(0); - const [width, setWidth] = useState(0); - const [coverSrc, setCoverSrc] = useState<string | undefined>(); - const calcLeftOffset = useCallback((bodyOffsetLeft: number) => { - const docTitle = document.querySelector('.doc-title') as HTMLElement; - - if (!docTitle) { - setLeftOffset(0); - return; - } - - const titleOffsetLeft = docTitle.getBoundingClientRect().left; - - setLeftOffset(titleOffsetLeft - bodyOffsetLeft); - }, []); - - const handleWidthChange: ResizeObserverCallback = useCallback( - (entries) => { - entries.forEach((entry) => { - const { width } = entry.contentRect; - - setWidth(width); - const left = entry.target.getBoundingClientRect().left; - - calcLeftOffset(left); - }); - }, - [calcLeftOffset] - ); - - useEffect(() => { - const observer = new ResizeObserver(handleWidthChange); - const docPage = document.getElementById(`appflowy-block-doc-${docId}`) as HTMLElement; - - observer.observe(docPage); - return () => { - observer.disconnect(); - }; - }, [handleWidthChange, docId]); - - useEffect(() => { - if (coverType === CoverType.Image && cover) { - void (async () => { - const src = await readImage(cover); - - setCoverSrc(src); - })(); - } - }, [cover, coverType]); - - if (!cover || !coverType) return null; - return ( - <div - onMouseEnter={() => setHover(true)} - onMouseLeave={() => setHover(false)} - style={{ - left: -leftOffset, - width, - }} - className={`absolute top-0 w-full overflow-hidden ${className}`} - > - {coverType === CoverType.Image ? ( - <img src={coverSrc} className={'h-full w-full object-cover'} /> - ) : ( - <div className={'h-full w-full'} style={{ backgroundColor: cover }} /> - )} - <ChangeCoverButton onUpdateCover={onUpdateCover} visible={hover} cover={cover} coverType={coverType} /> - </div> - ); -} - -export default React.memo(DocumentCover); diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/DocumentBanner/cover/GalleryItem.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/document/DocumentBanner/cover/GalleryItem.tsx deleted file mode 100644 index d6fa325dec..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/DocumentBanner/cover/GalleryItem.tsx +++ /dev/null @@ -1,46 +0,0 @@ -import React, { useState } from 'react'; -import { DeleteOutlineRounded } from '@mui/icons-material'; -import ImageListItem from '@mui/material/ImageListItem'; - -export interface Image { - url: string; - src?: string; -} -function GalleryItem({ image, onSelected, onDelete }: { image: Image; onSelected: () => void; onDelete: () => void }) { - const [hover, setHover] = useState(false); - - return ( - <ImageListItem - onMouseEnter={() => setHover(true)} - onMouseLeave={() => setHover(false)} - className={'flex items-center justify-center '} - key={image.url} - > - <div className={'flex h-[80px] w-[120px] cursor-pointer items-center justify-center overflow-hidden rounded'}> - <img - style={{ - objectFit: 'cover', - width: '100%', - height: '100%', - }} - onClick={onSelected} - src={`${image.src}`} - alt={image.url} - /> - </div> - - <div - style={{ - display: hover ? 'block' : 'none', - }} - className={'absolute right-2 top-2'} - > - <button className={'rounded bg-bg-body opacity-80 hover:opacity-100'} onClick={() => onDelete()}> - <DeleteOutlineRounded /> - </button> - </div> - </ImageListItem> - ); -} - -export default GalleryItem; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/DocumentBanner/cover/GalleryList.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/document/DocumentBanner/cover/GalleryList.tsx deleted file mode 100644 index 7816e492e6..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/DocumentBanner/cover/GalleryList.tsx +++ /dev/null @@ -1,62 +0,0 @@ -import React, { useCallback, useState } from 'react'; -import ImageList from '@mui/material/ImageList'; -import ImageListItem from '@mui/material/ImageListItem'; -import { AddOutlined } from '@mui/icons-material'; -import { useTranslation } from 'react-i18next'; - -import Dialog from '@mui/material/Dialog'; -import DialogTitle from '@mui/material/DialogTitle'; -import ImageEdit from '$app/components/document/_shared/UploadImage/ImageEdit'; -import GalleryItem, { Image } from '$app/components/document/DocumentBanner/cover/GalleryItem'; - -interface Props { - onSelected: (image: Image) => void; - images: Image[]; - onDelete: (image: Image) => Promise<void>; - onAddImage: (url: string) => Promise<void>; -} -function GalleryList({ images, onSelected, onDelete, onAddImage }: Props) { - const { t } = useTranslation(); - const [showEdit, setShowEdit] = useState(false); - const onExitEdit = useCallback(() => { - setShowEdit(false); - }, []); - - return ( - <> - <ImageList className={'max-h-[172px] w-full overflow-auto'} cols={4}> - <ImageListItem> - <div - className={ - 'm-1 flex h-[80px] w-[120px] cursor-pointer items-center justify-center rounded border border-fill-default bg-content-blue-50 text-fill-default hover:bg-content-blue-100' - } - onClick={() => setShowEdit(true)} - > - <AddOutlined /> - </div> - </ImageListItem> - {images.map((image) => { - return ( - <GalleryItem - key={image.url} - image={image} - onSelected={() => onSelected(image)} - onDelete={() => onDelete(image)} - /> - ); - })} - </ImageList> - <Dialog open={showEdit} onClose={onExitEdit} fullWidth> - <DialogTitle>{t('button.upload')}</DialogTitle> - <ImageEdit - onSubmitUrl={async (url) => { - await onAddImage(url); - onExitEdit(); - }} - /> - </Dialog> - </> - ); -} - -export default GalleryList; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/DocumentBanner/cover/config.ts b/frontend/appflowy_tauri/src/appflowy_app/components/document/DocumentBanner/cover/config.ts deleted file mode 100644 index 4dac836486..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/DocumentBanner/cover/config.ts +++ /dev/null @@ -1,5 +0,0 @@ -export const colors = ['#e1fbff', '#defff1', '#ddffd6', '#f5ffdc', '#fff2cd', '#ffefe3', '#ffe7ee', '#e8e0ff']; - -export const randomColor = () => { - return colors[Math.floor(Math.random() * colors.length)]; -}; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/DocumentBanner/index.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/document/DocumentBanner/index.tsx deleted file mode 100644 index f8d4c269ac..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/DocumentBanner/index.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import { heightCls, useDocumentBanner } from './DocumentBanner.hooks'; -import TitleButtonGroup from './TitleButtonGroup'; -import DocumentCover from './cover/DocumentCover'; -import DocumentIcon from './DocumentIcon'; - -function DocumentBanner({ id, hover }: { id: string; hover: boolean }) { - const { onUpdateCover, node, onUpdateIcon, icon, cover, className, coverType } = useDocumentBanner(id); - - return ( - <> - <div - style={{ - display: icon || cover ? 'block' : 'none', - }} - className={`relative ${className}`} - > - <DocumentCover onUpdateCover={onUpdateCover} className={heightCls.cover} cover={cover} coverType={coverType} /> - <DocumentIcon onUpdateIcon={onUpdateIcon} className={heightCls.icon} icon={icon} /> - </div> - <div - style={{ - opacity: hover ? 1 : 0, - }} - > - <TitleButtonGroup node={node} onUpdateCover={onUpdateCover} onUpdateIcon={onUpdateIcon} /> - </div> - </> - ); -} - -export default DocumentBanner; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/DocumentTitle/DocumentTitle.hooks.ts b/frontend/appflowy_tauri/src/appflowy_app/components/document/DocumentTitle/DocumentTitle.hooks.ts deleted file mode 100644 index 82be68be35..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/DocumentTitle/DocumentTitle.hooks.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { useSubscribeNode } from '../_shared/SubscribeNode.hooks'; -import { useEffect } from 'react'; -import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks'; -import { useAppDispatch, useAppSelector } from '$app/stores/store'; -import { documentActions } from '$app_reducers/document/slice'; - -export function useDocumentTitle(id: string) { - const { node } = useSubscribeNode(id); - const dispatch = useAppDispatch(); - const { docId } = useSubscribeDocument(); - const page = useAppSelector((state) => state.pages.pageMap[docId]); - - useEffect(() => { - if (page) { - dispatch( - documentActions.updateRootNodeDelta({ - docId, - delta: [{ insert: page.name }], - rootId: id, - }) - ); - } - }, [dispatch, docId, id, page]); - - return { - node, - }; -} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/DocumentTitle/index.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/document/DocumentTitle/index.tsx deleted file mode 100644 index 8ca6bb8fdf..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/DocumentTitle/index.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import React, { useState } from 'react'; -import { useDocumentTitle } from './DocumentTitle.hooks'; -import TextBlock from '../TextBlock'; -import { useTranslation } from 'react-i18next'; -import DocumentBanner from '$app/components/document/DocumentBanner'; -import { ContainerType, useContainerType } from '$app/hooks/document.hooks'; - -export default function DocumentTitle({ id }: { id: string }) { - const { node } = useDocumentTitle(id); - const { t } = useTranslation(); - const [hover, setHover] = useState(false); - - const containerType = useContainerType(); - - if (!node || containerType !== ContainerType.DocumentPage) return null; - - return ( - <div className={'flex flex-col'} onMouseEnter={() => setHover(true)} onMouseLeave={() => setHover(false)}> - <DocumentBanner id={node.id} hover={hover} /> - <div data-block-id={node.id} className='doc-title relative text-4xl font-bold'> - <TextBlock placeholder={t('document.title.placeholder')} childIds={[]} node={node} /> - </div> - </div> - ); -} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/EquationBlock/index.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/document/EquationBlock/index.tsx deleted file mode 100644 index 489a7aa641..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/EquationBlock/index.tsx +++ /dev/null @@ -1,87 +0,0 @@ -import React, { useCallback, useState } from 'react'; -import { BlockType, NestedBlock } from '$app/interfaces/document'; -import KatexMath from '$app/components/document/_shared/KatexMath'; -import EquationEditContent from '$app/components/document/_shared/TemporaryInput/EquationEditContent'; -import { Functions } from '@mui/icons-material'; -import { useBlockPopover } from '$app/components/document/_shared/BlockPopover/BlockPopover.hooks'; -import { updateNodeDataThunk } from '$app_reducers/document/async-actions'; -import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks'; -import { useAppDispatch } from '$app/stores/store'; -import { useTranslation } from 'react-i18next'; - -function EquationBlock({ node }: { node: NestedBlock<BlockType.EquationBlock> }) { - const formula = node.data.formula; - const [value, setValue] = useState(formula); - const { controller } = useSubscribeDocument(); - const id = node.id; - const dispatch = useAppDispatch(); - - const onChange = useCallback((newVal: string) => { - setValue(newVal); - }, []); - - const onAfterOpen = useCallback(() => { - setValue(formula); - }, [formula]); - - const onConfirm = useCallback(async () => { - await dispatch( - updateNodeDataThunk({ - id, - data: { - formula: value, - }, - controller, - }) - ); - }, [dispatch, id, value, controller]); - - const renderContent = useCallback( - ({ onClose }: { onClose: () => void }) => { - return ( - <EquationEditContent - placeholder={'c = \\pm\\sqrt{a^2 + b^2\\text{ if }a\\neq 0\\text{ or }b\\neq 0}'} - multiline={true} - value={value} - onChange={onChange} - onConfirm={async () => { - await onConfirm(); - onClose(); - }} - /> - ); - }, - [value, onChange, onConfirm] - ); - - const { open, contextHolder, openPopover, anchorElRef } = useBlockPopover({ - id: node.id, - renderContent, - onAfterOpen, - }); - const displayFormula = open ? value : formula; - - const { t } = useTranslation(); - - return ( - <> - <div - ref={anchorElRef} - onClick={openPopover} - className={'my-1 flex min-h-[59px] cursor-pointer flex-col overflow-hidden rounded hover:bg-content-blue-50'} - > - {displayFormula ? ( - <KatexMath latex={displayFormula} /> - ) : ( - <div className={'flex h-[100%] w-[100%] flex-1 items-center bg-content-blue-50 px-1 text-text-caption'}> - <Functions /> - <span>{t('document.plugins.mathEquation.addMathEquation')}</span> - </div> - )} - </div> - {contextHolder} - </> - ); -} - -export default React.memo(EquationBlock); diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/GridBlock/index.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/document/GridBlock/index.tsx deleted file mode 100644 index d3c9d10dc1..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/GridBlock/index.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import React, { useEffect, useRef, useState } from 'react'; -import { BlockType, NestedBlock } from '$app/interfaces/document'; -import { Database } from '$app/components/database'; -import { ViewIdProvider } from '@/appflowy_app/hooks'; - -function GridBlock({ node }: { node: NestedBlock<BlockType.GridBlock> }) { - const viewId = node.data.viewId; - const ref = useRef<HTMLDivElement>(null); - const [selectedViewId, onChangeSelectedViewId] = useState(viewId); - - useEffect(() => { - const element = ref.current; - - if (!element) return; - - const resizeObserver = new ResizeObserver(() => { - element.style.minHeight = `${element.clientHeight}px`; - }); - - resizeObserver.observe(element); - - return () => { - resizeObserver.disconnect(); - }; - }, []); - - return ( - <div className='flex h-[400px] overflow-hidden py-3 caret-text-title' ref={ref}> - <ViewIdProvider value={viewId}> - <Database selectedViewId={selectedViewId} setSelectedViewId={onChangeSelectedViewId} /> - </ViewIdProvider> - </div> - ); -} - -export default React.memo(GridBlock); 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 deleted file mode 100644 index a8d1ee3da4..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/HeadingBlock/index.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import TextBlock from '$app/components/document/TextBlock'; -import { BlockType, NestedBlock } from '@/appflowy_app/interfaces/document'; - -const fontSize: Record<string, string> = { - 1: 'mt-5 text-3xl', - 2: 'mt-4 text-2xl', - 3: 'text-xl', -}; - -export default function HeadingBlock({ node }: { node: NestedBlock<BlockType.HeadingBlock> }) { - return ( - <div className={`${fontSize[node.data.level]} font-semibold `}> - <TextBlock node={node} childIds={[]} /> - </div> - ); -} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/ImageBlock/ImageAlign.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/document/ImageBlock/ImageAlign.tsx deleted file mode 100644 index bbd2c40c31..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/ImageBlock/ImageAlign.tsx +++ /dev/null @@ -1,119 +0,0 @@ -import React, { useCallback, useEffect, useRef, useState } from 'react'; -import { useAppDispatch } from '$app/stores/store'; -import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks'; -import { Align } from '$app/interfaces/document'; -import { FormatAlignCenter, FormatAlignLeft, FormatAlignRight } from '@mui/icons-material'; -import { updateNodeDataThunk } from '$app_reducers/document/async-actions'; -import Popover from '@mui/material/Popover'; -import { useTranslation } from 'react-i18next'; -import { Tooltip } from '@mui/material'; - -function ImageAlign({ - id, - align, - onOpen, - onClose, -}: { - id: string; - align: Align; - onOpen: () => void; - onClose: () => void; -}) { - const ref = useRef<HTMLDivElement | null>(null); - const [anchorEl, setAnchorEl] = useState<HTMLDivElement>(); - const popoverOpen = Boolean(anchorEl); - const { t } = useTranslation(); - - useEffect(() => { - if (popoverOpen) { - onOpen(); - } else { - onClose(); - } - }, [onClose, onOpen, popoverOpen]); - - const dispatch = useAppDispatch(); - const { controller } = useSubscribeDocument(); - const renderAlign = (align: Align) => { - switch (align) { - case Align.Left: - return <FormatAlignLeft />; - case Align.Center: - return <FormatAlignCenter />; - default: - return <FormatAlignRight />; - } - }; - - const updateAlign = useCallback( - (align: Align) => { - void dispatch( - updateNodeDataThunk({ - id, - data: { - align, - }, - controller, - }) - ); - setAnchorEl(undefined); - }, - [controller, dispatch, id] - ); - - return ( - <> - <Tooltip disableInteractive placement={'top'} title={t('document.plugins.optionAction.align')}> - <div - ref={ref} - className='flex items-center justify-center p-1' - onClick={(_) => { - ref.current && setAnchorEl(ref.current); - }} - > - {renderAlign(align)} - </div> - </Tooltip> - <Popover - open={popoverOpen} - anchorOrigin={{ - vertical: 'bottom', - horizontal: 'center', - }} - transformOrigin={{ - vertical: 'top', - horizontal: 'center', - }} - onMouseDown={(e) => e.stopPropagation()} - anchorEl={anchorEl} - onClose={() => setAnchorEl(undefined)} - PaperProps={{ - style: { - backgroundColor: 'var(--bg-body)', - }, - }} - > - <div className='flex items-center justify-center p-1'> - {[Align.Left, Align.Center, Align.Right].map((item: Align) => { - return ( - <div - key={item} - style={{ - color: align === item ? '#00BCF0' : '#fff', - }} - className={'cursor-pointer'} - onClick={() => { - updateAlign(item); - }} - > - {renderAlign(item)} - </div> - ); - })} - </div> - </Popover> - </> - ); -} - -export default ImageAlign; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/ImageBlock/ImagePlaceholder.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/document/ImageBlock/ImagePlaceholder.tsx deleted file mode 100644 index 0f311216f6..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/ImageBlock/ImagePlaceholder.tsx +++ /dev/null @@ -1,57 +0,0 @@ -import React from 'react'; -import { Alert, CircularProgress } from '@mui/material'; -import { ImageSvg } from '$app/components/_shared/svg/ImageSvg'; -import { useTranslation } from 'react-i18next'; - -function ImagePlaceholder({ - error, - loading, - isEmpty, - width, - height, - alignSelf, - openPopover, -}: { - error: boolean; - loading: boolean; - isEmpty: boolean; - width?: number; - height?: number; - alignSelf: string; - openPopover: () => void; -}) { - const visible = loading || error || isEmpty; - const { t } = useTranslation(); - - return ( - <div - style={{ - width: width ? width + 'px' : undefined, - height: height ? height + 'px' : undefined, - alignSelf, - visibility: visible ? undefined : 'hidden', - }} - className={'absolute z-10 flex h-[100%] min-h-[59px] w-[100%] items-center justify-center'} - > - {loading && <CircularProgress />} - {error && ( - <Alert className={'flex h-[100%] w-[100%] items-center justify-center'} severity='error'> - Error loading image - </Alert> - )} - {isEmpty && ( - <div - onClick={openPopover} - className={'flex h-[100%] w-[100%] flex-1 items-center rounded bg-content-blue-50 px-1 text-text-caption'} - > - <i className={'mx-2 h-5 w-5'}> - <ImageSvg /> - </i> - <span>{t('document.imageBlock.placeholder')}</span> - </div> - )} - </div> - ); -} - -export default ImagePlaceholder; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/ImageBlock/ImageRender.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/document/ImageBlock/ImageRender.tsx deleted file mode 100644 index 074c004526..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/ImageBlock/ImageRender.tsx +++ /dev/null @@ -1,71 +0,0 @@ -import React, { useCallback, useState } from 'react'; -import ImageToolbar from '$app/components/document/ImageBlock/ImageToolbar'; -import { BlockType, NestedBlock } from '$app/interfaces/document'; - -function ImageRender({ - src, - node, - width, - height, - alignSelf, - onResizeStart, -}: { - node: NestedBlock<BlockType.ImageBlock>; - width: number; - height: number; - alignSelf: string; - src: string; - onResizeStart: (e: React.MouseEvent<HTMLDivElement>, isLeft: boolean) => void; -}) { - const [toolbarOpen, setToolbarOpen] = useState<boolean>(false); - - const renderResizer = useCallback( - (isLeft: boolean) => { - return ( - <div - onMouseDown={(e) => onResizeStart(e, isLeft)} - className={`${toolbarOpen ? 'pointer-events-auto' : 'pointer-events-none'} absolute z-[2] ${ - isLeft ? 'left-0' : 'right-0' - } top-0 flex h-[100%] w-[15px] cursor-col-resize items-center justify-center`} - > - <div - className={`h-[48px] max-h-[50%] w-2 rounded-[20px] border border-solid border-line-divider bg-line-border ${ - toolbarOpen ? 'opacity-1' : 'opacity-0' - } transition-opacity duration-300 `} - /> - </div> - ); - }, - [onResizeStart, toolbarOpen] - ); - - return ( - <div - contentEditable={false} - onMouseEnter={() => setToolbarOpen(true)} - onMouseLeave={() => setToolbarOpen(false)} - style={{ - width: width + 'px', - height: height + 'px', - alignSelf, - }} - className={`relative cursor-default`} - > - {src && ( - <img - src={src} - className={'relative cursor-pointer'} - style={{ - height: height + 'px', - width: width + 'px', - }} - /> - )} - {renderResizer(true)} - {renderResizer(false)} - <ImageToolbar id={node.id} open={toolbarOpen} align={node.data.align} /> - </div> - ); -} - -export default ImageRender; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/ImageBlock/ImageToolbar.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/document/ImageBlock/ImageToolbar.tsx deleted file mode 100644 index c9fc838a81..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/ImageBlock/ImageToolbar.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import React, { useState } from 'react'; -import { Align } from '$app/interfaces/document'; -import ImageAlign from '$app/components/document/ImageBlock/ImageAlign'; -import { DeleteOutline } from '@mui/icons-material'; -import { useAppDispatch } from '$app/stores/store'; -import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks'; -import { deleteNodeThunk } from '$app_reducers/document/async-actions'; -import { useTranslation } from 'react-i18next'; -import Tooltip from '@mui/material/Tooltip'; - -function ImageToolbar({ id, open, align }: { id: string; open: boolean; align: Align }) { - const [popoverOpen, setPopoverOpen] = useState(false); - const visible = open || popoverOpen; - const dispatch = useAppDispatch(); - const { controller } = useSubscribeDocument(); - - const { t } = useTranslation(); - - return ( - <> - <div - className={`${ - visible ? 'opacity-1 pointer-events-auto' : 'pointer-events-none opacity-0' - } absolute right-2 top-2 z-[1px] flex h-[26px] max-w-[calc(100%-16px)] cursor-pointer items-center justify-center whitespace-nowrap rounded bg-bg-body text-sm text-text-title transition-opacity`} - > - <ImageAlign id={id} align={align} onOpen={() => setPopoverOpen(true)} onClose={() => setPopoverOpen(false)} /> - <Tooltip disableInteractive placement={'top'} title={t('button.delete')}> - <div - onClick={() => { - void dispatch(deleteNodeThunk({ id, controller })); - }} - className='flex items-center justify-center p-1' - > - <DeleteOutline /> - </div> - </Tooltip> - </div> - </> - ); -} - -export default ImageToolbar; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/ImageBlock/index.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/document/ImageBlock/index.tsx deleted file mode 100644 index aff3cf4bc6..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/ImageBlock/index.tsx +++ /dev/null @@ -1,80 +0,0 @@ -import React, { useCallback } from 'react'; -import { BlockType, NestedBlock } from '$app/interfaces/document'; -import { useImageBlock } from './useImageBlock'; -import { useBlockPopover } from '$app/components/document/_shared/BlockPopover/BlockPopover.hooks'; -import ImagePlaceholder from '$app/components/document/ImageBlock/ImagePlaceholder'; -import ImageRender from '$app/components/document/ImageBlock/ImageRender'; -import { useAppDispatch } from '$app/stores/store'; -import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks'; -import { updateNodeDataThunk } from '$app_reducers/document/async-actions'; -import ImageEdit from '$app/components/document/_shared/UploadImage/ImageEdit'; - -function ImageBlock({ node }: { node: NestedBlock<BlockType.ImageBlock> }) { - const { url } = node.data; - const { displaySize, onResizeStart, src, alignSelf, loading, error } = useImageBlock(node); - const dispatch = useAppDispatch(); - const { controller } = useSubscribeDocument(); - const id = node.id; - - const renderPopoverContent = useCallback( - ({ onClose }: { onClose: () => void }) => { - const onSubmitUrl = (url: string) => { - if (!url) return; - void dispatch( - updateNodeDataThunk({ - id, - data: { - url, - }, - controller, - }) - ); - onClose(); - }; - - return ( - <div className={'w-[540px]'}> - <ImageEdit url={url} onSubmitUrl={onSubmitUrl} /> - </div> - ); - }, - [controller, dispatch, id, url] - ); - - const { anchorElRef, contextHolder, openPopover } = useBlockPopover({ - id: node.id, - renderContent: renderPopoverContent, - }); - - const { width, height } = displaySize; - - return ( - <> - <div - ref={anchorElRef} - className={'my-1 flex min-h-[59px] cursor-pointer flex-col justify-center overflow-hidden rounded'} - > - <ImageRender - node={node} - width={width} - height={height} - alignSelf={alignSelf} - src={src} - onResizeStart={onResizeStart} - /> - <ImagePlaceholder - isEmpty={!src} - alignSelf={alignSelf} - width={width} - height={height} - loading={loading} - error={error} - openPopover={openPopover} - /> - </div> - {contextHolder} - </> - ); -} - -export default React.memo(ImageBlock); diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/ImageBlock/useImageBlock.ts b/frontend/appflowy_tauri/src/appflowy_app/components/document/ImageBlock/useImageBlock.ts deleted file mode 100644 index f9b7c7da5d..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/ImageBlock/useImageBlock.ts +++ /dev/null @@ -1,182 +0,0 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { Align, BlockType, NestedBlock } from '$app/interfaces/document'; -import { useAppDispatch } from '$app/stores/store'; -import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks'; -import { updateNodeDataThunk } from '$app_reducers/document/async-actions'; -import { Log } from '$app/utils/log'; -import { getNode } from '$app/utils/document/node'; -import { readImage } from '$app/utils/document/image'; - -export function useImageBlock(node: NestedBlock<BlockType.ImageBlock>) { - const { url, width, align, height } = node.data; - const dispatch = useAppDispatch(); - const [loading, setLoading] = useState<boolean>(false); - const [error, setError] = useState<boolean>(false); - const { controller } = useSubscribeDocument(); - const [resizing, setResizing] = useState<boolean>(false); - const startResizePoint = useRef<{ - left: boolean; - x: number; - y: number; - }>(); - const startResizeWidth = useRef<number>(0); - - const [src, setSrc] = useState<string>(''); - const [displaySize, setDisplaySize] = useState<{ - width: number; - height: number; - }>({ - width: width || 0, - height: height || 0, - }); - - const onResizeStart = useCallback( - (e: React.MouseEvent<HTMLDivElement>, left: boolean) => { - e.preventDefault(); - e.stopPropagation(); - setResizing(true); - startResizeWidth.current = displaySize.width; - startResizePoint.current = { - x: e.clientX, - y: e.clientY, - left, - }; - }, - [displaySize.width] - ); - - const updateWidth = useCallback( - (width: number, height: number) => { - void dispatch( - updateNodeDataThunk({ - id: node.id, - data: { - width, - height, - }, - controller, - }) - ); - }, - [controller, dispatch, node.id] - ); - - useEffect(() => { - const currentSize: { - width?: number; - height?: number; - } = {}; - const onResize = (e: MouseEvent) => { - const clientX = e.clientX; - - if (!startResizePoint.current) return; - const { x, left } = startResizePoint.current; - const startWidth = startResizeWidth.current || 0; - const diff = (left ? x - clientX : clientX - x) / 2; - - setDisplaySize((prevState) => { - const displayWidth = prevState?.width || 0; - const displayHeight = prevState?.height || 0; - const ratio = displayWidth / displayHeight; - - const width = startWidth + diff; - const height = width / ratio; - - Object.assign(currentSize, { - width, - height, - }); - return { - width, - height, - }; - }); - }; - - const onResizeEnd = () => { - setResizing(false); - if (!startResizePoint.current) return; - startResizePoint.current = undefined; - if (!currentSize.width || !currentSize.height) return; - updateWidth(Math.floor(currentSize.width) || 0, Math.floor(currentSize.height) || 0); - }; - - if (resizing) { - document.addEventListener('mousemove', onResize); - document.addEventListener('mouseup', onResizeEnd); - } else { - document.removeEventListener('mousemove', onResize); - document.removeEventListener('mouseup', onResizeEnd); - } - }, [resizing, updateWidth]); - - const alignSelf = useMemo(() => { - if (align === Align.Left) return 'flex-start'; - if (align === Align.Right) return 'flex-end'; - return 'center'; - }, [align]); - - useEffect(() => { - if (!url) return; - const image = new Image(); - - setLoading(true); - setError(false); - image.onload = function () { - const ratio = image.width / image.height; - const element = getNode(node.id) as HTMLDivElement; - - if (!element) return; - const maxWidth = element.offsetWidth || 1000; - const imageWidth = Math.min(image.width, maxWidth); - - setDisplaySize((prevState) => { - if (prevState.width <= 0) { - return { - width: imageWidth, - height: imageWidth / ratio, - }; - } - - return prevState; - }); - - setLoading(false); - }; - - image.onerror = function () { - setLoading(false); - setError(true); - }; - - const isRemote = url.startsWith('http'); - - if (isRemote) { - setSrc(url); - image.src = url; - return; - } - - void (async () => { - setError(false); - try { - const src = await readImage(url); - - setSrc(src); - image.src = src; - } catch (e) { - Log.error(e); - setError(true); - } - })(); - }, [node.id, url]); - - return { - displaySize, - src, - alignSelf, - onResizeStart, - loading, - error, - }; -} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/Mention/Mention.hooks.ts b/frontend/appflowy_tauri/src/appflowy_app/components/document/Mention/Mention.hooks.ts deleted file mode 100644 index ab12f2e66a..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/Mention/Mention.hooks.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { useCallback, useEffect, useState } from 'react'; -import { useAppSelector } from '$app/stores/store'; -import { Page } from '$app_reducers/pages/slice'; - -export function useMentionPopoverProps({ open }: { open: boolean }) { - const [anchorPosition, setAnchorPosition] = useState< - | { - top: number; - left: number; - } - | undefined - >(undefined); - const popoverOpen = Boolean(anchorPosition); - const getPosition = useCallback(() => { - const range = document.getSelection()?.getRangeAt(0); - const rangeRect = range?.getBoundingClientRect(); - - return rangeRect; - }, []); - - useEffect(() => { - if (open) { - const position = getPosition(); - - if (!position) return; - setAnchorPosition({ - top: position.top + position.height || 0, - left: position.left + 14 || 0, - }); - } else { - setAnchorPosition(undefined); - } - }, [getPosition, open]); - - return { - anchorPosition, - popoverOpen, - }; -} - -export function useLoadRecentPages(searchText: string) { - const [recentPages, setRecentPages] = useState<Page[]>([]); - const pages = useAppSelector((state) => state.pages.pageMap); - - useEffect(() => { - const recentPages = Object.values(pages) - .map((page) => { - return page; - }) - .filter((page) => { - return page.name.toLowerCase().includes(searchText.toLowerCase()); - }); - - setRecentPages(recentPages); - }, [pages, searchText]); - - return { - recentPages, - }; -} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/Mention/MentionPopover.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/document/Mention/MentionPopover.tsx deleted file mode 100644 index 27a008d9e0..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/Mention/MentionPopover.tsx +++ /dev/null @@ -1,79 +0,0 @@ -import React, { useCallback } from 'react'; -import { useSubscribeMentionState } from '$app/components/document/_shared/SubscribeMention.hooks'; -import Popover from '@mui/material/Popover'; -import { useAppDispatch } from '$app/stores/store'; -import { mentionActions } from '$app_reducers/document/mention_slice'; -import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks'; -import { useMentionPopoverProps } from '$app/components/document/Mention/Mention.hooks'; -import RecentPages from '$app/components/document/Mention/RecentPages'; -import { formatMention, MentionType } from '$app_reducers/document/async-actions/mention'; -import { useSubscribePanelSearchText } from '$app/components/document/_shared/usePanelSearchText'; - -function MentionPopover() { - const { docId, controller } = useSubscribeDocument(); - const { open, blockId } = useSubscribeMentionState(); - - const dispatch = useAppDispatch(); - const onClose = useCallback(() => { - dispatch( - mentionActions.close({ - docId, - }) - ); - }, [dispatch, docId]); - - const { searchText } = useSubscribePanelSearchText({ - blockId, - open, - }); - - const { popoverOpen, anchorPosition } = useMentionPopoverProps({ - open, - }); - - const onSelectPage = useCallback( - async (pageId: string) => { - await dispatch( - formatMention({ - controller, - type: MentionType.PAGE, - value: pageId, - searchTextLength: searchText.length, - }) - ); - onClose(); - }, - [controller, dispatch, searchText.length, onClose] - ); - - if (!open) return null; - return ( - <Popover - onClose={onClose} - open={popoverOpen} - disableAutoFocus - disableRestoreFocus={true} - anchorReference={'anchorPosition'} - anchorPosition={anchorPosition} - transformOrigin={{ vertical: 'top', horizontal: 'left' }} - PaperProps={{ - sx: { - height: 'auto', - overflow: 'visible', - }, - elevation: 0, - }} - > - <div - style={{ - boxShadow: 'var(--shadow-resize-popover)', - }} - className={'flex w-[420px] flex-col rounded-md bg-bg-body px-4 py-2'} - > - <RecentPages onSelect={onSelectPage} searchText={searchText} /> - </div> - </Popover> - ); -} - -export default MentionPopover; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/Mention/RecentPages.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/document/Mention/RecentPages.tsx deleted file mode 100644 index da746eb679..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/Mention/RecentPages.tsx +++ /dev/null @@ -1,59 +0,0 @@ -import React, { useEffect, useState } from 'react'; -import { List } from '@mui/material'; -import MenuItem from '@mui/material/MenuItem'; -import { useLoadRecentPages } from '$app/components/document/Mention/Mention.hooks'; -import { useTranslation } from 'react-i18next'; -import { Article } from '@mui/icons-material'; -import { useBindArrowKey } from '$app/components/document/_shared/useBindArrowKey'; - -function RecentPages({ searchText, onSelect }: { searchText: string; onSelect: (pageId: string) => void }) { - const { t } = useTranslation(); - const { recentPages } = useLoadRecentPages(searchText); - const [selectOption, setSelectOption] = useState<string | null>(null); - - const { run, stop } = useBindArrowKey({ - options: recentPages.map((item) => item.id), - onChange: (key) => { - setSelectOption(key); - }, - selectOption, - onEnter: () => selectOption && onSelect(selectOption), - }); - - useEffect(() => { - if (recentPages.length > 0) { - run(); - } else { - stop(); - } - }, [recentPages, run, stop]); - - return ( - <List> - <div className={'p-2 text-text-caption'}>{t('document.mention.page.label')}</div> - {recentPages.map((page) => ( - <MenuItem - style={{ - margin: 0, - padding: '0.5rem', - }} - onMouseEnter={() => { - setSelectOption(page.id); - }} - selected={selectOption === page.id} - key={page.id} - onClick={() => { - onSelect(page.id); - }} - > - <div className={'flex items-center'}> - <div className={'mr-2'}>{page.icon?.value || <Article />}</div> - <div>{page.name || t('menuAppHeader.defaultNewPageName')}</div> - </div> - </MenuItem> - ))} - </List> - ); -} - -export default RecentPages; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/Node/Node.hooks.ts b/frontend/appflowy_tauri/src/appflowy_app/components/document/Node/Node.hooks.ts deleted file mode 100644 index 89b05cb975..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/Node/Node.hooks.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { useRef } from 'react'; -import { useSubscribeNode } from '../_shared/SubscribeNode.hooks'; - -export function useNode(id: string) { - const { node, childIds, isSelected } = useSubscribeNode(id); - const ref = useRef<HTMLDivElement>(null); - - return { - ref, - node, - childIds, - isSelected, - }; -} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/Node/NodeChildren.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/document/Node/NodeChildren.tsx deleted file mode 100644 index c134058dba..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/Node/NodeChildren.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import React from 'react'; -import NodeComponent from '$app/components/document/Node/index'; - -function NodeChildren({ childIds, ...props }: { childIds?: string[] } & React.HTMLAttributes<HTMLDivElement>) { - return childIds && childIds.length > 0 ? ( - <div {...props}> - {childIds.map((item) => ( - <NodeComponent key={item} id={item} /> - ))} - </div> - ) : null; -} - -export default React.memo(NodeChildren); 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 deleted file mode 100644 index 3dc5f6c50b..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/Node/index.tsx +++ /dev/null @@ -1,113 +0,0 @@ -import React, { useCallback } from 'react'; -import { useNode } from './Node.hooks'; -import { withErrorBoundary } from 'react-error-boundary'; -import { ErrorBoundaryFallbackComponent } from '../_shared/ErrorBoundaryFallbackComponent'; -import TextBlock from '../TextBlock'; -import { BlockType } from '$app/interfaces/document'; -import { Alert } from '@mui/material'; - -import HeadingBlock from '$app/components/document/HeadingBlock'; -import TodoListBlock from '$app/components/document/TodoListBlock'; -import QuoteBlock from '$app/components/document/QuoteBlock'; -import BulletedListBlock from '$app/components/document/BulletedListBlock'; -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'; -import { NodeIdContext } from '$app/components/document/_shared/SubscribeNode.hooks'; -import EquationBlock from '$app/components/document/EquationBlock'; -import ImageBlock from '$app/components/document/ImageBlock'; -import GridBlock from '$app/components/document/GridBlock'; - -import { useTranslation } from 'react-i18next'; -import BlockDraggable from '$app/components/_shared/BlockDraggable'; -import { BlockDraggableType } from '$app_reducers/block-draggable/slice'; - -function NodeComponent({ id, ...props }: { id: string } & React.HTMLAttributes<HTMLDivElement>) { - const { node, childIds, isSelected, ref } = useNode(id); - - const renderBlock = useCallback(() => { - switch (node.type) { - case BlockType.TextBlock: - return <TextBlock node={node} childIds={childIds} />; - - case BlockType.HeadingBlock: - return <HeadingBlock node={node} />; - - case BlockType.TodoListBlock: - return <TodoListBlock node={node} childIds={childIds} />; - - case BlockType.QuoteBlock: - return <QuoteBlock node={node} childIds={childIds} />; - case BlockType.BulletedListBlock: - return <BulletedListBlock node={node} childIds={childIds} />; - - case BlockType.NumberedListBlock: - return <NumberedListBlock node={node} childIds={childIds} />; - - case BlockType.ToggleListBlock: - return <ToggleListBlock node={node} childIds={childIds} />; - - case BlockType.DividerBlock: - return <DividerBlock />; - - case BlockType.CalloutBlock: - return <CalloutBlock node={node} childIds={childIds} />; - - case BlockType.CodeBlock: - return <CodeBlock node={node} />; - case BlockType.EquationBlock: - return <EquationBlock node={node} />; - case BlockType.ImageBlock: - return <ImageBlock node={node} />; - case BlockType.GridBlock: - return <GridBlock node={node} />; - default: - return <UnSupportedBlock />; - } - }, [node, childIds]); - - const className = props.className ? ` ${props.className}` : ''; - - if (!node) return null; - - return ( - <NodeIdContext.Provider value={id}> - <BlockDraggable - id={id} - type={BlockDraggableType.BLOCK} - getAnchorEl={() => { - return ref.current?.querySelector(`[data-draggable-anchor="${id}"]`) || null; - }} - {...props} - ref={ref} - data-block-id={node.id} - className={className} - > - {renderBlock()} - <BlockOverlay id={id} /> - {isSelected ? ( - <div className='pointer-events-none absolute inset-0 z-[-1] my-[1px] rounded-[4px] bg-content-blue-100' /> - ) : null} - </BlockDraggable> - </NodeIdContext.Provider> - ); -} - -const NodeWithErrorBoundary = withErrorBoundary(React.memo(NodeComponent), { - FallbackComponent: ErrorBoundaryFallbackComponent, -}); - -const UnSupportedBlock = () => { - const { t } = useTranslation(); - - return ( - <Alert severity='info' className='mb-2'> - <p>{t('unSupportBlock')}</p> - </Alert> - ); -}; - -export default NodeWithErrorBoundary; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/NumberedListBlock/NumberedListBlock.hooks.ts b/frontend/appflowy_tauri/src/appflowy_app/components/document/NumberedListBlock/NumberedListBlock.hooks.ts deleted file mode 100644 index f9ff2c55ed..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/NumberedListBlock/NumberedListBlock.hooks.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { useAppSelector } from '$app/stores/store'; -import { BlockType, NestedBlock } from '$app/interfaces/document'; -import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks'; - -export function useNumberedListBlock(node: NestedBlock<BlockType.NumberedListBlock>) { - const { docId } = useSubscribeDocument(); - - // Find the last index of the previous blocks - const prevNumberedIndex = useAppSelector((state) => { - const documentState = state['document'][docId]; - const nodes = documentState.nodes; - const children = documentState.children; - - if (!node.parent) return 0; - // The parent must be existed - const parent = nodes[node.parent]; - const siblings = children[parent.children]; - const index = siblings.indexOf(node.id); - - if (index === 0) return 0; - const prevNodeIds = siblings.slice(0, index); - // The index is distance from last block to the last non-numbered-list block - const lastIndex = prevNodeIds.reverse().findIndex((id) => { - return nodes[id].type !== BlockType.NumberedListBlock; - }); - - if (lastIndex === -1) return prevNodeIds.length; - return lastIndex; - }); - - return { - index: prevNumberedIndex + 1, - }; -} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/NumberedListBlock/index.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/document/NumberedListBlock/index.tsx deleted file mode 100644 index d3efb37196..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/NumberedListBlock/index.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import React from 'react'; -import { BlockType, NestedBlock } from '$app/interfaces/document'; -import TextBlock from '$app/components/document/TextBlock'; -import NodeChildren from '$app/components/document/Node/NodeChildren'; -import { useNumberedListBlock } from '$app/components/document/NumberedListBlock/NumberedListBlock.hooks'; - -function NumberedListBlock({ node, childIds }: { node: NestedBlock<BlockType.NumberedListBlock>; childIds?: string[] }) { - const { index } = useNumberedListBlock(node); - - return ( - <> - <div className={'flex'}> - <div - className={`relative flex h-[calc(1.5em_+_4px)] min-w-[1.5em] select-none items-center whitespace-nowrap px-1 text-left`} - > - {index}. - </div> - <div className={'flex-1'}> - <TextBlock node={node} /> - </div> - </div> - <NodeChildren className='pl-[1.5em]' childIds={childIds} /> - </> - ); -} - -export default NumberedListBlock; 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 deleted file mode 100644 index 3125e79662..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/Overlay/BlockOverlay.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import React from 'react'; -import BlockSideToolbar from '$app/components/document/BlockSideToolbar'; - -function BlockOverlay({ id }: { id: string }) { - return ( - <div className='block-overlay'> - <BlockSideToolbar id={id} /> - </div> - ); -} - -export default BlockOverlay; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/Overlay/index.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/document/Overlay/index.tsx deleted file mode 100644 index 32035418ef..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/Overlay/index.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import React from 'react'; -import BlockSelection from '../BlockSelection'; -import TextActionMenu from '$app/components/document/TextActionMenu'; -import BlockSlash from '$app/components/document/BlockSlash'; -import { useCopy } from '$app/components/document/_shared/CopyPasteHooks/useCopy'; -import { usePaste } from '$app/components/document/_shared/CopyPasteHooks/usePaste'; -import { useUndoRedo } from '$app/components/document/_shared/UndoHooks/useUndoRedo'; -import TemporaryPopover from '$app/components/document/_shared/TemporaryInput/TemporaryPopover'; -import MentionPopover from '$app/components/document/Mention/MentionPopover'; - -export default function Overlay({ container }: { container: HTMLDivElement }) { - useCopy(container); - usePaste(container); - useUndoRedo(container); - return ( - <> - <TextActionMenu container={container} /> - <BlockSelection container={container} /> - <BlockSlash container={container} /> - <TemporaryPopover /> - <MentionPopover /> - </> - ); -} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/QuoteBlock/index.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/document/QuoteBlock/index.tsx deleted file mode 100644 index 669e611d6b..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/QuoteBlock/index.tsx +++ /dev/null @@ -1,20 +0,0 @@ -import { BlockType, NestedBlock } from '$app/interfaces/document'; -import TextBlock from '$app/components/document/TextBlock'; -import NodeChildren from '$app/components/document/Node/NodeChildren'; - -export default function QuoteBlock({ - node, - childIds, -}: { - node: NestedBlock<BlockType.QuoteBlock>; - childIds?: string[]; -}) { - return ( - <div className={'py-[2px] pl-0.5'}> - <div className={'border-l-4 border-solid border-fill-default pl-3'}> - <TextBlock node={node} /> - <NodeChildren childIds={childIds} /> - </div> - </div> - ); -} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/Root/Root.hooks.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/document/Root/Root.hooks.tsx deleted file mode 100644 index 6d12480b1f..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/Root/Root.hooks.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import { DocumentData } from '$app/interfaces/document'; -import { useSubscribeNode } from '../_shared/SubscribeNode.hooks'; - -export function useRoot({ documentData }: { documentData: DocumentData }) { - const { rootId } = documentData; - - const { node: rootNode, childIds: rootChildIds } = useSubscribeNode(rootId); - - return { - node: rootNode, - childIds: rootChildIds, - }; -} 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 deleted file mode 100644 index fce317a738..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/Root/index.tsx +++ /dev/null @@ -1,44 +0,0 @@ -import { DocumentData } from '@/appflowy_app/interfaces/document'; -import React, { useCallback } from 'react'; -import { useRoot } from './Root.hooks'; -import Node from '../Node'; -import { withErrorBoundary } from 'react-error-boundary'; -import { ErrorBoundaryFallbackComponent } from '../_shared/ErrorBoundaryFallbackComponent'; -import VirtualizedList from '../VirtualizedList'; -import { Skeleton } from '@mui/material'; -import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks'; - -function Root({ - documentData, - getDocumentTitle, -}: { - documentData: DocumentData; - getDocumentTitle?: () => React.ReactNode; -}) { - const { node, childIds } = useRoot({ documentData }); - const { docId } = useSubscribeDocument(); - const renderNode = useCallback((nodeId: string) => { - return <Node key={nodeId} id={nodeId} />; - }, []); - - if (!node || !childIds) { - return <Skeleton />; - } - - return ( - <> - <div - id={`appflowy-block-doc-${docId}`} - className='h-[100%] overflow-hidden text-base text-text-title caret-text-title' - > - <VirtualizedList getDocumentTitle={getDocumentTitle} node={node} childIds={childIds} renderNode={renderNode} /> - </div> - </> - ); -} - -const RootWithErrorBoundary = withErrorBoundary(React.memo(Root), { - FallbackComponent: ErrorBoundaryFallbackComponent, -}); - -export default RootWithErrorBoundary; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/TextActionMenu/config.ts b/frontend/appflowy_tauri/src/appflowy_app/components/document/TextActionMenu/config.ts deleted file mode 100644 index 14a291294c..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/TextActionMenu/config.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { TextAction, TextActionMenuProps } from '$app/interfaces/document'; - -export const defaultTextActionItems = [ - TextAction.Turn, - TextAction.Link, - TextAction.Bold, - TextAction.Italic, - TextAction.Underline, - TextAction.Strikethrough, - TextAction.Code, - TextAction.Equation, - TextAction.TextColor, - TextAction.Highlight, -]; -const groupKeys = { - comment: [], - format: [ - TextAction.Bold, - TextAction.Italic, - TextAction.Underline, - TextAction.Strikethrough, - TextAction.Code, - TextAction.Equation, - ], - link: [TextAction.Link], - color: [TextAction.TextColor, TextAction.Highlight], - turn: [TextAction.Turn], -}; - -export const multiLineTextActionProps: TextActionMenuProps = { - customItems: [ - TextAction.Bold, - TextAction.Italic, - TextAction.Underline, - TextAction.Strikethrough, - TextAction.Code, - TextAction.TextColor, - TextAction.Highlight, - ], -}; -export const multiLineTextActionGroups = [groupKeys.format, groupKeys.color]; -export const textActionGroups = [groupKeys.turn, groupKeys.format, groupKeys.color, groupKeys.link]; 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 deleted file mode 100644 index f432643d35..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/TextActionMenu/index.hooks.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { calcToolbarPosition } from '$app/utils/document/toolbar'; -import { getNode } from '$app/utils/document/node'; -import { debounce } from '$app/utils/tool'; -import { useSubscribeCaret } from '$app/components/document/_shared/SubscribeSelection.hooks'; - -export function useMenuStyle(container: HTMLDivElement) { - const ref = useRef<HTMLDivElement | null>(null); - - const caret = useSubscribeCaret(); - const id = caret?.id; - - const [isScrolling, setIsScrolling] = useState(false); - - const reCalculatePosition = useCallback(() => { - const el = ref.current; - - if (!el || !id) return; - - const node = getNode(id); - - if (!node) return; - const position = calcToolbarPosition(el, node, container); - - if (!position) { - el.style.opacity = '0'; - el.style.pointerEvents = 'none'; - } else { - el.style.opacity = '1'; - el.style.pointerEvents = 'auto'; - 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 = () => { - setIsScrolling(true); - debounceScrollEnd(); - }; - - container.addEventListener('scroll', handleScroll); - return () => { - debounceScrollEnd.cancel(); - container.removeEventListener('scroll', handleScroll); - }; - }, [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 deleted file mode 100644 index 2491932409..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/TextActionMenu/index.tsx +++ /dev/null @@ -1,88 +0,0 @@ -import { useMenuStyle } from './index.hooks'; -import TextActionMenuList from '$app/components/document/TextActionMenu/menu'; -import BlockPortal from '$app/components/document/BlockPortal'; -import { useEffect, useMemo, useState } from 'react'; -import { useSubscribeRanges } from '$app/components/document/_shared/SubscribeSelection.hooks'; -import { debounce } from '$app/utils/tool'; -import { getBlock } from '$app/components/document/_shared/SubscribeNode.hooks'; -import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks'; - -const TextActionComponent = ({ container }: { container: HTMLDivElement }) => { - const { ref, id } = useMenuStyle(container); - - if (!id) return null; - return ( - <BlockPortal blockId={id}> - <div - ref={ref} - style={{ - opacity: 0, - }} - className='absolute mt-[-6px] inline-flex h-[32px] min-w-[100px] items-stretch overflow-hidden rounded-[8px] bg-fill-toolbar leading-tight text-content-on-fill shadow-md' - onMouseDown={(e) => { - // prevent toolbar from taking focus away from editor - e.preventDefault(); - e.stopPropagation(); - }} - > - <TextActionMenuList /> - </div> - </BlockPortal> - ); -}; - -const TextActionMenu = ({ container }: { container: HTMLDivElement }) => { - const range = useSubscribeRanges(); - const { docId } = useSubscribeDocument(); - const [show, setShow] = useState(false); - - const debounceShow = useMemo(() => { - return debounce(() => { - setShow(true); - }, 100); - }, []); - - const canShow = useMemo(() => { - const { isDragging, focus, anchor, ranges, caret } = range; - - // don't show if dragging - if (isDragging) return false; - // don't show if no focus or anchor - if (!caret) return false; - if (!anchor || !focus) return false; - - const anchorNode = getBlock(docId, anchor.id); - const focusNode = getBlock(docId, focus.id); - - // include document title - if (!anchorNode.parent || !focusNode.parent) return false; - - const isSameLine = anchor.id === focus.id; - - // show toolbar if range has multiple nodes - if (!isSameLine) return true; - - const caretRange = ranges?.[caret.id]; - - if (!caretRange) return false; - - // show toolbar if range is not collapsed - return caretRange.length > 0; - }, [docId, range]); - - useEffect(() => { - if (!canShow) { - debounceShow.cancel(); - setShow(false); - return; - } - - debounceShow(); - }, [canShow, debounceShow]); - - if (!show) return null; - - return <TextActionComponent container={container} />; -}; - -export default TextActionMenu; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/TextActionMenu/menu/BgColorPicker.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/document/TextActionMenu/menu/BgColorPicker.tsx deleted file mode 100644 index a8526bdf21..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/TextActionMenu/menu/BgColorPicker.tsx +++ /dev/null @@ -1,101 +0,0 @@ -import React, { useCallback, useMemo } from 'react'; -import { useTranslation } from 'react-i18next'; -import ColorPicker from '$app/components/document/TextActionMenu/menu/ColorPicker'; -import { FormatColorFill, FormatColorText } from '@mui/icons-material'; -import { TextAction } from '$app/interfaces/document'; - -function BgColorPicker() { - const { t } = useTranslation(); - - const getColorIcon = useCallback((color: string) => { - return ( - <div - style={{ - backgroundColor: color, - }} - className={'rounded border border-line-divider p-0.5'} - > - <FormatColorText /> - </div> - ); - }, []); - const colors = useMemo( - () => [ - { - name: t('colors.default'), - key: 'default', - color: 'transparent', - }, - { - name: t('colors.custom'), - key: 'custom', - color: 'transparent', - }, - { - key: 'gray', - name: t('colors.gray'), - color: '#78909c', - }, - { - key: 'brown', - name: t('colors.brown'), - color: '#8d6e63', - }, - { - key: 'orange', - name: t('colors.orange'), - color: '#ff9100', - }, - { - key: 'yellow', - name: t('colors.yellow'), - color: '#ffd600', - }, - { - key: 'green', - name: t('colors.green'), - color: '#00e676', - }, - { - key: 'blue', - name: t('colors.blue'), - color: '#448aff', - }, - { - key: 'purple', - name: t('colors.purple'), - color: '#e040fb', - }, - { - key: 'pink', - name: t('colors.pink'), - color: '#ff4081', - }, - { - key: 'red', - name: t('colors.red'), - color: '#ff5252', - }, - ], - [t] - ); - - return ( - <ColorPicker - getColorIcon={getColorIcon} - icon={ - <FormatColorFill - sx={{ - width: 18, - height: 18, - }} - /> - } - colors={colors} - format={TextAction.Highlight} - label={t('toolbar.highlight')} - /> - ); -} - -export default BgColorPicker; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/TextActionMenu/menu/ColorPicker.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/document/TextActionMenu/menu/ColorPicker.tsx deleted file mode 100644 index 9427b98bc7..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/TextActionMenu/menu/ColorPicker.tsx +++ /dev/null @@ -1,205 +0,0 @@ -import React, { useCallback, useEffect, useRef, useState } from 'react'; -import { List } from '@mui/material'; -import MenuItem from '@mui/material/MenuItem'; -import { useBindArrowKey } from '$app/components/document/_shared/useBindArrowKey'; -import Popover from '@mui/material/Popover'; -import Tooltip from '@mui/material/Tooltip'; -import { useAppDispatch } from '$app/stores/store'; -import { formatThunk, getFormatValuesThunk } from '$app_reducers/document/async-actions/format'; -import { TextAction } from '$app/interfaces/document'; -import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks'; -import CustomColorPicker from '$app/components/document/TextActionMenu/menu/CustomColorPicker'; - -export interface ColorItem { - name: string; - key: string; - color: string; -} -function ColorPicker({ - label, - format, - colors, - icon, - getColorIcon, -}: { - format: TextAction; - label: string; - colors: ColorItem[]; - icon: React.ReactNode; - getColorIcon: (color: string) => React.ReactNode; -}) { - const { controller, docId } = useSubscribeDocument(); - const ref = useRef<HTMLDivElement>(null); - const [anchorPosition, setAnchorPosition] = useState< - | { - left: number; - top: number; - } - | undefined - >(undefined); - const open = Boolean(anchorPosition); - const dispatch = useAppDispatch(); - const [customPickerAnchorPosition, setCustomPickerAnchorPosition] = useState< - | { - left: number; - top: number; - } - | undefined - >(undefined); - const customOpened = Boolean(customPickerAnchorPosition); - const [selectOption, setSelectOption] = useState<string | null>(null); - const [activeColor, setActiveColor] = useState<string | null>(null); - - const openCustomColorPicker = useCallback(() => { - const target = document.querySelector('.color-item-custom') as Element; - - const rect = target.getBoundingClientRect(); - - setCustomPickerAnchorPosition({ - left: rect.left + rect.width + 10, - top: rect.top, - }); - }, []); - - useEffect(() => { - if (selectOption === 'custom') { - openCustomColorPicker(); - } else { - setCustomPickerAnchorPosition(undefined); - } - }, [selectOption, openCustomColorPicker]); - - const onOpen = useCallback(() => { - const rect = ref.current?.getBoundingClientRect(); - - if (!rect) return; - setAnchorPosition({ - left: rect.left, - top: rect.top + rect.height + 10, - }); - }, []); - - const loadActiveColor = useCallback(async () => { - const { payload: formatValues } = (await dispatch(getFormatValuesThunk({ format, docId }))) as { - payload: Record<string, (boolean | string | undefined)[]>; - }; - const multiLines = Object.keys(formatValues).length > 1; - const firstKey = Object.keys(formatValues)[0]; - const firstValue = formatValues[firstKey].find((item) => item); - - setActiveColor(multiLines ? null : String(firstValue)); - }, [dispatch, docId, format]); - - useEffect(() => { - void (async () => { - await loadActiveColor(); - })(); - }, [loadActiveColor]); - - const formatColor = useCallback( - async (color: string | null) => { - await dispatch(formatThunk({ format, value: color, controller })); - setAnchorPosition(undefined); - await loadActiveColor(); - }, - [format, controller, dispatch, loadActiveColor] - ); - - const onClick = useCallback(async () => { - if (selectOption === 'custom') { - return; - } - - if (selectOption === 'default') { - await formatColor(null); - } else { - const item = colors.find((color) => color.key === selectOption); - - await formatColor(item?.color || null); - } - }, [selectOption, formatColor, colors]); - - const { run, stop } = useBindArrowKey({ - options: colors.map((item) => item.key), - onChange: (key) => { - setSelectOption(key); - }, - selectOption, - onEnter: () => onClick(), - }); - - useEffect(() => { - if (open) { - run(); - } else { - stop(); - } - }, [open, run, stop]); - - return ( - <> - <div - ref={ref} - className={'cursor-pointer px-1.5 hover:text-fill-hover'} - onClick={onOpen} - style={{ - color: activeColor || undefined, - }} - > - <Tooltip placement={'top-start'} disableInteractive title={label}> - <div>{icon}</div> - </Tooltip> - </div> - <Popover - onMouseDown={(e) => { - e.stopPropagation(); - }} - disableAutoFocus={true} - disableRestoreFocus={true} - transformOrigin={{ - vertical: 'top', - horizontal: 'left', - }} - open={open} - anchorReference={'anchorPosition'} - anchorPosition={anchorPosition} - onClose={() => setAnchorPosition(undefined)} - > - <List> - <div className={'w-[200px] px-4 py-2 uppercase text-text-caption'}>{label}</div> - {colors.map((item) => ( - <MenuItem - className={`color-item-${item.key}`} - key={item.key} - onMouseEnter={() => { - setSelectOption(item.key); - }} - style={{ - padding: '4px', - }} - selected={selectOption === item.key} - onClick={onClick} - > - <div className={'flex items-center'}> - {getColorIcon(item.color)} - <div className={'ml-2'}>{item.name}</div> - </div> - {item.key === 'custom' && ( - <CustomColorPicker - open={customOpened} - onChange={formatColor} - anchorPosition={customPickerAnchorPosition} - onClose={() => { - setCustomPickerAnchorPosition(undefined); - }} - /> - )} - </MenuItem> - ))} - </List> - </Popover> - </> - ); -} - -export default ColorPicker; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/TextActionMenu/menu/CustomColorPicker.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/document/TextActionMenu/menu/CustomColorPicker.tsx deleted file mode 100644 index eee5f9c126..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/TextActionMenu/menu/CustomColorPicker.tsx +++ /dev/null @@ -1,69 +0,0 @@ -import React, { useState } from 'react'; -import Popover from '@mui/material/Popover'; -import { RGBColor, SketchPicker } from 'react-color'; -import Button from '@mui/material/Button'; -import { useTranslation } from 'react-i18next'; -import { Divider } from '@mui/material'; - -function CustomColorPicker({ - onChange, - open, - onClose, - anchorPosition, -}: { - open: boolean; - onChange: (color: string) => void; - anchorPosition?: { - left: number; - top: number; - }; - onClose: () => void; -}) { - const { t } = useTranslation(); - const [color, setColor] = useState<RGBColor | undefined>(); - - return ( - <Popover - onMouseDown={(e) => e.stopPropagation()} - disableAutoFocus={true} - disableRestoreFocus={true} - sx={{ - pointerEvents: 'none', - }} - PaperProps={{ - style: { - pointerEvents: 'auto', - }, - className: 'p-2', - }} - open={open} - transformOrigin={{ - vertical: 'top', - horizontal: 'left', - }} - anchorReference={'anchorPosition'} - anchorPosition={anchorPosition} - onClose={onClose} - > - <SketchPicker - onChange={(color) => { - setColor(color.rgb); - }} - color={color} - /> - <Divider /> - <div className={'z-10 flex justify-end bg-bg-body px-2 pt-2'}> - <Button - onClick={() => { - onChange(`rgba(${color?.r}, ${color?.g}, ${color?.b}, ${color?.a})`); - }} - variant={'contained'} - > - {t('button.done')} - </Button> - </div> - </Popover> - ); -} - -export default CustomColorPicker; 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 deleted file mode 100644 index 9d8412e5e3..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/TextActionMenu/menu/FormatButton.tsx +++ /dev/null @@ -1,144 +0,0 @@ -import React, { useCallback, useEffect, useMemo } from 'react'; -import { TemporaryType, TextAction } from '$app/interfaces/document'; -import { getFormatActiveThunk, toggleFormatThunk } from '$app_reducers/document/async-actions/format'; -import { useAppDispatch, useAppSelector } from '$app/stores/store'; -import { useSubscribeNode } from '$app/components/document/_shared/SubscribeNode.hooks'; -import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks'; -import { RANGE_NAME } from '$app/constants/document/name'; -import { createTemporary } from '$app_reducers/document/async-actions/temporary'; -import { - CodeOutlined, - FormatBold, - FormatItalic, - FormatUnderlined, - Functions, - StrikethroughSOutlined, -} from '@mui/icons-material'; -import LinkIcon from '@mui/icons-material/AddLink'; -import { useTranslation } from 'react-i18next'; -import Tooltip from '@mui/material/Tooltip'; - -export const iconSize = { width: 18, height: 18 }; - -const FormatButton = ({ format, icon }: { format: TextAction; icon: string }) => { - const dispatch = useAppDispatch(); - const { docId, controller } = useSubscribeDocument(); - const { t } = useTranslation(); - const focusId = useAppSelector((state) => state[RANGE_NAME][docId]?.focus?.id || ''); - const { node: focusNode } = useSubscribeNode(focusId); - - const [isActive, setIsActive] = React.useState(false); - const color = useMemo(() => (isActive ? 'text-fill-hover' : ''), [isActive]); - - const isFormatActive = useCallback(async () => { - if (!focusNode) return false; - const { payload: isActive } = await dispatch( - getFormatActiveThunk({ - format, - docId, - }) - ); - - return !!isActive; - }, [docId, dispatch, format, focusNode]); - - const toggleFormat = useCallback( - async (format: TextAction) => { - if (!controller) return; - await dispatch( - toggleFormatThunk({ - format, - controller, - isActive, - }) - ); - const actived = await isFormatActive(); - - setIsActive(actived); - }, - [controller, dispatch, isActive, isFormatActive] - ); - - const addTemporaryInput = useCallback( - (type: TemporaryType) => { - void dispatch(createTemporary({ type, docId })); - }, - [dispatch, docId] - ); - - useEffect(() => { - void (async () => { - const isActive = await isFormatActive(); - - setIsActive(isActive); - })(); - }, [isFormatActive]); - - const formatTooltips: Record<string, string> = useMemo( - () => ({ - [TextAction.Bold]: t('toolbar.bold'), - [TextAction.Italic]: t('toolbar.italic'), - [TextAction.Underline]: t('toolbar.underline'), - [TextAction.Strikethrough]: t('toolbar.strike'), - [TextAction.Code]: t('toolbar.inlineCode'), - [TextAction.Link]: t('toolbar.addLink'), - [TextAction.Equation]: t('document.plugins.mathEquation.addMathEquation'), - }), - [t] - ); - - const formatClick = useCallback( - (format: TextAction) => { - switch (format) { - case TextAction.Bold: - case TextAction.Italic: - case TextAction.Underline: - case TextAction.Strikethrough: - case TextAction.Code: - return toggleFormat(format); - case TextAction.Link: - return addTemporaryInput(TemporaryType.Link); - case TextAction.Equation: - return addTemporaryInput(TemporaryType.Equation); - } - }, - [addTemporaryInput, toggleFormat] - ); - - const formatIcon = useMemo(() => { - switch (icon) { - case TextAction.Bold: - return <FormatBold sx={iconSize} />; - case TextAction.Underline: - return <FormatUnderlined sx={iconSize} />; - case TextAction.Italic: - return <FormatItalic sx={iconSize} />; - case TextAction.Code: - return <CodeOutlined sx={iconSize} />; - case TextAction.Strikethrough: - return <StrikethroughSOutlined sx={iconSize} />; - case TextAction.Link: - return ( - <LinkIcon - sx={{ - fontSize: '1.2rem', - }} - /> - ); - case TextAction.Equation: - return <Functions sx={iconSize} />; - default: - return null; - } - }, [icon]); - - return ( - <Tooltip disableInteractive placement={'top'} title={formatTooltips[format]}> - <div className={`${color} cursor-pointer px-1 hover:text-fill-default`} onClick={() => formatClick(format)}> - {formatIcon} - </div> - </Tooltip> - ); -}; - -export default FormatButton; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/TextActionMenu/menu/TextColorPicker.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/document/TextActionMenu/menu/TextColorPicker.tsx deleted file mode 100644 index c88664327d..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/TextActionMenu/menu/TextColorPicker.tsx +++ /dev/null @@ -1,97 +0,0 @@ -import React, { useCallback, useMemo } from 'react'; -import { useTranslation } from 'react-i18next'; -import { TextAction } from '$app/interfaces/document'; -import ColorPicker from '$app/components/document/TextActionMenu/menu/ColorPicker'; -import { FormatColorText } from '@mui/icons-material'; - -function TextColorPicker() { - const { t } = useTranslation(); - - const getColorIcon = useCallback((color: string) => { - return ( - <div className={'rounded border border-line-divider p-0.5'}> - <FormatColorText style={{ color }} /> - </div> - ); - }, []); - - const colors = useMemo( - () => [ - { - name: t('colors.default'), - key: 'default', - color: 'var(--text-title)', - }, - { - name: t('colors.custom'), - key: 'custom', - color: 'var(--text-title)', - }, - { - key: 'gray', - name: t('colors.gray'), - color: '#546e7a', - }, - { - key: 'brown', - name: t('colors.brown'), - color: '#795548', - }, - { - key: 'orange', - name: t('colors.orange'), - color: '#ff5722', - }, - { - key: 'yellow', - name: t('colors.yellow'), - color: '#ffff00', - }, - { - key: 'green', - name: t('colors.green'), - color: '#4caf50', - }, - { - key: 'blue', - name: t('colors.blue'), - color: '#0d47a1', - }, - { - key: 'purple', - name: t('colors.purple'), - color: '#9c27b0', - }, - { - key: 'pink', - name: t('colors.pink'), - color: '#d81b60', - }, - { - key: 'red', - name: t('colors.red'), - color: '#b71c1c', - }, - ], - [t] - ); - - return ( - <ColorPicker - icon={ - <FormatColorText - sx={{ - width: 18, - height: 18, - }} - /> - } - getColorIcon={getColorIcon} - colors={colors} - format={TextAction.TextColor} - label={t('toolbar.color')} - /> - ); -} - -export default TextColorPicker; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/TextActionMenu/menu/TurnIntoSelect.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/document/TextActionMenu/menu/TurnIntoSelect.tsx deleted file mode 100644 index cdbf752e54..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/TextActionMenu/menu/TurnIntoSelect.tsx +++ /dev/null @@ -1,54 +0,0 @@ -import React, { useCallback } from 'react'; -import TurnIntoPopover from '$app/components/document/_shared/TurnInto'; -import ArrowDropDown from '@mui/icons-material/ArrowDropDown'; -import { useSubscribeNode } from '$app/components/document/_shared/SubscribeNode.hooks'; -import { useTranslation } from 'react-i18next'; -import Tooltip from '@mui/material/Tooltip'; - -function TurnIntoSelect({ id }: { id: string }) { - const [anchorPosition, setAnchorPosition] = React.useState<{ - top: number; - left: number; - }>(); - - const { node } = useSubscribeNode(id); - const handleClick = useCallback((event: React.MouseEvent<HTMLDivElement>) => { - const rect = event.currentTarget.getBoundingClientRect(); - - setAnchorPosition({ - top: rect.top + rect.height + 5, - left: rect.left, - }); - }, []); - - const handleClose = useCallback(() => { - setAnchorPosition(undefined); - }, []); - - const open = Boolean(anchorPosition); - const { t } = useTranslation(); - - return ( - <> - <Tooltip disableInteractive placement={'top'} title={t('document.plugins.optionAction.turnInto')}> - <div onClick={handleClick} className='flex cursor-pointer items-center px-2 text-sm text-fill-default'> - <span>{node.type}</span> - <ArrowDropDown /> - </div> - </Tooltip> - <TurnIntoPopover - id={id} - open={open} - onClose={handleClose} - anchorReference={'anchorPosition'} - anchorPosition={anchorPosition} - transformOrigin={{ - vertical: 'top', - horizontal: 'left', - }} - /> - </> - ); -} - -export default TurnIntoSelect; 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 deleted file mode 100644 index 315ccd3337..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/TextActionMenu/menu/index.hooks.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { useMemo } from 'react'; -import { useSubscribeNode } from '$app/components/document/_shared/SubscribeNode.hooks'; -import { BlockType, TextAction } from '$app/interfaces/document'; -import { useSubscribeRanges } from '$app/components/document/_shared/SubscribeSelection.hooks'; -import { - defaultTextActionItems, - multiLineTextActionGroups, - multiLineTextActionProps, - textActionGroups, -} from '$app/components/document/TextActionMenu/config'; - -export function useTextActionMenu() { - const range = useSubscribeRanges(); - const isSingleLine = useMemo(() => { - return range.focus?.id === range.anchor?.id; - }, [range]); - const focusId = range.caret?.id; - - const { node } = useSubscribeNode(focusId || ''); - - const items = useMemo(() => { - if (!node) return []; - if (isSingleLine) { - const excludeItems = node.type === BlockType.CodeBlock ? [TextAction.Code] : []; - - return defaultTextActionItems?.filter((item) => !excludeItems?.includes(item)) || []; - } else { - return multiLineTextActionProps.customItems || []; - } - }, [isSingleLine, node]); - - // the groups have default items, so we need to filter the items if this node has excluded items - const groupItems: TextAction[][] = useMemo(() => { - const groups = node ? textActionGroups : multiLineTextActionGroups; - - return groups.map((group) => { - return group.filter((item) => items.includes(item)); - }); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [JSON.stringify(items), node]); - - return { - groupItems, - 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 deleted file mode 100644 index 8852ea13e8..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/TextActionMenu/menu/index.tsx +++ /dev/null @@ -1,53 +0,0 @@ -import { TextAction } from '$app/interfaces/document'; -import React, { useCallback } from 'react'; -import TurnIntoSelect from '$app/components/document/TextActionMenu/menu/TurnIntoSelect'; -import FormatButton from '$app/components/document/TextActionMenu/menu/FormatButton'; -import { useTextActionMenu } from '$app/components/document/TextActionMenu/menu/index.hooks'; -import TextColorPicker from '$app/components/document/TextActionMenu/menu/TextColorPicker'; -import BgColorPicker from '$app/components/document/TextActionMenu/menu/BgColorPicker'; - -function TextActionMenuList() { - const { groupItems, isSingleLine, focusId } = useTextActionMenu(); - const renderNode = useCallback( - (action: TextAction) => { - switch (action) { - case TextAction.Turn: - return isSingleLine && focusId ? <TurnIntoSelect id={focusId} /> : null; - case TextAction.Link: - case TextAction.Bold: - case TextAction.Italic: - case TextAction.Underline: - case TextAction.Strikethrough: - case TextAction.Code: - case TextAction.Equation: - return <FormatButton format={action} icon={action} />; - case TextAction.TextColor: - return <TextColorPicker />; - case TextAction.Highlight: - return <BgColorPicker />; - default: - return null; - } - }, - [isSingleLine, focusId] - ); - - return ( - <div className={'flex px-1'}> - {groupItems.map( - (group, i: number) => - group.length > 0 && ( - <div className={'flex border-r border-solid border-line-on-toolbar px-1 last:border-r-0'} key={i}> - {group.map((item) => ( - <div key={item} className={'flex items-center'}> - {renderNode(item)} - </div> - ))} - </div> - ) - )} - </div> - ); -} - -export default TextActionMenuList; 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 deleted file mode 100644 index 2f5a57a78d..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/TextBlock/index.tsx +++ /dev/null @@ -1,35 +0,0 @@ -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'; -import { useTranslation } from 'react-i18next'; - -interface Props { - node: NestedBlock; - childIds?: string[]; - placeholder?: string; -} -function TextBlock({ node, childIds, placeholder }: Props) { - const { value, onChange } = useChange(node); - const selectionProps = useSelection(node.id); - const { onKeyDown } = useKeyDown(node.id); - const { t } = useTranslation(); - - return ( - <> - <Editor - value={value} - onChange={onChange} - {...selectionProps} - onKeyDown={onKeyDown} - placeholder={placeholder || t('document.textBlock.placeholder')} - /> - <NodeChildren className='pl-[1.5em]' childIds={childIds} /> - </> - ); -} - -export default React.memo(TextBlock); diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/TextBlock/shortchut.ts b/frontend/appflowy_tauri/src/appflowy_app/components/document/TextBlock/shortchut.ts deleted file mode 100644 index d2ad58565d..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/TextBlock/shortchut.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { Keyboard } from '$app/constants/document/keyboard'; -import { BlockType } from '$app/interfaces/document'; - -// eslint-disable-next-line @typescript-eslint/ban-ts-comment -// @ts-ignore -export const turnIntoConfig: Record< - BlockType, - { - type: BlockType; - markdownRegexp: RegExp; - triggerKey: string; - } -> = { - [BlockType.HeadingBlock]: { - type: BlockType.HeadingBlock, - markdownRegexp: /^(#{1,3})(\s)+$/, - triggerKey: Keyboard.keys.SPACE, - }, - [BlockType.TodoListBlock]: { - type: BlockType.TodoListBlock, - markdownRegexp: /^((-)?\[(x|\s)?\])(\s)+$/, - triggerKey: Keyboard.keys.SPACE, - }, - [BlockType.BulletedListBlock]: { - type: BlockType.BulletedListBlock, - markdownRegexp: /^(\s*[-+*])(\s)+$/, - triggerKey: Keyboard.keys.SPACE, - }, - [BlockType.NumberedListBlock]: { - type: BlockType.NumberedListBlock, - markdownRegexp: /^(\s*[\d|a-zA-Z]+\.)(\s)+$/, - triggerKey: Keyboard.keys.SPACE, - }, - [BlockType.QuoteBlock]: { - type: BlockType.QuoteBlock, - markdownRegexp: /^("|“|”)(\s)+$/, - triggerKey: Keyboard.keys.SPACE, - }, - [BlockType.ToggleListBlock]: { - type: BlockType.ToggleListBlock, - markdownRegexp: /^(>)(\s)+$/, - triggerKey: Keyboard.keys.SPACE, - }, - [BlockType.CalloutBlock]: { - type: BlockType.CalloutBlock, - markdownRegexp: /^(\[!)(TIP|INFO|WARNING|DANGER)(\])(\s)+$/, - triggerKey: Keyboard.keys.SPACE, - }, - [BlockType.EquationBlock]: { - type: BlockType.EquationBlock, - markdownRegexp: /^(\${2})(\s)*(.+)(\s)*(\${2})$/, - triggerKey: Keyboard.keys.DOLLAR, - }, - [BlockType.DividerBlock]: { - type: BlockType.DividerBlock, - markdownRegexp: /^(-{3,})$/, - triggerKey: Keyboard.keys.REDUCE, - }, - [BlockType.CodeBlock]: { - type: BlockType.CodeBlock, - markdownRegexp: /^(```)$/, - triggerKey: Keyboard.keys.BACK_QUOTE, - }, -}; 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 deleted file mode 100644 index 0c49ef54e4..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/TextBlock/useKeyDown.ts +++ /dev/null @@ -1,104 +0,0 @@ -import { useCallback, useMemo } from 'react'; -import { Keyboard } from '$app/constants/document/keyboard'; -import isHotkey from 'is-hotkey'; -import { useAppDispatch } from '@/appflowy_app/stores/store'; -import { - enterActionForBlockThunk, - tabActionForBlockThunk, - shiftTabActionForBlockThunk, -} from '$app_reducers/document/async-actions'; -import { useTurnIntoBlockEvents } from './useTurnIntoBlockEvents'; -import { useCommonKeyEvents } from '../_shared/EditorHooks/useCommonKeyEvents'; -import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks'; - -export function useKeyDown(id: string) { - const { controller } = useSubscribeDocument(); - const dispatch = useAppDispatch(); - const turnIntoEvents = useTurnIntoBlockEvents(id); - const commonKeyEvents = useCommonKeyEvents(id); - const interceptEvents = useMemo(() => { - return [ - ...commonKeyEvents, - { - // Prevent all enter key unless it be rewritten - canHandle: (e: React.KeyboardEvent<HTMLDivElement>) => { - return e.key === Keyboard.keys.ENTER; - }, - handler: (e: React.KeyboardEvent<HTMLDivElement>) => { - e.preventDefault(); - }, - }, - { - // rewrite only enter key and no other key is pressed - canHandle: (e: React.KeyboardEvent<HTMLDivElement>) => { - return isHotkey(Keyboard.keys.ENTER, e); - }, - handler: () => { - if (!controller) return; - void dispatch( - enterActionForBlockThunk({ - id, - controller, - }) - ); - }, - }, - { - // Prevent all tab key unless it be rewritten - canHandle: (e: React.KeyboardEvent<HTMLDivElement>) => { - return e.key === Keyboard.keys.TAB; - }, - handler: (e: React.KeyboardEvent<HTMLDivElement>) => { - e.preventDefault(); - }, - }, - { - // rewrite only tab key and no other key is pressed - canHandle: (e: React.KeyboardEvent<HTMLDivElement>) => { - return isHotkey(Keyboard.keys.TAB, e); - }, - handler: () => { - if (!controller) return; - void dispatch( - tabActionForBlockThunk({ - id, - controller, - }) - ); - }, - }, - { - // rewrite only shift+tab key and no other key is pressed - canHandle: (e: React.KeyboardEvent<HTMLDivElement>) => { - return isHotkey(Keyboard.keys.SHIFT_TAB, e); - }, - handler: () => { - if (!controller) return; - void dispatch( - shiftTabActionForBlockThunk({ - id, - controller, - }) - ); - }, - }, - ...turnIntoEvents, - ]; - }, [commonKeyEvents, controller, dispatch, id, turnIntoEvents]); - - const onKeyDown = useCallback( - (e: React.KeyboardEvent<HTMLDivElement>) => { - const filteredEvents = interceptEvents.filter((event) => event.canHandle(e)); - - filteredEvents.forEach((event) => { - e.stopPropagation(); - 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 deleted file mode 100644 index d06f17b1b0..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/TextBlock/useTurnIntoBlockEvents.ts +++ /dev/null @@ -1,218 +0,0 @@ -import { useCallback, useMemo } from 'react'; -import { BlockType } from '$app/interfaces/document'; -import { useAppDispatch } from '$app/stores/store'; -import { turnToBlockThunk } from '$app_reducers/document/async-actions'; -import { blockConfig } from '$app/constants/document/config'; - -import Delta from 'quill-delta'; -import { useRangeRef } from '$app/components/document/_shared/SubscribeSelection.hooks'; -import { getBlockDelta } from '$app/components/document/_shared/SubscribeNode.hooks'; -import { getDeltaText } from '$app/utils/document/delta'; -import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks'; -import { turnIntoConfig } from './shortchut'; - -export function useTurnIntoBlockEvents(id: string) { - const { docId, controller } = useSubscribeDocument(); - - const dispatch = useAppDispatch(); - const rangeRef = useRangeRef(); - - const getFlag = useCallback(() => { - const range = rangeRef.current?.caret; - - if (!range || range.id !== id) return; - - const delta = getBlockDelta(docId, id); - - if (!delta) return ''; - return getDeltaText(delta.slice(0, range.index)); - }, [docId, id, rangeRef]); - - const getDeltaContent = useCallback(() => { - const range = rangeRef.current?.caret; - - if (!range || range.id !== id) return; - const delta = getBlockDelta(docId, id); - - if (!delta) return ''; - const content = delta.slice(range.index); - - return new Delta(content); - }, [docId, id, rangeRef]); - - const canHandle = useCallback( - (event: React.KeyboardEvent<HTMLDivElement>, type: BlockType) => { - { - const triggerKey = event.key === turnIntoConfig[type].triggerKey ? event.key : undefined; - - if (!triggerKey) return false; - - const regex = turnIntoConfig[type].markdownRegexp; - - // 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.test(`${flag}${triggerKey}`); - } - }, - [getFlag] - ); - - const getTurnIntoBlockDelta = useCallback(() => { - const content = getDeltaContent(); - - if (!content) return; - return { - delta: content.ops, - }; - }, [getDeltaContent]); - - const getAttrs = useCallback( - (type: BlockType) => { - const flag = getFlag(); - - if (!flag) return; - const triggerKey = turnIntoConfig[type].triggerKey; - const regex = turnIntoConfig[type].markdownRegexp; - const match = `${flag}${triggerKey}`.match(regex); - - return match?.[3]; - }, - [getFlag] - ); - - const spaceTriggerMap = useMemo(() => { - return { - [BlockType.HeadingBlock]: () => { - const flag = getFlag(); - - if (!flag) return; - const level = flag.match(/#/g)?.length; - - if (!level || level > 3) return; - return { - level, - ...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<string, string> = { - 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; - - return { - canHandle: (e: React.KeyboardEvent<HTMLDivElement>) => canHandle(e, blockType), - handler: (e: React.KeyboardEvent<HTMLDivElement>) => { - e.preventDefault(); - if (!controller) return; - const data = getData(); - - if (!data) return; - void dispatch(turnToBlockThunk({ id, data, type: blockType, controller })); - }, - }; - }); - - return [ - ...spaceTriggerEvents, - { - canHandle: (e: React.KeyboardEvent<HTMLDivElement>) => canHandle(e, BlockType.DividerBlock), - handler: (e: React.KeyboardEvent<HTMLDivElement>) => { - e.preventDefault(); - if (!controller) return; - - void dispatch( - turnToBlockThunk({ - id, - controller, - type: BlockType.DividerBlock, - data: {}, - }) - ); - }, - }, - { - canHandle: (e: React.KeyboardEvent<HTMLDivElement>) => canHandle(e, BlockType.CodeBlock), - handler: (e: React.KeyboardEvent<HTMLDivElement>) => { - e.preventDefault(); - if (!controller) return; - const defaultData = blockConfig[BlockType.CodeBlock].defaultData; - - void dispatch( - turnToBlockThunk({ - id, - data: { - ...defaultData, - }, - type: BlockType.CodeBlock, - controller, - }) - ); - }, - }, - { - canHandle: (e: React.KeyboardEvent<HTMLDivElement>) => canHandle(e, BlockType.EquationBlock), - handler: (e: React.KeyboardEvent<HTMLDivElement>) => { - e.preventDefault(); - const formula = getAttrs(BlockType.EquationBlock); - - const data = { - formula, - }; - - void dispatch(turnToBlockThunk({ id, data, type: BlockType.EquationBlock, controller })); - }, - }, - ]; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [canHandle, controller, dispatch, getAttrs, getDeltaContent, 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 deleted file mode 100644 index 090420b848..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/TodoListBlock/TodoListBlock.hooks.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { useAppDispatch } from '$app/stores/store'; -import { useCallback } from 'react'; -import { updateNodeDataThunk } from '$app_reducers/document/async-actions/blocks/update'; -import { BlockData, BlockType } from '$app/interfaces/document'; -import isHotkey from 'is-hotkey'; -import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks'; - -export function useTodoListBlock(id: string, data: BlockData<BlockType.TodoListBlock>) { - const dispatch = useAppDispatch(); - const { controller } = useSubscribeDocument(); - const toggleCheckbox = useCallback(() => { - if (!controller) return; - void dispatch( - updateNodeDataThunk({ - id, - controller, - data: { - checked: !data.checked, - }, - }) - ); - }, [controller, dispatch, id, data.checked]); - - const handleShortcut = useCallback( - (event: React.KeyboardEvent<HTMLDivElement>) => { - // Accepts mod for the classic "cmd on Mac, ctrl on Windows" use case. - if (isHotkey('mod+enter', event)) { - toggleCheckbox(); - } - }, - [toggleCheckbox] - ); - - return { - toggleCheckbox, - handleShortcut, - }; -} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/TodoListBlock/index.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/document/TodoListBlock/index.tsx deleted file mode 100644 index d6612168a9..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/TodoListBlock/index.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import { BlockType, NestedBlock } from '$app/interfaces/document'; -import TextBlock from '$app/components/document/TextBlock'; -import { useTodoListBlock } from '$app/components/document/TodoListBlock/TodoListBlock.hooks'; -import { EditorCheckSvg } from '$app/components/_shared/svg/EditorCheckSvg'; -import { EditorUncheckSvg } from '$app/components/_shared/svg/EditorUncheckSvg'; -import React from 'react'; -import NodeChildren from '$app/components/document/Node/NodeChildren'; - -export default function TodoListBlock({ - node, - childIds, -}: { - node: NestedBlock<BlockType.TodoListBlock>; - childIds?: string[]; -}) { - const { id, data } = node; - const { toggleCheckbox, handleShortcut } = useTodoListBlock(id, node.data); - - const checked = !!data.checked; - - return ( - <> - <div className={'flex'} onKeyDownCapture={handleShortcut}> - <div className={'flex h-[calc(1.5em_+_2px)] w-[1.5em] select-none items-center justify-start px-1'}> - <div className={'relative flex h-4 w-4 items-center justify-start transition'}> - <div>{checked ? <EditorCheckSvg /> : <EditorUncheckSvg />}</div> - <input - type={'checkbox'} - checked={checked} - onChange={toggleCheckbox} - className={'absolute h-[100%] w-[100%] cursor-pointer opacity-0'} - /> - </div> - </div> - <div className={`flex-1 ${checked ? 'text-text-caption line-through' : ''}`}> - <TextBlock node={node} /> - </div> - </div> - <NodeChildren className='pl-[1.5em]' childIds={childIds} /> - </> - ); -} 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 deleted file mode 100644 index b70c2a1f50..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/ToggleListBlock/ToggleListBlock.hooks.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { useAppDispatch } from '$app/stores/store'; -import { useCallback } from 'react'; -import { updateNodeDataThunk } from '$app_reducers/document/async-actions/blocks/update'; -import { BlockData, BlockType } from '$app/interfaces/document'; -import isHotkey from 'is-hotkey'; -import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks'; - -export function useToggleListBlock(id: string, data: BlockData<BlockType.ToggleListBlock>) { - const dispatch = useAppDispatch(); - const { controller } = useSubscribeDocument(); - const toggleCollapsed = useCallback(() => { - if (!controller) return; - void dispatch( - updateNodeDataThunk({ - id, - controller, - data: { - collapsed: !data.collapsed, - }, - }) - ); - }, [controller, dispatch, id, data.collapsed]); - - const handleShortcut = useCallback( - (event: React.KeyboardEvent<HTMLDivElement>) => { - // Accepts mod for the classic "cmd on Mac, ctrl on Windows" use case. - if (isHotkey('mod+enter', event)) { - toggleCollapsed(); - } - }, - [toggleCollapsed] - ); - - return { - toggleCollapsed, - handleShortcut, - }; -} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/ToggleListBlock/index.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/document/ToggleListBlock/index.tsx deleted file mode 100644 index 54387fe922..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/ToggleListBlock/index.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import React from 'react'; -import { BlockType, NestedBlock } from '$app/interfaces/document'; -import TextBlock from '$app/components/document/TextBlock'; -import NodeChildren from '$app/components/document/Node/NodeChildren'; -import { useToggleListBlock } from '$app/components/document/ToggleListBlock/ToggleListBlock.hooks'; -import { DropDownShowSvg } from '$app/components/_shared/svg/DropDownShowSvg'; -import Button from '@mui/material/Button'; - -function ToggleListBlock({ node, childIds }: { node: NestedBlock<BlockType.ToggleListBlock>; childIds?: string[] }) { - const { toggleCollapsed, handleShortcut } = useToggleListBlock(node.id, node.data); - const collapsed = node.data.collapsed; - - return ( - <> - <div className={'flex'} onKeyDownCapture={handleShortcut}> - <div className={`relative h-[calc(1.5em_+_2px)] w-[1.5em] select-none overflow-hidden px-1`}> - <Button - variant={'text'} - color={'inherit'} - size={'small'} - onClick={toggleCollapsed} - style={{ - minWidth: '20px', - padding: 0, - }} - className={`transition-transform duration-500 ${collapsed && 'rotate-[-90deg]'}`} - > - <DropDownShowSvg /> - </Button> - </div> - - <div className={'flex-1'}> - <TextBlock node={node} /> - </div> - </div> - {!collapsed && <NodeChildren className='pl-[1.5em]' childIds={childIds} />} - </> - ); -} - -export default ToggleListBlock; 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 deleted file mode 100644 index 25572d1322..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/VirtualizedList/VirtualizedList.hooks.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import { useVirtualizer } from '@tanstack/react-virtual'; -import { useRef } from 'react'; - -const defaultSize = 30; - -export function useVirtualizedList(count: number) { - const parentRef = useRef<HTMLDivElement>(null); - - const virtualize = useVirtualizer({ - count, - getScrollElement: () => parentRef.current, - overscan: 10, - estimateSize: () => { - return defaultSize; - }, - }); - - return { - virtualize, - parentRef, - }; -} 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 deleted file mode 100644 index 898f70a6ee..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/VirtualizedList/index.tsx +++ /dev/null @@ -1,83 +0,0 @@ -import { useVirtualizedList } from './VirtualizedList.hooks'; -import DocumentTitle from '../DocumentTitle'; -import Overlay from '../Overlay'; -import { Node } from '$app/interfaces/document'; - -import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks'; -import { ContainerType, useContainerType } from '$app/hooks/document.hooks'; -import { useCallback } from 'react'; - -export default function VirtualizedList({ - childIds, - node, - renderNode, - getDocumentTitle, -}: { - childIds: string[]; - node: Node; - renderNode: (nodeId: string) => JSX.Element; - getDocumentTitle?: () => React.ReactNode; -}) { - const { virtualize, parentRef } = useVirtualizedList(childIds.length + 1); - const virtualItems = virtualize.getVirtualItems(); - const { docId } = useSubscribeDocument(); - const containerType = useContainerType(); - - const isDocumentPage = containerType === ContainerType.DocumentPage; - - const renderDocumentTitle = useCallback(() => { - if (getDocumentTitle) { - return getDocumentTitle(); - } - - return <DocumentTitle id={node.id} />; - }, [getDocumentTitle, node.id]); - - return ( - <> - <div - ref={parentRef} - id={`appflowy-scroller_${docId}`} - className={`doc-scroller-container flex h-[100%] flex-wrap justify-center overflow-auto px-20`} - > - <div - className={`doc-body ${isDocumentPage ? 'max-w-screen w-[900px] min-w-0' : 'w-full'}`} - style={{ - height: virtualize.getTotalSize(), - position: 'relative', - }} - > - {node && childIds && virtualItems.length ? ( - <div - className={'doc-body-inner'} - style={{ - position: 'absolute', - top: 0, - left: 0, - width: '100%', - transform: `translateY(${virtualItems[0].start || 0}px)`, - }} - > - {virtualItems.map((virtualRow) => { - const isDocumentTitle = virtualRow.index === 0; - const id = isDocumentTitle ? node.id : childIds[virtualRow.index - 1]; - - return ( - <div - className={isDocumentTitle ? '' : 'pt-[0.5px]'} - key={id} - data-index={virtualRow.index} - ref={virtualize.measureElement} - > - {isDocumentTitle ? renderDocumentTitle() : renderNode(id)} - </div> - ); - })} - </div> - ) : null} - </div> - </div> - {parentRef.current ? <Overlay container={parentRef.current} /> : null} - </> - ); -} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/BlockPopover/BlockPopover.hooks.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/BlockPopover/BlockPopover.hooks.tsx deleted file mode 100644 index a86f5e8e9e..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/BlockPopover/BlockPopover.hooks.tsx +++ /dev/null @@ -1,114 +0,0 @@ -import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import Popover from '@mui/material/Popover'; -import { useEditingState } from '$app/components/document/_shared/SubscribeBlockEdit.hooks'; -import { useAppDispatch } from '$app/stores/store'; -import { blockEditActions } from '$app_reducers/document/block_edit_slice'; -import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks'; -import { setRectSelectionThunk } from '$app_reducers/document/async-actions/rect_selection'; - -export function useBlockPopover({ - renderContent, - onAfterClose, - onAfterOpen, - id, -}: { - id: string; - onAfterClose?: () => void; - onAfterOpen?: () => void; - renderContent: ({ onClose }: { onClose: () => void }) => React.ReactNode; -}) { - const anchorElRef = useRef<HTMLDivElement | null>(null); - const { docId } = useSubscribeDocument(); - - const [anchorPosition, setAnchorPosition] = useState<{ - top: number; - left: number; - }>(); - const open = Boolean(anchorPosition); - const editing = useEditingState(id); - const dispatch = useAppDispatch(); - const closePopover = useCallback(() => { - setAnchorPosition(undefined); - dispatch( - blockEditActions.setBlockEditState({ - id: docId, - state: { - id, - editing: false, - }, - }) - ); - onAfterClose?.(); - }, [dispatch, docId, id, onAfterClose]); - - const selectBlock = useCallback(() => { - void dispatch( - setRectSelectionThunk({ - docId, - selection: [id], - }) - ); - }, [dispatch, docId, id]); - - const openPopover = useCallback(() => { - if (!anchorElRef.current) return; - - const rect = anchorElRef.current.getBoundingClientRect(); - - setAnchorPosition({ - top: rect.top + rect.height, - left: rect.left + rect.width / 2, - }); - selectBlock(); - onAfterOpen?.(); - }, [onAfterOpen, selectBlock]); - - useEffect(() => { - if (editing) { - openPopover(); - } - }, [editing, openPopover]); - - const contextHolder = useMemo(() => { - return ( - <Popover - disableRestoreFocus={true} - disableAutoFocus={true} - transformOrigin={{ - vertical: 'top', - horizontal: 'center', - }} - onMouseDown={(e) => e.stopPropagation()} - onClose={closePopover} - open={open} - anchorReference={'anchorPosition'} - anchorPosition={anchorPosition} - > - {renderContent({ - onClose: closePopover, - })} - </Popover> - ); - }, [anchorPosition, closePopover, open, renderContent]); - - useEffect(() => { - if (!anchorElRef.current) { - return; - } - - const el = anchorElRef.current; - - el.addEventListener('click', selectBlock); - return () => { - el.removeEventListener('click', selectBlock); - }; - }, [selectBlock]); - - return { - contextHolder, - openPopover, - closePopover, - open, - anchorElRef, - }; -} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/CopyPasteHooks/useCopy.ts b/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/CopyPasteHooks/useCopy.ts deleted file mode 100644 index 0a34e4971c..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/CopyPasteHooks/useCopy.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { useCallback, useEffect } from 'react'; -import { copyThunk } from '$app_reducers/document/async-actions/copy_paste'; -import { useAppDispatch } from '$app/stores/store'; -import { BlockCopyData } from '$app/interfaces/document'; -import { clipboardTypes } from '$app/constants/document/copy_paste'; -import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks'; - -export function useCopy(container: HTMLDivElement) { - const dispatch = useAppDispatch(); - const { controller } = useSubscribeDocument(); - - const onCopy = useCallback( - (e: ClipboardEvent, isCut: boolean) => { - if (!controller) return; - e.stopPropagation(); - e.preventDefault(); - const setClipboardData = (data: BlockCopyData) => { - e.clipboardData?.setData(clipboardTypes.JSON, data.json); - e.clipboardData?.setData(clipboardTypes.TEXT, data.text); - e.clipboardData?.setData(clipboardTypes.HTML, data.html); - }; - - void dispatch( - copyThunk({ - setClipboardData, - controller, - isCut, - }) - ); - }, - [controller, dispatch] - ); - - const handleCopyCapture = useCallback( - (e: ClipboardEvent) => { - onCopy(e, false); - }, - [onCopy] - ); - - const handleCutCapture = useCallback( - (e: ClipboardEvent) => { - onCopy(e, true); - }, - [onCopy] - ); - - useEffect(() => { - container.addEventListener('copy', handleCopyCapture, true); - container.addEventListener('cut', handleCutCapture, true); - - return () => { - container.removeEventListener('copy', handleCopyCapture, true); - container.removeEventListener('cut', handleCutCapture, true); - }; - }, [container, handleCopyCapture, handleCutCapture]); -} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/CopyPasteHooks/usePaste.ts b/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/CopyPasteHooks/usePaste.ts deleted file mode 100644 index 6ed3c9fd6b..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/CopyPasteHooks/usePaste.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { useCallback, useEffect } from 'react'; -import { useAppDispatch } from '$app/stores/store'; -import { pasteThunk } from '$app_reducers/document/async-actions/copy_paste'; -import { clipboardTypes } from '$app/constants/document/copy_paste'; -import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks'; - -export function usePaste(container: HTMLDivElement) { - const dispatch = useAppDispatch(); - const { controller } = useSubscribeDocument(); - const handlePasteCapture = useCallback( - (e: ClipboardEvent) => { - if (!controller) return; - e.stopPropagation(); - e.preventDefault(); - void dispatch( - pasteThunk({ - controller, - data: { - json: e.clipboardData?.getData(clipboardTypes.JSON) || '', - text: e.clipboardData?.getData(clipboardTypes.TEXT) || '', - html: e.clipboardData?.getData(clipboardTypes.HTML) || '', - }, - }) - ); - }, - [controller, dispatch] - ); - - useEffect(() => { - container.addEventListener('paste', handlePasteCapture, true); - return () => { - container.removeEventListener('paste', handlePasteCapture, true); - }; - }, [container, handlePasteCapture]); -} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/DatabaseList/index.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/DatabaseList/index.tsx deleted file mode 100644 index 355ec2216b..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/DatabaseList/index.tsx +++ /dev/null @@ -1,103 +0,0 @@ -import React, { useMemo } from 'react'; -import { ViewLayoutPB } from '@/services/backend'; -import { useLoadDatabaseList } from '$app/components/document/_shared/DatabaseList/index.hooks'; -import { List } from '@mui/material'; -import { useTranslation } from 'react-i18next'; -import { BackupTableOutlined } from '@mui/icons-material'; -import MenuItem from '@mui/material/MenuItem'; -import { useAppDispatch } from '@/appflowy_app/stores/store'; -import { turnToBlockThunk } from '$app_reducers/document/async-actions'; -import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks'; -import { BlockType } from '$app/interfaces/document'; -import AddSvg from '$app/components/_shared/svg/AddSvg'; -import Button from '@mui/material/Button'; -import { PageController } from '$app/stores/effects/workspace/page/page_controller'; - -interface Props { - layout: ViewLayoutPB; - searchText?: string; - blockId: string; - onClose?: () => void; -} - -function DatabaseList({ layout, searchText, blockId, onClose }: Props) { - const { t } = useTranslation(); - const { docId } = useSubscribeDocument(); - const pageController = useMemo(() => new PageController(docId), [docId]); - const dispatch = useAppDispatch(); - const { controller } = useSubscribeDocument(); - const { list } = useLoadDatabaseList({ - searchText: searchText || '', - layout, - }); - - const renderEmpty = () => { - return <div className={'p-2 text-text-caption'}>No {layout === ViewLayoutPB.Grid ? 'grid' : 'list'} found</div>; - }; - - const handleReferenceDatabase = (viewId: string) => { - let blockType; - - switch (layout) { - case ViewLayoutPB.Grid: - blockType = BlockType.GridBlock; - break; - default: - break; - } - - if (blockType === undefined) return; - onClose?.(); - void dispatch( - turnToBlockThunk({ - id: blockId, - controller, - type: blockType, - data: { - viewId, - }, - }) - ); - }; - - const handleCreateNewGrid = async () => { - const newViewId = await pageController.createPage({ - layout, - name: t('editor.table'), - }); - - handleReferenceDatabase(newViewId); - }; - - return ( - <div className={'max-h-[360px] w-[200px] p-3'}> - <div className={'flex items-center justify-center'}> - <Button - color='inherit' - startIcon={ - <i className={'h-8 w-8'}> - <AddSvg /> - </i> - } - onClick={handleCreateNewGrid} - > - {t('document.slashMenu.grid.createANewGrid')} - </Button> - </div> - {list.length === 0 ? ( - renderEmpty() - ) : ( - <List> - {list.map((item) => ( - <MenuItem onClick={() => handleReferenceDatabase(item.id)} key={item.id}> - <div className={'mr-2'}>{item.icon?.value || <BackupTableOutlined />}</div> - {item.name || t('grid.title.placeholder')} - </MenuItem> - ))} - </List> - )} - </div> - ); -} - -export default DatabaseList; 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 deleted file mode 100644 index a530086234..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/EditorHooks/useChange.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { BlockType, NestedBlock } from '$app/interfaces/document'; -import { useCallback, useEffect, useState } from 'react'; -import Delta, { Op } from 'quill-delta'; -import { useDelta } from '$app/components/document/_shared/EditorHooks/useDelta'; - -export function useChange(node: NestedBlock<BlockType.TextBlock | BlockType.CodeBlock>) { - const { update, delta } = useDelta({ id: node.id }); - - const [value, setValue] = useState<Delta>(() => { - return delta; - }); - - useEffect(() => { - setValue(delta); - }, [delta]); - - const onChange = useCallback( - async (ops: Op[], newDelta: Delta) => { - if (ops.length === 0) return; - setValue(newDelta); - await update(ops, newDelta); - }, - [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 deleted file mode 100644 index e7da44baf8..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/EditorHooks/useCommonKeyEvents.ts +++ /dev/null @@ -1,100 +0,0 @@ -import isHotkey from 'is-hotkey'; -import { Keyboard } from '$app/constants/document/keyboard'; -import { - backspaceDeleteActionForBlockThunk, - leftActionForBlockThunk, - rightActionForBlockThunk, - upDownActionForBlockThunk, -} from '$app_reducers/document/async-actions'; -import { useMemo } from 'react'; -import { useFocused } from '$app/components/document/_shared/SubscribeSelection.hooks'; -import { useAppDispatch } from '$app/stores/store'; -import { isFormatHotkey, parseFormat } from '$app/utils/document/format'; -import { toggleFormatThunk } from '$app_reducers/document/async-actions/format'; -import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks'; - -export function useCommonKeyEvents(id: string) { - const { focused, caretRef } = useFocused(id); - const { docId, controller } = useSubscribeDocument(); - - 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<HTMLDivElement>) => { - return ( - (isHotkey(Keyboard.keys.BACKSPACE, e) || isHotkey(Keyboard.keys.DELETE, e)) && - focused && - caretRef.current?.index === 0 && - caretRef.current?.length === 0 - ); - }, - handler: (e: React.KeyboardEvent<HTMLDivElement>) => { - e.preventDefault(); - if (!controller) return; - void dispatch(backspaceDeleteActionForBlockThunk({ id, controller })); - }, - }, - { - // handle up arrow key and no other key is pressed - canHandle: (e: React.KeyboardEvent<HTMLDivElement>) => { - return isHotkey(Keyboard.keys.UP, e); - }, - handler: (e: React.KeyboardEvent<HTMLDivElement>) => { - e.preventDefault(); - void dispatch(upDownActionForBlockThunk({ docId, id })); - }, - }, - { - // handle down arrow key and no other key is pressed - canHandle: (e: React.KeyboardEvent<HTMLDivElement>) => { - return isHotkey(Keyboard.keys.DOWN, e); - }, - handler: (e: React.KeyboardEvent<HTMLDivElement>) => { - e.preventDefault(); - void dispatch(upDownActionForBlockThunk({ docId, id, down: true })); - }, - }, - { - // handle left arrow key and no other key is pressed - canHandle: (e: React.KeyboardEvent<HTMLDivElement>) => { - return isHotkey(Keyboard.keys.LEFT, e); - }, - handler: (e: React.KeyboardEvent<HTMLDivElement>) => { - e.preventDefault(); - e.stopPropagation(); - void dispatch(leftActionForBlockThunk({ docId, id })); - }, - }, - { - // handle right arrow key and no other key is pressed - canHandle: (e: React.KeyboardEvent<HTMLDivElement>) => { - return isHotkey(Keyboard.keys.RIGHT, e); - }, - handler: (e: React.KeyboardEvent<HTMLDivElement>) => { - e.preventDefault(); - void dispatch(rightActionForBlockThunk({ docId, id })); - }, - }, - { - // handle format shortcuts - canHandle: isFormatHotkey, - handler: (e: React.KeyboardEvent<HTMLDivElement>) => { - if (!controller) return; - const format = parseFormat(e); - - if (!format) return; - void dispatch( - toggleFormatThunk({ - format, - controller, - }) - ); - }, - }, - ]; - }, [docId, 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 deleted file mode 100644 index 9d690d9b0a..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/EditorHooks/useDelta.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { useSubscribeNode } from '$app/components/document/_shared/SubscribeNode.hooks'; -import { useCallback, useEffect, useMemo, useRef } from 'react'; -import { useAppDispatch } from '$app/stores/store'; -import { updateNodeDeltaThunk } from '$app_reducers/document/async-actions'; -import Delta, { Op } from 'quill-delta'; -import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks'; - -export function useDelta({ id, onDeltaChange }: { id: string; onDeltaChange?: (delta: Delta) => void }) { - const { controller } = useSubscribeDocument(); - const dispatch = useAppDispatch(); - const penddingRef = useRef(false); - const { delta: deltaStr } = useSubscribeNode(id); - - const delta = useMemo(() => { - if (!deltaStr) return new Delta(); - const deltaJson = JSON.parse(deltaStr); - - return new Delta(deltaJson); - }, [deltaStr]); - - useEffect(() => { - onDeltaChange?.(delta); - }, [delta, onDeltaChange]); - - const update = useCallback( - async (ops: Op[], newDelta: Delta) => { - if (!controller) return; - await dispatch( - updateNodeDeltaThunk({ - id, - ops, - newDelta, - 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 deleted file mode 100644 index 563241fec3..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/EditorHooks/useSelection.ts +++ /dev/null @@ -1,72 +0,0 @@ -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, - useSubscribeDecorate, -} from '$app/components/document/_shared/SubscribeSelection.hooks'; -import { storeRangeThunk } from '$app_reducers/document/async-actions/range'; -import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks'; - -export function useSelection(id: string) { - const rangeRef = useRangeRef(); - const { focusCaret } = useFocused(id); - const decorateProps = useSubscribeDecorate(id); - const [selection, setSelection] = useState<RangeStatic | undefined>(undefined); - const dispatch = useAppDispatch(); - const { docId } = useSubscribeDocument(); - - const storeRange = useCallback( - async (range: RangeStatic) => { - await dispatch(storeRangeThunk({ id, range, docId })); - }, - [docId, id, dispatch] - ); - - const onSelectionChange = useCallback( - (range: RangeStatic | null, _oldRange: RangeStatic | null, _source?: string) => { - if (!range) return; - dispatch( - rangeActions.setCaret({ - docId, - caret: { - id, - index: range.index, - length: range.length, - }, - }) - ); - void storeRange(range); - }, - [docId, id, dispatch, storeRange] - ); - - useEffect(() => { - if (rangeRef.current) { - const { isDragging, anchor, focus } = rangeRef.current; - const mouseDownFocused = anchor?.point.x === focus?.point.x && anchor?.point.y === focus?.point.y; - - if (isDragging && !mouseDownFocused) { - return; - } - } - - if (!focusCaret) { - setSelection(undefined); - return; - } - - setSelection({ - index: focusCaret.index, - length: focusCaret.length, - }); - }, [rangeRef, focusCaret]); - - return { - onSelectionChange, - selection, - ...decorateProps, - }; -} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/ErrorBoundaryFallbackComponent.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/ErrorBoundaryFallbackComponent.tsx deleted file mode 100644 index fc6851734c..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/ErrorBoundaryFallbackComponent.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import { Alert } from '@mui/material'; -import { FallbackProps } from 'react-error-boundary'; - -export function ErrorBoundaryFallbackComponent({ error, resetErrorBoundary }: FallbackProps) { - return ( - <Alert severity='error' className='mb-2'> - <p>Something went wrong:</p> - <pre>{error.message}</pre> - <button onClick={resetErrorBoundary}>Try again</button> - </Alert> - ); -} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/InlineBlock/CodeInline.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/InlineBlock/CodeInline.tsx deleted file mode 100644 index f28892ab22..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/InlineBlock/CodeInline.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import React from 'react'; - -function CodeInline({ children, selected }: { text: string; children: React.ReactNode; selected: boolean }) { - return ( - <span - className={'bg-content-blue-50 py-1'} - style={{ - fontSize: '85%', - lineHeight: 'normal', - backgroundColor: selected ? 'var(--content-blue-100)' : undefined, - }} - > - {children} - </span> - ); -} - -export default CodeInline; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/InlineBlock/FakeCursorContainer.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/InlineBlock/FakeCursorContainer.tsx deleted file mode 100644 index 8ea849be6f..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/InlineBlock/FakeCursorContainer.tsx +++ /dev/null @@ -1,91 +0,0 @@ -import React, { useContext, useEffect, useRef, useState } from 'react'; -import { useFocused, useRangeRef } from '$app/components/document/_shared/SubscribeSelection.hooks'; -import { NodeIdContext } from '$app/components/document/_shared/SubscribeNode.hooks'; - -/** - * This component is used to wrap the cursor display position for inline block. - * Since the children of inline blocks are just single characters, - * if not wrapped, the cursor position would follow the character instead of the block's boundary. - * This component ensures that when the cursor switches between characters, - * it is wrapped to move within the block's boundary. - */ -export const FakeCursorContainer = ({ - isFirst, - isLast, - onClick, - getSelection, - children, - renderNode, -}: { - onClick?: (node: HTMLSpanElement) => void; - getSelection: (element: HTMLElement) => { index: number; length: number } | null; - isFirst: boolean; - isLast: boolean; - children: React.ReactNode; - renderNode: () => React.ReactNode; -}) => { - const id = useContext(NodeIdContext); - const ref = useRef<HTMLSpanElement>(null); - const { focused, focusCaret } = useFocused(id); - const rangeRef = useRangeRef(); - const [position, setPosition] = useState<'left' | 'right' | undefined>(); - - useEffect(() => { - setPosition(undefined); - if (!ref.current) return; - if (!focused || !focusCaret || rangeRef.current?.isDragging) { - return; - } - - const inlineBlockSelection = getSelection(ref.current); - - if (!inlineBlockSelection) return; - const distance = inlineBlockSelection.index - focusCaret.index; - - if (distance === 0 && isFirst) { - setPosition('left'); - return; - } - - if (distance === -1) { - setPosition('right'); - return; - } - }, [focused, focusCaret, getSelection, isFirst, rangeRef]); - - useEffect(() => { - if (!ref.current) return; - const onMouseDown = (e: MouseEvent) => { - if (e.target === ref.current) { - e.stopPropagation(); - e.preventDefault(); - } - }; - - // prevent page scroll when the caret change by mouse down - document.addEventListener('mousedown', onMouseDown, true); - return () => { - document.removeEventListener('mousedown', onMouseDown, true); - }; - }, []); - - return ( - <span className={'relative inline-block px-1'} ref={ref} onClick={() => ref.current && onClick?.(ref.current)}> - <span - style={{ - pointerEvents: 'none', - left: position === 'left' ? '-1px' : undefined, - right: position === 'right' ? '-1px' : undefined, - caretColor: position === undefined ? 'transparent' : undefined, - }} - className={`absolute text-transparent`} - > - {children} - </span> - <span data-slate-placeholder={true} contentEditable={false} className={'inline-block-content'}> - {renderNode()} - </span> - {isLast && <span data-slate-string={false}></span>} - </span> - ); -}; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/InlineBlock/FormulaInline.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/InlineBlock/FormulaInline.tsx deleted file mode 100644 index 97f4b9e00c..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/InlineBlock/FormulaInline.tsx +++ /dev/null @@ -1,67 +0,0 @@ -import React, { useCallback, useContext } from 'react'; -import { NodeIdContext } from '$app/components/document/_shared/SubscribeNode.hooks'; -import { RangeStaticNoId, TemporaryType } from '$app/interfaces/document'; -import { useAppDispatch } from '$app/stores/store'; -import { createTemporary } from '$app_reducers/document/async-actions/temporary'; -import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks'; -import KatexMath from '$app/components/document/_shared/KatexMath'; -import { FakeCursorContainer } from '$app/components/document/_shared/InlineBlock/FakeCursorContainer'; - -function FormulaInline({ - isFirst, - isLast, - children, - getSelection, - selectedText, - data, -}: { - getSelection: (node: Element) => RangeStaticNoId | null; - children: React.ReactNode; - selectedText: string; - isLast: boolean; - isFirst: boolean; - data: { - latex?: string; - }; -}) { - const id = useContext(NodeIdContext); - const { docId } = useSubscribeDocument(); - const dispatch = useAppDispatch(); - const onClick = useCallback( - async (node: HTMLSpanElement) => { - const selection = getSelection(node); - - if (!selection) return; - - await dispatch( - createTemporary({ - docId, - state: { - id, - selection, - selectedText, - type: TemporaryType.Equation, - data: { latex: data.latex }, - }, - }) - ); - }, - [getSelection, data.latex, dispatch, docId, id, selectedText] - ); - - if (!selectedText) return null; - - return ( - <FakeCursorContainer - onClick={onClick} - getSelection={getSelection} - isFirst={isFirst} - isLast={isLast} - renderNode={() => <KatexMath latex={data.latex || ''} isInline />} - > - {children} - </FakeCursorContainer> - ); -} - -export default FormulaInline; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/InlineBlock/LinkInline.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/InlineBlock/LinkInline.tsx deleted file mode 100644 index ebb5c0f4c2..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/InlineBlock/LinkInline.tsx +++ /dev/null @@ -1,68 +0,0 @@ -import React, { useCallback, useContext, useRef } from 'react'; -import { RangeStaticNoId, TemporaryType } from '$app/interfaces/document'; -import { NodeIdContext } from '$app/components/document/_shared/SubscribeNode.hooks'; -import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks'; -import { useAppDispatch } from '$app/stores/store'; -import { createTemporary } from '$app_reducers/document/async-actions/temporary'; - -function LinkInline({ - children, - getSelection, - selectedText, - temporaryType, - data, -}: { - getSelection: (node: Element) => RangeStaticNoId | null; - children: React.ReactNode; - selectedText: string; - temporaryType: TemporaryType; - data: { - href?: string; - }; -}) { - const id = useContext(NodeIdContext); - const { docId } = useSubscribeDocument(); - const ref = useRef<HTMLAnchorElement>(null); - const dispatch = useAppDispatch(); - - const onClick = useCallback( - async (e: React.MouseEvent) => { - if (!ref.current) return; - const selection = getSelection(ref.current); - - if (!selection) return; - const rect = ref.current?.getBoundingClientRect(); - - if (!rect) return; - e.stopPropagation(); - e.preventDefault(); - - await dispatch( - createTemporary({ - docId, - state: { - id, - selection, - selectedText, - type: temporaryType, - data: { - href: data.href, - text: selectedText, - }, - }, - }) - ); - }, - [data, dispatch, docId, getSelection, id, selectedText, temporaryType] - ); - - return ( - <> - <span onClick={onClick} ref={ref} className='cursor-pointer text-text-link-default'> - <span className={' border-b-[1px] border-b-text-link-default'}>{children}</span> - </span> - </> - ); -} - -export default LinkInline; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/InlineBlock/PageInline.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/InlineBlock/PageInline.tsx deleted file mode 100644 index 6786ba8389..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/InlineBlock/PageInline.tsx +++ /dev/null @@ -1,63 +0,0 @@ -import React, { useCallback, useEffect, useState } from 'react'; -import { useAppSelector } from '$app/stores/store'; -import { Article } from '@mui/icons-material'; -import { PageController } from '$app/stores/effects/workspace/page/page_controller'; -import { Page } from '$app_reducers/pages/slice'; -import { useTranslation } from 'react-i18next'; -import { useNavigate } from 'react-router-dom'; -import { pageTypeMap } from '$app/constants'; -import { LinearProgress } from '@mui/material'; -import Tooltip from '@mui/material/Tooltip'; - -function PageInline({ pageId }: { pageId: string }) { - const { t } = useTranslation(); - const page = useAppSelector((state) => state.pages.pageMap[pageId]); - const navigate = useNavigate(); - const [currentPage, setCurrentPage] = useState<Page | null>(page); - const loadPage = useCallback(async (id: string) => { - const controller = new PageController(id); - - const page = await controller.getPage(); - - setCurrentPage(page); - }, []); - - const navigateToPage = useCallback( - (page: Page) => { - const pageType = pageTypeMap[page.layout]; - - navigate(`/page/${pageType}/${page.id}`); - }, - [navigate] - ); - - useEffect(() => { - if (!page) { - void loadPage(pageId); - } else { - setCurrentPage(page); - } - }, [page, loadPage, pageId]); - - return currentPage ? ( - <Tooltip arrow title={t('document.mention.page.tooltip')} placement={'top'}> - <span - onClick={() => { - if (!currentPage) return; - - navigateToPage(currentPage); - }} - className={'inline-block cursor-pointer rounded px-1 hover:bg-content-blue-100'} - > - <span className={'mr-1'}>{currentPage.icon?.value || <Article />}</span> - <span className={'font-medium underline '}>{currentPage.name || t('menuAppHeader.defaultNewPageName')}</span> - </span> - </Tooltip> - ) : ( - <span> - <LinearProgress /> - </span> - ); -} - -export default PageInline; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/MenuItem.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/MenuItem.tsx deleted file mode 100644 index ccdec64580..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/MenuItem.tsx +++ /dev/null @@ -1,82 +0,0 @@ -import React, { forwardRef, MouseEvent, useMemo } from 'react'; -import { MenuItem as MuiMenuItem } from '@mui/material'; - -const MenuItem = forwardRef(function ( - { - id, - icon, - title, - onClick, - extra, - onHover, - isHovered, - className, - iconSize, - desc, - }: { - id?: string; - className?: string; - title?: string; - desc?: string; - icon: React.ReactNode; - onClick?: () => void; - extra?: React.ReactNode; - isHovered?: boolean; - onHover?: (e: MouseEvent) => void; - iconSize?: { - width: number; - height: number; - }; - }, - ref: React.ForwardedRef<HTMLDivElement> -) { - const imgSize = useMemo(() => iconSize || { width: 50, height: 50 }, [iconSize]); - - return ( - <div className={className} ref={ref} id={id}> - <MuiMenuItem - sx={{ - borderRadius: '4px', - padding: '4px 8px', - fontSize: 14, - }} - selected={isHovered} - onMouseEnter={(e) => { - onHover?.(e); - }} - onClick={(e) => { - e.preventDefault(); - e.stopPropagation(); - onClick?.(); - }} - > - <div - style={{ - width: imgSize.width, - height: imgSize.height, - }} - className={`mr-2 flex items-center justify-center rounded border border-line-divider`} - > - {icon} - </div> - <div className={'flex flex-1 flex-col'}> - <div className={'text-sm'}>{title}</div> - {desc && ( - <div - className={'font-normal text-text-caption'} - style={{ - fontSize: '0.85em', - fontWeight: 300, - }} - > - {desc} - </div> - )} - </div> - <div>{extra}</div> - </MuiMenuItem> - </div> - ); -}); - -export default MenuItem; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/Message/index.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/Message/index.tsx deleted file mode 100644 index 69e9b74031..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/Message/index.tsx +++ /dev/null @@ -1,57 +0,0 @@ -import React, { useCallback, useMemo, useState } from 'react'; -import { Alert, Portal, Snackbar } from '@mui/material'; -import Slide, { SlideProps } from '@mui/material/Slide'; - -function SlideTransition(props: SlideProps) { - return <Slide {...props} direction='up' />; -} - -interface MessageProps { - message?: string; - key?: string; - duration?: number; - type?: 'success' | 'error'; -} -export function useMessage() { - const [state, setState] = useState<MessageProps>(); - const show = useCallback((message: MessageProps) => { - setState(message); - }, []); - const hide = useCallback(() => { - setState(undefined); - }, []); - - const contentHolder = useMemo(() => { - const open = !!state; - - return ( - <Portal> - <Snackbar - autoHideDuration={state?.duration} - anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }} - open={open} - onClose={hide} - TransitionProps={{ onExited: hide }} - key={state?.key} - TransitionComponent={SlideTransition} - > - <> - {state?.type ? ( - <Alert severity={state.type} sx={{ width: '100%' }}> - {state.message} - </Alert> - ) : ( - <span>{state?.message}</span> - )} - </> - </Snackbar> - </Portal> - ); - }, [hide, state]); - - return { - show, - hide, - contentHolder, - }; -} 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 deleted file mode 100644 index 68ca2b9ad1..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SlateEditor/CodeEditor.tsx +++ /dev/null @@ -1,34 +0,0 @@ -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 { CodeBlockElement } from '$app/components/document/_shared/SlateEditor/CodeElements'; -import TextLeaf from '$app/components/document/_shared/SlateEditor/TextLeaf'; - -function CodeEditor({ language, isDark, ...props }: CodeEditorProps) { - const { editor, onChange, value, ref, ...editableProps } = useEditor({ - ...props, - isCodeBlock: true, - }); - - return ( - <div ref={ref}> - <Slate editor={editor} onChange={onChange} value={value}> - <Editable - {...editableProps} - decorate={(entry) => { - const codeRange = decorateCode(entry, language, isDark); - const range = editableProps.decorate?.(entry) || []; - - return [...range, ...codeRange]; - }} - renderLeaf={(leafProps) => <TextLeaf editor={editor} {...leafProps} isCodeBlock={true} />} - renderElement={CodeBlockElement} - /> - </Slate> - </div> - ); -} - -export default React.memo(CodeEditor); diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SlateEditor/CodeElements.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SlateEditor/CodeElements.tsx deleted file mode 100644 index c3111a5a42..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SlateEditor/CodeElements.tsx +++ /dev/null @@ -1,9 +0,0 @@ -import { RenderElementProps } from 'slate-react'; - -export const CodeBlockElement = (props: RenderElementProps) => { - return ( - <pre className='code-block-element' {...props.attributes}> - <code>{props.children}</code> - </pre> - ); -}; 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 deleted file mode 100644 index 2fe64d2992..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SlateEditor/TextEditor.tsx +++ /dev/null @@ -1,25 +0,0 @@ -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, ref, ...editableProps } = useEditor(props); - - return ( - <div ref={ref} className={'px-1 py-0.5'}> - <Slate editor={editor} onChange={onChange} value={value}> - <Editable - renderLeaf={(leafProps) => <TextLeaf {...leafProps} editor={editor} />} - placeholder={placeholder} - renderElement={TextElement} - {...editableProps} - /> - </Slate> - </div> - ); -} - -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 deleted file mode 100644 index fbf4e9005a..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SlateEditor/TextElement.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import { RenderElementProps } from 'slate-react'; -import React, { useRef } from 'react'; - -export function TextElement(props: RenderElementProps) { - const ref = useRef<HTMLDivElement | null>(null); - - return ( - <div - {...props.attributes} - ref={(e) => { - ref.current = e; - props.attributes.ref(e); - }} - > - {props.children} - </div> - ); -} 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 deleted file mode 100644 index 6e2e87bb64..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SlateEditor/TextLeaf.tsx +++ /dev/null @@ -1,142 +0,0 @@ -import { ReactEditor, RenderLeafProps } from 'slate-react'; -import { BaseText } from 'slate'; -import { useCallback, useRef } from 'react'; -import { converToIndexLength } from '$app/utils/document/slate_editor'; -import TemporaryInput from '$app/components/document/_shared/TemporaryInput'; -import FormulaInline from '$app/components/document/_shared/InlineBlock/FormulaInline'; -import { TemporaryType } from '$app/interfaces/document'; -import LinkInline from '$app/components/document/_shared/InlineBlock/LinkInline'; -import { MentionType } from '$app_reducers/document/async-actions/mention'; -import PageInline from '$app/components/document/_shared/InlineBlock/PageInline'; -import { FakeCursorContainer } from '$app/components/document/_shared/InlineBlock/FakeCursorContainer'; -import CodeInline from '$app/components/document/_shared/InlineBlock/CodeInline'; - -interface Attributes { - bold?: boolean; - italic?: boolean; - underline?: boolean; - strikethrough?: boolean; - code?: string; - selection_high_lighted?: boolean; - href?: string; - prism_token?: string; - temporary?: boolean; - formula?: string; - font_color?: string; - bg_color?: string; - mention?: Record<string, string>; -} -interface TextLeafProps extends RenderLeafProps { - leaf: BaseText & Attributes; - isCodeBlock?: boolean; - editor: ReactEditor; -} - -const TextLeaf = (props: TextLeafProps) => { - const { attributes, children, leaf, isCodeBlock, editor } = props; - const ref = useRef<HTMLSpanElement>(null); - const { isLast, text, parent } = children.props; - const isSelected = Boolean(leaf.selection_high_lighted); - - const isFirst = text === parent?.children?.[0]; - const customAttributes = { - ...attributes, - }; - let newChildren = children; - - if (leaf.code && !leaf.temporary) { - newChildren = ( - <CodeInline selected={isSelected} text={text}> - {newChildren} - </CodeInline> - ); - } - - const getSelection = useCallback( - (node: Element) => { - const slateNode = ReactEditor.toSlateNode(editor, node); - const path = ReactEditor.findPath(editor, slateNode); - const selection = converToIndexLength(editor, { - anchor: { path, offset: 0 }, - focus: { path, offset: leaf.text.length }, - }); - - return selection; - }, - [editor, leaf] - ); - - if (leaf.href) { - newChildren = ( - <LinkInline - temporaryType={TemporaryType.Link} - getSelection={getSelection} - selectedText={leaf.text} - data={{ - href: leaf.href, - }} - > - {newChildren} - </LinkInline> - ); - } - - if (leaf.formula && leaf.text) { - const data = { latex: leaf.formula }; - - newChildren = ( - <FormulaInline isLast={isLast} isFirst={isFirst} getSelection={getSelection} data={data} selectedText={leaf.text}> - {newChildren} - </FormulaInline> - ); - } - - const mention = leaf.mention; - - if (mention && mention.type === MentionType.PAGE && leaf.text) { - newChildren = ( - <FakeCursorContainer - getSelection={getSelection} - isFirst={isFirst} - isLast={isLast} - renderNode={() => <PageInline pageId={mention.page} />} - > - {newChildren} - </FakeCursorContainer> - ); - } - - const className = [ - isCodeBlock && 'token', - leaf.prism_token && leaf.prism_token, - isSelected && 'bg-content-blue-100', - leaf.bold && 'font-bold', - leaf.italic && 'italic', - leaf.underline && 'underline', - leaf.strikethrough && 'line-through', - ].filter(Boolean); - - if (leaf.temporary) { - newChildren = ( - <TemporaryInput getSelection={getSelection} leaf={leaf}> - {newChildren} - </TemporaryInput> - ); - } - - return ( - <span - style={{ - backgroundColor: leaf.bg_color, - color: leaf.font_color, - }} - ref={ref} - {...customAttributes} - className={className.join(' ')} - > - {newChildren} - </span> - ); -}; - -export default TextLeaf; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SlateEditor/markdown.ts b/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SlateEditor/markdown.ts deleted file mode 100644 index e0d92d3a96..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SlateEditor/markdown.ts +++ /dev/null @@ -1,156 +0,0 @@ -import { TextAction } from '$app/interfaces/document'; -import { Keyboard } from '$app/constants/document/keyboard'; -import { ReactEditor } from 'slate-react'; -import { Editor, Range } from 'slate'; -import { converToSlatePoint } from '$app/utils/document/slate_editor'; -import { EQUATION_PLACEHOLDER } from '$app/constants/document/name'; - -const bold = { - type: TextAction.Bold, - /** - * ** or __ - */ - markdownRegexp: /(\*\*|__)([^\s](?:[^\s]*?[^\s])?)(\*\*|__)$/, -}; -const italic = { - type: TextAction.Italic, - /** - * * or _ - */ - markdownRegexp: /(\*|_)([^\s](?:[^\s]*?[^\s])?)(\*|_)$/, -}; -const strikethrough = { - type: TextAction.Strikethrough, - - /** - * ~~ - */ - markdownRegexp: /(~~)([^\s](?:[^\s]*?[^\s])?)(~~)$/, -}; -const inlineCode = { - type: TextAction.Code, - /** - * ` - */ - markdownRegexp: /(`)([^\s](?:[^\s]*?[^\s])?)(`)$/, -}; -const inlineEquation = { - type: TextAction.Equation, - /** - * $ - */ - markdownRegexp: /(\$)([^\s](?:[^\s]*?[^\s])?)(\$)$/, -}; -const config: Record< - string, - { - type: TextAction; - getValue?: (matchStr: string) => string | boolean; - markdownRegexp: RegExp; - }[] -> = { - [Keyboard.keys.ASTERISK]: [bold, italic], - [Keyboard.keys.UNDER_SCORE]: [bold, italic], - [Keyboard.keys.TILDE]: [strikethrough], - [Keyboard.keys.BACK_QUOTE]: [inlineCode], - [Keyboard.keys.DOLLAR]: [inlineEquation], -}; - -export const withMarkdown = (editor: ReactEditor) => { - const { insertText } = editor; - - editor.insertText = (text) => { - const { selection } = editor; - const char = text.charAt(text.length - 1); - const matchFormatTypes = config[char]; - - if (matchFormatTypes && matchFormatTypes.length > 0 && selection && Range.isCollapsed(selection)) { - const { anchor } = selection; - const start = Editor.start(editor, []); - const range = { anchor, focus: start }; - const textString = Editor.string(editor, range) + text; - const prevChar = textString.charAt(textString.length - 2); - - // If the previous character is a space, we don't want to trigger the markdown - if (prevChar === ' ') { - return insertText(text); - } - - for (const formatType of matchFormatTypes) { - const match = textString.match(formatType.markdownRegexp); - - if (match) { - const pluralStart = match[0].substring(0, 2) === char.padStart(2, char); - const pluralEnd = prevChar === char; - - if (pluralStart && !pluralEnd) { - break; - } - - const matchIndex = match.index || 0; - - if (formatType.type === TextAction.Equation) { - formatEquation(editor, matchIndex, match[2]); - return; - } - - // format already applied - editor.select({ - anchor, - focus: converToSlatePoint(editor, matchIndex), - }); - if (isMarkAction(editor, formatType.type)) { - editor.select(anchor); - break; - } - - Editor.addMark(editor, formatType.type, true); - - // delete extra characters - editor.select(converToSlatePoint(editor, matchIndex)); - editor.delete({ - distance: pluralStart ? 2 : 1, - }); - - editor.select(converToSlatePoint(editor, matchIndex + match[2].length)); - if (pluralStart) { - editor.delete({ - distance: 1, - }); - } - - return; - } - } - } - - insertText(text); - }; - - return editor; -}; - -function isMarkAction(editor: Editor, format: string) { - const marks = Editor.marks(editor) as Record<string, boolean> | null; - - return marks ? !!marks[format] : false; -} - -function formatEquation(editor: Editor, index: number, latex: string) { - editor.select(converToSlatePoint(editor, index)); - editor.delete({ - distance: latex.length + 1, - }); - - editor.insertNode( - { - text: EQUATION_PLACEHOLDER, - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - formula: latex, - }, - { - select: true, - } - ); -} 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 deleted file mode 100644 index 53d805f3ab..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SlateEditor/useEditor.ts +++ /dev/null @@ -1,218 +0,0 @@ -import { EditorProps } from '$app/interfaces/document'; -import { useCallback, useEffect, useMemo, useRef } from 'react'; -import { ReactEditor } from 'slate-react'; -import { BaseRange, Editor, NodeEntry, Range, Selection, Transforms } from 'slate'; -import { converToIndexLength, convertToSlateSelection, indent, outdent } from '$app/utils/document/slate_editor'; -import { focusNodeByIndex } from '$app/utils/document/node'; -import { Keyboard } from '$app/constants/document/keyboard'; -import isHotkey from 'is-hotkey'; -import { useSlateYjs } from '$app/components/document/_shared/SlateEditor/useSlateYjs'; - -const AFTER_RENDER_DELAY = 100; - -export function useEditor({ - onChange, - onSelectionChange, - selection, - value: delta, - decorateSelection, - onKeyDown, - isCodeBlock, - temporarySelection, -}: EditorProps) { - const { editor } = useSlateYjs({ delta, onChange }); - const ref = useRef<HTMLDivElement | null>(null); - const newValue = useMemo(() => [], []); - const onSelectionChangeHandler = useCallback( - (slateSelection: Selection) => { - const rangeStatic = converToIndexLength(editor, slateSelection); - - onSelectionChange?.(rangeStatic, null); - }, - [editor, onSelectionChange] - ); - - const onChangeHandler = useCallback(() => { - onSelectionChangeHandler(editor.selection); - }, [editor, onSelectionChangeHandler]); - - // Prevent attributes from being applied when entering text at the beginning or end of an inline block. - // For example, when entering text before or after a mentioned page, - // we expect plain text instead of applying mention attributes. - // Similarly, when entering text before or after inline code, - // we also expect plain text that is not confined within the inline code scope. - const preventInlineBlockAttributeOverride = useCallback(() => { - const marks = editor.getMarks(); - const markKeys = marks - ? Object.keys(marks).filter((mark) => ['mention', 'formula', 'href', 'code'].includes(mark)) - : []; - const currentSelection = editor.selection || []; - let removeMark = markKeys.length > 0; - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const [_, path] = editor.node(currentSelection); - - if (removeMark) { - const selectionStart = editor.start(currentSelection); - const selectionEnd = editor.end(currentSelection); - const isNodeEnd = editor.isEnd(selectionEnd, path); - const isNodeStart = editor.isStart(selectionStart, path); - - removeMark = isNodeStart || isNodeEnd; - } - - if (removeMark) { - markKeys.forEach((mark) => { - editor.removeMark(mark); - }); - } - }, [editor]); - - 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(); - } - - preventInlineBlockAttributeOverride(); - }, - [preventInlineBlockAttributeOverride] - ); - - const getDecorateRange = useCallback( - ( - path: number[], - selection: - | { - index: number; - length: number; - } - | undefined, - value: Record<string, boolean | string | undefined> - ) => { - if (!selection) return null; - const range = convertToSlateSelection(selection.index, selection.length, editor.children) as BaseRange; - - if (range && !Range.isCollapsed(range)) { - const intersection = Range.intersection(range, Editor.range(editor, path)); - - if (intersection) { - return { - ...intersection, - ...value, - }; - } - } - - return null; - }, - [editor] - ); - - const decorate = useCallback( - (entry: NodeEntry) => { - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const [_, path] = entry; - - return [ - getDecorateRange(path, decorateSelection, { - selection_high_lighted: true, - }), - getDecorateRange(path, temporarySelection, { - temporary: true, - }), - ].filter((range) => range !== null) as Range[]; - }, - [temporarySelection, decorateSelection, getDecorateRange] - ); - - const onKeyDownRewrite = useCallback( - (event: React.KeyboardEvent<HTMLDivElement>) => { - 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<HTMLDivElement>) => { - editor.deselect(); - }, - [editor] - ); - - useEffect(() => { - if (!ref.current) return; - - const isFocused = ReactEditor.isFocused(editor); - - if (!selection) { - isFocused && editor.deselect(); - return; - } - - const slateSelection = convertToSlateSelection(selection.index, selection.length, editor.children); - - if (!slateSelection) return; - - const isEqual = JSON.stringify(slateSelection) === JSON.stringify(editor.selection); - - if (isFocused && isEqual) return; - - // why we didn't use slate api to change selection? - // because the slate must be focused before change selection, - // but then it will trigger selection change, and the selection is not what we want - const isSuccess = focusNodeByIndex(ref.current, selection.index, selection.length); - - if (!isSuccess) { - Transforms.select(editor, slateSelection); - } else { - // Fix: the slate is possible to lose focus in next tick after focusNodeByIndex - setTimeout(() => { - if (window.getSelection()?.type === 'None' && !editor.selection) { - Transforms.select(editor, slateSelection); - } - }, AFTER_RENDER_DELAY); - } - }, [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 deleted file mode 100644 index ba4669db6c..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SlateEditor/useSlateYjs.ts +++ /dev/null @@ -1,54 +0,0 @@ -import Delta, { Op } from 'quill-delta'; -import { useEffect, useMemo, useState } 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'; -import { withMarkdown } from '$app/components/document/_shared/SlateEditor/markdown'; - -export function useSlateYjs({ delta, onChange }: { delta?: Delta; onChange: (ops: Op[], newDelta: Delta) => void }) { - const [yText, setYText] = useState<Y.Text | undefined>(undefined); - 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); - setYText(insertDelta[0].insert as Y.Text); - return sharedType; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - // eslint-disable-next-line react-hooks/exhaustive-deps - const editor = useMemo(() => withYjs(withMarkdown(withReact(createEditor())), sharedType), []); - - // Connect editor in useEffect to comply with concurrent mode requirements. - useEffect(() => { - YjsEditor.connect(editor); - const observer = (event: Y.YTextEvent) => { - const ops = event.changes.delta as Op[]; - const newDelta = new Delta(yText?.toDelta()); - - onChange(ops, newDelta); - }; - - yText?.observe(observer); - return () => { - YjsEditor.disconnect(editor); - yText?.unobserve(observer); - }; - }, [editor, yText, onChange]); - - useEffect(() => { - 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, yText]); - - return { editor }; -} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SubscribeBlockEdit.hooks.ts b/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SubscribeBlockEdit.hooks.ts deleted file mode 100644 index 59d97b6f94..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SubscribeBlockEdit.hooks.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks'; -import { useAppSelector } from '$app/stores/store'; -import { BLOCK_EDIT_NAME } from '$app/constants/document/name'; - -export function useSubscribeBlockEditState() { - const { docId } = useSubscribeDocument(); - const blockEditState = useAppSelector((state) => state[BLOCK_EDIT_NAME][docId]); - - return blockEditState; -} - -export function useEditingState(id: string) { - const blockEditState = useSubscribeBlockEditState(); - - return blockEditState?.id === id && blockEditState?.editing; -} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SubscribeDoc.hooks.ts b/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SubscribeDoc.hooks.ts deleted file mode 100644 index 11eceaaeca..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SubscribeDoc.hooks.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { DocumentControllerContext } from '$app/stores/effects/document/document_controller'; -import { useContext } from 'react'; -import { useAppSelector } from '$app/stores/store'; -import { DOCUMENT_NAME } from '$app/constants/document/name'; - -export function useSubscribeDocument() { - const controller = useContext(DocumentControllerContext); - const docId = controller.documentId; - - return { - docId, - controller, - }; -} - -export function useSubscribeDocumentData() { - const { docId } = useSubscribeDocument(); - const data = useAppSelector((state) => { - return state[DOCUMENT_NAME][docId]; - }); - - return data; -} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SubscribeMention.hooks.ts b/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SubscribeMention.hooks.ts deleted file mode 100644 index 2043209b40..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SubscribeMention.hooks.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks'; -import { useAppSelector } from '$app/stores/store'; -import { MENTION_NAME } from '$app/constants/document/name'; -import { MentionState } from '$app_reducers/document/mention_slice'; - -const initialState: MentionState = { - open: false, - blockId: '', -}; - -export function useSubscribeMentionState() { - const { docId } = useSubscribeDocument(); - - const state = useAppSelector((state) => { - return state[MENTION_NAME][docId] || initialState; - }); - - return state; -} 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 deleted file mode 100644 index 0efefa4621..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SubscribeNode.hooks.ts +++ /dev/null @@ -1,85 +0,0 @@ -import { store, useAppSelector } from '@/appflowy_app/stores/store'; -import { createContext, useMemo } from 'react'; -import { Node } from '$app/interfaces/document'; -import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks'; -import { DOCUMENT_NAME, RECT_RANGE_NAME } from '$app/constants/document/name'; -import Delta from 'quill-delta'; - -/** - * Subscribe node information - * @param id - */ -export function useSubscribeNode(id: string) { - const { docId } = useSubscribeDocument(); - - const { node, delta } = useAppSelector<{ - node: Node; - delta: string; - }>((state) => { - const documentState = state[DOCUMENT_NAME][docId]; - const node = documentState?.nodes[id]; - - // if node is root, return page name - if (!node?.parent) { - const delta = state.pages?.pageMap[docId]?.name; - - return { - node, - delta: delta ? JSON.stringify(new Delta().insert(delta)) : '', - }; - } - - const externalId = node?.externalId; - - return { - node, - delta: externalId ? documentState?.deltaMap[externalId] : '', - }; - }); - - const childIds = useAppSelector<string[] | undefined>((state) => { - const documentState = state[DOCUMENT_NAME][docId]; - - if (!documentState) return; - const childrenId = documentState.nodes[id]?.children; - - if (!childrenId) return; - return documentState.children[childrenId]; - }); - - const isSelected = useAppSelector<boolean>((state) => { - return state[RECT_RANGE_NAME][docId]?.selection.includes(id) || false; - }); - - // Memoize the node and its children - // So that the component will not be re-rendered when other node is changed - // It very important for performance - // eslint-disable-next-line react-hooks/exhaustive-deps - const memoizedNode = useMemo(() => node, [JSON.stringify(node)]); - // eslint-disable-next-line react-hooks/exhaustive-deps - const memoizedChildIds = useMemo(() => childIds, [JSON.stringify(childIds)]); - - return { - node: memoizedNode, - childIds: memoizedChildIds, - delta, - isSelected, - }; -} - -export function getBlock(docId: string, id: string) { - return store.getState().document[docId]?.nodes[id]; -} - -export function getBlockDelta(docId: string, id: string) { - const node = getBlock(docId, id); - - if (!node?.externalId) return; - const deltaStr = store.getState().document[docId]?.deltaMap[node.externalId]; - const deltaJson = JSON.parse(deltaStr); - const delta = new Delta(deltaJson); - - return delta; -} - -export const NodeIdContext = createContext<string>(''); diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SubscribeRectRange.hooks.ts b/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SubscribeRectRange.hooks.ts deleted file mode 100644 index c5394878c5..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SubscribeRectRange.hooks.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks'; -import { useAppSelector } from '$app/stores/store'; -import { RECT_RANGE_NAME } from '$app/constants/document/name'; - -export function useSubscribeRectRange() { - const { docId } = useSubscribeDocument(); - const rectRange = useAppSelector((state) => { - return state[RECT_RANGE_NAME][docId]; - }); - - return rectRange; -} 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 deleted file mode 100644 index fa1ee30fd1..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SubscribeSelection.hooks.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { useAppSelector } from '$app/stores/store'; -import { RangeState, RangeStatic } from '$app/interfaces/document'; -import { useMemo, useRef } from 'react'; -import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks'; -import { RANGE_NAME, TEMPORARY_NAME } from '$app/constants/document/name'; - -export function useSubscribeDecorate(id: string) { - const { docId } = useSubscribeDocument(); - - const decorateSelection = useAppSelector((state) => { - return state[RANGE_NAME][docId]?.ranges[id]; - }); - - const temporarySelection = useAppSelector((state) => { - const temporary = state[TEMPORARY_NAME][docId]; - - if (!temporary || temporary.id !== id) return; - return temporary.selection; - }); - - return { - decorateSelection, - temporarySelection, - }; -} - -export function useFocused(id: string) { - const { docId } = useSubscribeDocument(); - - const caretRef = useRef<RangeStatic>(); - const focusCaret = useAppSelector((state) => { - const currentCaret = state[RANGE_NAME][docId]?.caret; - - caretRef.current = currentCaret; - if (currentCaret?.id === id) { - return currentCaret; - } - - return null; - }); - - const focused = useMemo(() => { - return focusCaret && focusCaret?.id === id; - }, [focusCaret, id]); - - return { - focused, - caretRef, - focusCaret, - }; -} - -export function useRangeRef() { - const { docId } = useSubscribeDocument(); - - const rangeRef = useRef<RangeState>(); - - useAppSelector((state) => { - const currentRange = state[RANGE_NAME][docId]; - - rangeRef.current = currentRange; - }); - return rangeRef; -} - -export function useSubscribeRanges() { - const { docId } = useSubscribeDocument(); - - const rangeState = useAppSelector((state) => { - return state[RANGE_NAME][docId]; - }); - - return rangeState; -} - -export function useSubscribeCaret() { - const { docId } = useSubscribeDocument(); - - const caret = useAppSelector((state) => { - return state[RANGE_NAME][docId]?.caret; - }); - - return caret; -} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SubscribeSlash.hooks.ts b/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SubscribeSlash.hooks.ts deleted file mode 100644 index 94fca0f2f1..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SubscribeSlash.hooks.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks'; -import { useAppSelector } from '$app/stores/store'; -import { SLASH_COMMAND_NAME } from '$app/constants/document/name'; - -export function useSubscribeSlashState() { - const { docId } = useSubscribeDocument(); - - const slashCommandState = useAppSelector((state) => { - return state[SLASH_COMMAND_NAME][docId]; - }); - - return slashCommandState; -} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SubscribeTemporary.hooks.ts b/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SubscribeTemporary.hooks.ts deleted file mode 100644 index 1b3d0f69a8..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SubscribeTemporary.hooks.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks'; -import { useAppSelector } from '$app/stores/store'; -import { TemporaryState } from '$app/interfaces/document'; -import { TEMPORARY_NAME } from '$app/constants/document/name'; - -export function useSubscribeTemporary(): TemporaryState { - const { docId } = useSubscribeDocument(); - const temporaryState = useAppSelector((state) => state[TEMPORARY_NAME][docId]); - - return temporaryState; -} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/TemporaryInput/EquationEditContent.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/TemporaryInput/EquationEditContent.tsx deleted file mode 100644 index 24c56fa073..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/TemporaryInput/EquationEditContent.tsx +++ /dev/null @@ -1,62 +0,0 @@ -import React from 'react'; -import TextField from '@mui/material/TextField'; -import { CheckOutlined, FunctionsOutlined } from '@mui/icons-material'; -import { IconButton, InputAdornment } from '@mui/material'; - -function EquationEditContent({ - value, - onChange, - onConfirm, - placeholder = 'E = mc^2', - multiline = false, -}: { - value: string; - placeholder?: string; - onChange: (newVal: string) => void; - onConfirm: () => void; - multiline?: boolean; -}) { - return ( - <div className={'flex items-center p-2'}> - <TextField - placeholder={placeholder} - autoFocus={true} - multiline={multiline} - label='Equation' - onKeyDown={(e) => { - if (e.key === 'Enter' && !e.shiftKey) { - e.preventDefault(); - onConfirm(); - } - }} - InputProps={{ - startAdornment: ( - <InputAdornment position='start'> - <FunctionsOutlined /> - </InputAdornment> - ), - }} - variant='standard' - value={value} - onChange={(e) => { - const newVal = e.target.value; - - if (newVal === value) return; - onChange(newVal); - }} - /> - - <IconButton - className={'h-[23px] w-[23px]'} - onClick={onConfirm} - color='primary' - sx={{ p: '10px', m: '10px' }} - aria-label='directions' - > - <CheckOutlined /> - </IconButton> - </div> - ); -} - -export default EquationEditContent; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/TemporaryInput/LinkEditContent.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/TemporaryInput/LinkEditContent.tsx deleted file mode 100644 index 8d36218972..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/TemporaryInput/LinkEditContent.tsx +++ /dev/null @@ -1,125 +0,0 @@ -import React, { useCallback, useMemo, useRef } from 'react'; -import TextField from '@mui/material/TextField'; -import { IconButton } from '@mui/material'; -import { LinkOff, OpenInNew } from '@mui/icons-material'; -import { useTranslation } from 'react-i18next'; -import Button from '@mui/material/Button'; -import Tooltip from '@mui/material/Tooltip'; -import CopyIcon from '@mui/icons-material/CopyAll'; -import { copyText } from '$app/utils/document/copy_paste'; -import { open } from '@tauri-apps/api/shell'; - -function LinkEditContent({ - value, - onChange, - onConfirm, -}: { - value: { - href?: string; - text?: string; - }; - onChange: (val: { href: string; text: string }) => void; - onConfirm: () => void; -}) { - const valueRef = useRef<{ - href?: string; - text?: string; - }>(value); - const { t } = useTranslation(); - const onKeyDown = useCallback( - (e: React.KeyboardEvent) => { - if (e.key === 'Enter' && !e.shiftKey) { - e.preventDefault(); - onConfirm(); - } - }, - [onConfirm] - ); - - const operations = useMemo( - () => [ - { - icon: <OpenInNew />, - tooltip: t('document.inlineLink.openInNewTab'), - onClick: () => { - void open(valueRef.current.href || ''); - }, - }, - { - icon: <CopyIcon />, - tooltip: t('document.inlineLink.copyLink'), - onClick: () => { - void copyText(valueRef.current.href || ''); - }, - }, - { - icon: <LinkOff />, - tooltip: t('document.inlineLink.removeLink'), - onClick: () => { - onChange({ - href: '', - text: valueRef.current.text || '', - }); - onConfirm(); - }, - }, - ], - [onChange, t, onConfirm] - ); - - return ( - <div className={'flex w-[420px] flex-col items-end p-4'}> - <div className={'flex w-full items-center justify-end'}> - {operations.map((operation, index) => ( - <Tooltip placement={'top'} key={index} title={operation.tooltip}> - <div className={'ml-2 cursor-pointer rounded border border-line-divider'}> - <IconButton onClick={operation.onClick}>{operation.icon}</IconButton> - </div> - </Tooltip> - ))} - </div> - <div className={'flex h-[150px] w-full flex-col justify-between'}> - <TextField - autoFocus - placeholder={t('document.inlineLink.url.placeholder')} - label={t('document.inlineLink.url.label')} - onKeyDown={onKeyDown} - variant='standard' - value={value.href} - onChange={(e) => { - const newVal = e.target.value; - - if (newVal === value.href) return; - onChange({ - text: value.text || '', - href: newVal, - }); - }} - /> - <TextField - placeholder={t('document.inlineLink.title.placeholder')} - label={t('document.inlineLink.title.label')} - onKeyDown={onKeyDown} - variant='standard' - value={value.text} - onChange={(e) => { - const newVal = e.target.value; - - if (newVal === value.text) return; - onChange({ - text: newVal, - href: value.href || '', - }); - }} - /> - <div className={'flex w-full items-center justify-end'}> - <Button onClick={onConfirm} color='primary'> - {t('button.save')} - </Button> - </div> - </div> - </div> - ); -} - -export default LinkEditContent; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/TemporaryInput/TemporaryEquation.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/TemporaryInput/TemporaryEquation.tsx deleted file mode 100644 index d26d496edd..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/TemporaryInput/TemporaryEquation.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import { Functions } from '@mui/icons-material'; -import KatexMath from '$app/components/document/_shared/KatexMath'; - -function TemporaryEquation({ latex }: { latex: string }) { - return ( - <span className={'rounded bg-content-blue-50 px-1 py-0.5'} contentEditable={false}> - {latex ? ( - <KatexMath latex={latex} isInline /> - ) : ( - <span className={'text-text-title'}> - <Functions /> {'New equation'} - </span> - )} - </span> - ); -} - -export default TemporaryEquation; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/TemporaryInput/TemporaryLink.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/TemporaryInput/TemporaryLink.tsx deleted file mode 100644 index f16352a31b..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/TemporaryInput/TemporaryLink.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import React from 'react'; -import { AddLinkOutlined } from '@mui/icons-material'; -import { useTranslation } from 'react-i18next'; - -function TemporaryLink({ text = '' }: { href?: string; text?: string }) { - const { t } = useTranslation(); - - return ( - <span className={'bg-content-blue-100'} contentEditable={false}> - {text ? ( - <span className={'text-text-link-default underline'}>{text}</span> - ) : ( - <span className={'text-text-caption'}> - <AddLinkOutlined /> {t('document.inlineLink.title.label')} - </span> - )} - </span> - ); -} - -export default TemporaryLink; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/TemporaryInput/TemporaryPopover.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/TemporaryInput/TemporaryPopover.tsx deleted file mode 100644 index ec53767774..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/TemporaryInput/TemporaryPopover.tsx +++ /dev/null @@ -1,147 +0,0 @@ -import React, { useCallback, useMemo } from 'react'; -import Popover from '@mui/material/Popover'; -import { RangeStaticNoId, TemporaryData, TemporaryState, TemporaryType } from '$app/interfaces/document'; -import EquationEditContent from '$app/components/document/_shared/TemporaryInput/EquationEditContent'; -import { temporaryActions } from '$app_reducers/document/temporary_slice'; -import { rangeActions } from '$app_reducers/document/slice'; -import { formatTemporary } from '$app_reducers/document/async-actions/temporary'; -import { useAppDispatch } from '$app/stores/store'; -import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks'; -import { useSubscribeTemporary } from '$app/components/document/_shared/SubscribeTemporary.hooks'; -import LinkEditContent from '$app/components/document/_shared/TemporaryInput/LinkEditContent'; - -const AFTER_RENDER_DELAY = 100; - -function TemporaryPopover() { - const temporaryState = useSubscribeTemporary(); - const anchorPosition = useMemo(() => temporaryState?.popoverPosition, [temporaryState]); - const open = Boolean(anchorPosition); - const id = temporaryState?.id; - const dispatch = useAppDispatch(); - const { docId, controller } = useSubscribeDocument(); - - const onChangeData = useCallback( - (data: TemporaryData) => { - dispatch( - temporaryActions.updateTemporaryState({ - id: docId, - state: { - data, - id, - }, - }) - ); - }, - [dispatch, docId, id] - ); - - const resetCaret = useCallback( - (id: string, selection: RangeStaticNoId) => { - dispatch( - rangeActions.setCaret({ - docId, - caret: { - id, - index: selection.index + selection.length, - length: 0, - }, - }) - ); - }, - [dispatch, docId] - ); - - const onClose = useCallback(() => { - dispatch( - temporaryActions.updateTemporaryState({ - id: docId, - state: { - id, - popoverPosition: null, - }, - }) - ); - }, [dispatch, docId, id]); - - const handleClose = useCallback(() => { - if (!temporaryState) return; - onClose(); - dispatch(temporaryActions.deleteTemporaryState(docId)); - resetCaret(temporaryState.id, temporaryState.selection); - }, [dispatch, docId, onClose, resetCaret, temporaryState]); - - const onConfirm = useCallback(async () => { - const res = await dispatch( - formatTemporary({ - controller, - }) - ); - const state = res.payload as TemporaryState; - - if (!state) return; - const { id, selection } = state; - - onClose(); - dispatch(rangeActions.clearRanges({ docId })); - dispatch(temporaryActions.deleteTemporaryState(docId)); - // wait slate to update the dom - setTimeout(() => { - resetCaret(id, selection); - }, AFTER_RENDER_DELAY); - }, [dispatch, controller, onClose, docId, resetCaret]); - - const renderPopoverContent = useCallback(() => { - if (!temporaryState) return null; - const { type, data } = temporaryState; - - switch (type) { - case TemporaryType.Equation: - return ( - <EquationEditContent - value={data.latex || ''} - onChange={(latex: string) => - onChangeData({ - latex, - }) - } - onConfirm={onConfirm} - /> - ); - case TemporaryType.Link: - return ( - <LinkEditContent - value={{ - href: data.href || '', - text: data.text || '', - }} - onChange={(val: { href: string; text: string }) => onChangeData(val)} - onConfirm={onConfirm} - /> - ); - } - }, [onChangeData, onConfirm, temporaryState]); - - return ( - <Popover - onClose={handleClose} - open={open} - anchorPosition={anchorPosition ? anchorPosition : undefined} - onMouseDown={(e) => e.stopPropagation()} - disableAutoFocus={true} - disableRestoreFocus={true} - anchorReference={'anchorPosition'} - anchorOrigin={{ - vertical: 'bottom', - horizontal: 'center', - }} - transformOrigin={{ - vertical: 'top', - horizontal: 'center', - }} - > - {renderPopoverContent()} - </Popover> - ); -} - -export default TemporaryPopover; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/TemporaryInput/index.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/TemporaryInput/index.tsx deleted file mode 100644 index 4e9460dbf3..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/TemporaryInput/index.tsx +++ /dev/null @@ -1,92 +0,0 @@ -import React, { useCallback, useEffect, useRef, useState } from 'react'; -import { RangeStaticNoId, TemporaryType } from '$app/interfaces/document'; -import TemporaryEquation from '$app/components/document/_shared/TemporaryInput/TemporaryEquation'; -import { useSubscribeTemporary } from '$app/components/document/_shared/SubscribeTemporary.hooks'; -import { PopoverPosition } from '@mui/material'; -import { useAppDispatch } from '$app/stores/store'; -import { temporaryActions } from '$app_reducers/document/temporary_slice'; -import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks'; -import TemporaryLink from '$app/components/document/_shared/TemporaryInput/TemporaryLink'; - -function TemporaryInput({ - leaf, - children, - getSelection, -}: { - leaf: { text: string }; - children: React.ReactNode; - getSelection: (node: Element) => RangeStaticNoId | null; -}) { - const temporaryState = useSubscribeTemporary(); - const id = temporaryState?.id; - const dispatch = useAppDispatch(); - const ref = useRef<HTMLSpanElement>(null); - const { docId } = useSubscribeDocument(); - const [match, setMatch] = useState(false); - - const getMatch = useCallback(() => { - if (!ref.current) return false; - if (!leaf.text) return false; - if (!temporaryState) return false; - const { selectedText } = temporaryState; - const selection = getSelection(ref.current); - - if (!selection) return false; - - return leaf.text === selectedText || selection.index <= temporaryState.selection.index; - }, [leaf.text, temporaryState, getSelection]); - - const renderPlaceholder = useCallback(() => { - if (!temporaryState) return null; - const { type, data } = temporaryState; - - switch (type) { - case TemporaryType.Equation: - return <TemporaryEquation latex={data.latex || ''} />; - case TemporaryType.Link: - return <TemporaryLink {...data} />; - default: - return null; - } - }, [temporaryState]); - - const setAnchorPosition = useCallback( - (position: PopoverPosition | null) => { - dispatch( - temporaryActions.updateTemporaryState({ - id: docId, - state: { - id, - popoverPosition: position, - }, - }) - ); - }, - [dispatch, docId, id] - ); - - useEffect(() => { - if (!ref.current || !match) return; - const { width, height, top, left } = ref.current.getBoundingClientRect(); - - setAnchorPosition({ - top: top + height, - left: left + width / 2, - }); - }, [dispatch, docId, id, match, setAnchorPosition]); - - useEffect(() => { - const match = getMatch(); - - setMatch(match); - }, [getMatch]); - - return ( - <span ref={ref}> - {match ? renderPlaceholder() : null} - <span className={`absolute opacity-0 ${match ? 'w-0' : ''}`}>{children}</span> - </span> - ); -} - -export default TemporaryInput; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/TurnInto/TurnInto.hooks.ts b/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/TurnInto/TurnInto.hooks.ts deleted file mode 100644 index 1f1df71304..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/TurnInto/TurnInto.hooks.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { useAppDispatch } from '$app/stores/store'; -import { useCallback } from 'react'; -import { BlockData, BlockType, NestedBlock } from '$app/interfaces/document'; -import { blockConfig } from '$app/constants/document/config'; -import { turnToBlockThunk } from '$app_reducers/document/async-actions'; -import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks'; -import { setRectSelectionThunk } from '$app_reducers/document/async-actions/rect_selection'; - -export function useTurnInto({ node, onClose }: { node: NestedBlock; onClose?: () => void }) { - const dispatch = useAppDispatch(); - - const { controller, docId } = useSubscribeDocument(); - - const turnIntoBlock = useCallback( - async (type: BlockType, isSelected: boolean, data?: BlockData) => { - if (!controller || isSelected) { - onClose?.(); - return; - } - - const config = blockConfig[type]; - const defaultData = config.defaultData; - const updateData = { - ...defaultData, - ...data, - }; - - const { payload: newBlockId } = await dispatch( - turnToBlockThunk({ - id: node.id, - controller, - type, - data: updateData, - }) - ); - - onClose?.(); - await dispatch( - setRectSelectionThunk({ - docId, - selection: [newBlockId as string], - }) - ); - }, - [controller, node, dispatch, onClose, docId] - ); - - const turnIntoHeading = useCallback( - (level: number, isSelected: boolean) => { - return turnIntoBlock(BlockType.HeadingBlock, isSelected, { level }); - }, - [turnIntoBlock] - ); - - return { - turnIntoBlock, - turnIntoHeading, - }; -} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/TurnInto/index.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/TurnInto/index.tsx deleted file mode 100644 index b1388ff6ce..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/TurnInto/index.tsx +++ /dev/null @@ -1,224 +0,0 @@ -import React, { useCallback, useEffect, useMemo } from 'react'; -import { BlockType, SlashCommandOptionKey } from '$app/interfaces/document'; - -import { - ArrowRight, - Check, - DataObject, - FormatListBulleted, - FormatListNumbered, - FormatQuote, - Lightbulb, - TextFields, - Title, - Functions, -} from '@mui/icons-material'; -import Popover, { PopoverProps } from '@mui/material/Popover'; -import { useSubscribeNode } from '$app/components/document/_shared/SubscribeNode.hooks'; -import { useTurnInto } from '$app/components/document/_shared/TurnInto/TurnInto.hooks'; -import { Keyboard } from '$app/constants/document/keyboard'; -import MenuItem from '$app/components/document/_shared/MenuItem'; -import { selectOptionByUpDown } from '$app/utils/document/menu'; - -interface Option { - key: SlashCommandOptionKey; - type: BlockType; - title: string; - icon: React.ReactNode; - selected?: boolean; - onClick?: (type: BlockType, isSelected: boolean) => void; -} -const TurnIntoPopover = ({ - id, - onClose, - onOk, - ...props -}: { - id: string; - onClose?: () => void; - onOk?: () => void; -} & PopoverProps) => { - const { node } = useSubscribeNode(id); - const { turnIntoHeading, turnIntoBlock } = useTurnInto({ node, onClose }); - const [hovered, setHovered] = React.useState<SlashCommandOptionKey>(); - - const options: Option[] = useMemo( - () => [ - { - key: SlashCommandOptionKey.TEXT, - type: BlockType.TextBlock, - title: 'Text', - icon: <TextFields />, - }, - { - key: SlashCommandOptionKey.HEADING_1, - type: BlockType.HeadingBlock, - title: 'Heading 1', - icon: <Title />, - selected: node?.data?.level === 1, - onClick: (type: BlockType, isSelected: boolean) => { - void turnIntoHeading(1, isSelected); - }, - }, - { - key: SlashCommandOptionKey.HEADING_2, - type: BlockType.HeadingBlock, - title: 'Heading 2', - icon: <Title />, - selected: node?.data?.level === 2, - onClick: (type: BlockType, isSelected: boolean) => { - void turnIntoHeading(2, isSelected); - }, - }, - { - key: SlashCommandOptionKey.HEADING_3, - type: BlockType.HeadingBlock, - title: 'Heading 3', - icon: <Title />, - selected: node?.data?.level === 3, - onClick: (type: BlockType, isSelected: boolean) => { - void turnIntoHeading(3, isSelected); - }, - }, - { - key: SlashCommandOptionKey.TODO, - type: BlockType.TodoListBlock, - title: 'To-do list', - icon: <Check />, - }, - { - key: SlashCommandOptionKey.BULLET, - type: BlockType.BulletedListBlock, - title: 'Bulleted list', - icon: <FormatListBulleted />, - }, - { - key: SlashCommandOptionKey.NUMBER, - type: BlockType.NumberedListBlock, - title: 'Numbered list', - icon: <FormatListNumbered />, - }, - { - key: SlashCommandOptionKey.TOGGLE, - type: BlockType.ToggleListBlock, - title: 'Toggle list', - icon: <ArrowRight />, - }, - { - key: SlashCommandOptionKey.CODE, - type: BlockType.CodeBlock, - title: 'Code', - icon: <DataObject />, - }, - { - key: SlashCommandOptionKey.QUOTE, - type: BlockType.QuoteBlock, - title: 'Quote', - icon: <FormatQuote />, - }, - { - key: SlashCommandOptionKey.CALLOUT, - type: BlockType.CalloutBlock, - title: 'Callout', - icon: <Lightbulb />, - }, - { - key: SlashCommandOptionKey.EQUATION, - type: BlockType.EquationBlock, - title: 'Block Equation', - icon: <Functions />, - }, - ], - [node?.data?.level, turnIntoHeading] - ); - - const getSelected = useCallback( - (option: Option) => { - return option.type === node.type && option.selected !== false; - }, - [node?.type] - ); - - const onClick = useCallback( - (option: Option) => { - const isSelected = getSelected(option); - - option.onClick ? option.onClick(option.type, isSelected) : void turnIntoBlock(option.type, isSelected); - onOk?.(); - }, - [onOk, getSelected, turnIntoBlock] - ); - - const onKeyDown = useCallback( - (e: KeyboardEvent) => { - e.stopPropagation(); - e.preventDefault(); - const isUp = e.key === Keyboard.keys.UP; - const isDown = e.key === Keyboard.keys.DOWN; - const isEnter = e.key === Keyboard.keys.ENTER; - const isLeft = e.key === Keyboard.keys.LEFT; - - if (isLeft) { - onClose?.(); - return; - } - - if (!isUp && !isDown && !isEnter) return; - if (isEnter) { - const option = options.find((option) => option.key === hovered); - - if (option) { - onClick(option); - } - - return; - } - - const nextKey = selectOptionByUpDown( - isUp, - String(hovered), - options.map((option) => String(option.key)) - ); - const nextOption = options.find((option) => String(option.key) === nextKey); - - setHovered(nextOption?.key); - }, - [hovered, onClick, onClose, options] - ); - - useEffect(() => { - if (props.open) { - document.addEventListener('keydown', onKeyDown, true); - } - - return () => { - document.removeEventListener('keydown', onKeyDown, true); - }; - }, [onKeyDown, props.open]); - - return ( - <Popover disableAutoFocus={true} onClose={onClose} {...props}> - <div className={'min-w-[220px] p-2'}> - {options.map((option) => { - return ( - <MenuItem - iconSize={{ - width: 20, - height: 20, - }} - icon={option.icon} - title={option.title} - isHovered={hovered === option.key} - extra={getSelected(option) ? <Check /> : null} - className={'w-[100%]'} - key={option.title} - onClick={() => onClick(option)} - ></MenuItem> - ); - })} - </div> - </Popover> - ); -}; - -export default TurnIntoPopover; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/UndoHooks/useUndoRedo.ts b/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/UndoHooks/useUndoRedo.ts deleted file mode 100644 index 3a101cac22..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/UndoHooks/useUndoRedo.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { useCallback, useEffect } from 'react'; -import isHotkey from 'is-hotkey'; -import { Keyboard } from '@/appflowy_app/constants/document/keyboard'; -import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks'; - -export function useUndoRedo(container: HTMLDivElement) { - const { controller } = useSubscribeDocument(); - - const onUndo = useCallback(async () => { - if (!controller) return; - await controller.undo(); - }, [controller]); - - const onRedo = useCallback(async () => { - if (!controller) return; - await controller.redo(); - }, [controller]); - - const handleKeyDownCapture = useCallback( - async (e: KeyboardEvent) => { - if (isHotkey(Keyboard.keys.UNDO, e)) { - e.stopPropagation(); - await onUndo(); - } - - if (isHotkey(Keyboard.keys.REDO, e)) { - e.stopPropagation(); - await onRedo(); - } - }, - [onRedo, onUndo] - ); - - useEffect(() => { - container.addEventListener('keydown', handleKeyDownCapture, true); - return () => { - container.removeEventListener('keydown', handleKeyDownCapture, true); - }; - }, [container, handleKeyDownCapture]); -} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/UploadImage/ImageEdit.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/UploadImage/ImageEdit.tsx deleted file mode 100644 index af3ed294a4..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/UploadImage/ImageEdit.tsx +++ /dev/null @@ -1,53 +0,0 @@ -import React, { useCallback, useState } from 'react'; -import { useTranslation } from 'react-i18next'; -import { TAB_KEYS, TabPanel } from './TabPanel'; -import { Box, Button, Tab, Tabs, TextField } from '@mui/material'; -import UploadImage from './UploadImage'; - -interface Props { - onSubmitUrl: (url: string) => void; - url?: string; -} - -function ImageEdit({ onSubmitUrl, url }: Props) { - const { t } = useTranslation(); - const [linkVal, setLinkVal] = useState<string>(url || ''); - const [tabKey, setTabKey] = useState<TAB_KEYS>(TAB_KEYS.UPLOAD); - const handleChange = useCallback((_: React.SyntheticEvent, newValue: TAB_KEYS) => { - setTabKey(newValue); - }, []); - - return ( - <div className={'h-full w-full'}> - <Box sx={{ borderBottom: 1, borderColor: 'divider' }}> - <Tabs value={tabKey} onChange={handleChange}> - <Tab label={t('document.imageBlock.upload.label')} value={TAB_KEYS.UPLOAD} /> - - <Tab label={t('document.imageBlock.url.label')} value={TAB_KEYS.LINK} /> - </Tabs> - </Box> - <TabPanel value={tabKey} index={TAB_KEYS.UPLOAD}> - <UploadImage onChange={onSubmitUrl} /> - </TabPanel> - - <TabPanel className={'flex flex-col p-3'} value={tabKey} index={TAB_KEYS.LINK}> - <TextField - value={linkVal} - onChange={(e) => setLinkVal(e.target.value)} - variant='outlined' - label={t('document.imageBlock.url.label')} - autoFocus={true} - style={{ - marginBottom: '10px', - }} - placeholder={t('document.imageBlock.url.placeholder')} - /> - <Button onClick={() => onSubmitUrl(linkVal)} variant='contained'> - {t('button.upload')} - </Button> - </TabPanel> - </div> - ); -} - -export default ImageEdit; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/UploadImage/ImageEditPopover.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/UploadImage/ImageEditPopover.tsx deleted file mode 100644 index e341563d57..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/UploadImage/ImageEditPopover.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import React from 'react'; -import Popover, { PopoverProps } from '@mui/material/Popover'; -import ImageEdit from './ImageEdit'; - -interface Props extends PopoverProps { - onSubmitUrl: (url: string) => void; - url?: string; -} - -function ImageEditPopover({ onSubmitUrl, url, ...props }: Props) { - return ( - <Popover {...props}> - <ImageEdit onSubmitUrl={onSubmitUrl} url={url} /> - </Popover> - ); -} - -export default ImageEditPopover; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/UploadImage/TabPanel.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/UploadImage/TabPanel.tsx deleted file mode 100644 index 9356a246aa..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/UploadImage/TabPanel.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import React from 'react'; - -export enum TAB_KEYS { - UPLOAD = 'upload', - LINK = 'link', -} - -interface TabPanelProps { - children?: React.ReactNode; - index: TAB_KEYS; - value: TAB_KEYS; -} - -export function TabPanel(props: TabPanelProps & React.HTMLAttributes<HTMLDivElement>) { - const { children, value, index, ...other } = props; - - return ( - <div - role='tabpanel' - hidden={value !== index} - id={`image-tabpanel-${index}`} - aria-labelledby={`image-tab-${index}`} - {...other} - > - {value === index && children} - </div> - ); -} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/UploadImage/UploadImage.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/UploadImage/UploadImage.tsx deleted file mode 100644 index 5990f5b5b9..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/UploadImage/UploadImage.tsx +++ /dev/null @@ -1,146 +0,0 @@ -import React, { useCallback, useEffect, useRef, useState } from 'react'; -import { ImageSvg } from '$app/components/_shared/svg/ImageSvg'; -import { CircularProgress } from '@mui/material'; -import { writeImage } from '$app/utils/document/image'; -import { useTranslation } from 'react-i18next'; -import { useMessage } from '$app/components/document/_shared/Message'; - -export interface UploadImageProps { - onChange: (filePath: string) => void; -} - -function UploadImage({ onChange }: UploadImageProps) { - const { t } = useTranslation(); - const message = useMessage(); - - const inputRef = useRef<HTMLInputElement>(null); - const [loading, setLoading] = useState<boolean>(false); - const [error, setError] = useState<string>(''); - const beforeUpload = useCallback( - (file: File) => { - // check file size and type - const sizeMatched = file.size / 1024 / 1024 < 5; // 5MB - const typeMatched = /image\/(png|jpg|jpeg|gif)/.test(file.type); // png, jpg, jpeg, gif - - if (!sizeMatched) { - setError(t('document.imageBlock.error.invalidImageSize')); - } - - if (!typeMatched) { - setError(t('document.imageBlock.error.invalidImageFormat')); - } - - return sizeMatched && typeMatched; - }, - [t] - ); - - useEffect(() => { - if (!error) return; - message.show({ - message: error, - duration: 3000, - type: 'error', - }); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [error]); - - const handleUpload = useCallback( - async (file: File) => { - if (!file) return; - if (!beforeUpload(file)) { - return; - } - - setError(''); - setLoading(true); - // upload to tauri local data dir - try { - const filePath = await writeImage(file); - - setLoading(false); - onChange(filePath); - } catch { - setLoading(false); - setError(t('document.imageBlock.error.invalidImage')); - } - }, - [beforeUpload, onChange, t] - ); - - const handleChange = useCallback( - (e: React.ChangeEvent<HTMLInputElement>) => { - const files = e.target.files; - - if (!files || files.length === 0) return; - const file = files[0]; - - void handleUpload(file); - }, - [handleUpload] - ); - - const handleDrop = useCallback( - (e: React.DragEvent<HTMLDivElement>) => { - e.preventDefault(); - const files = e.dataTransfer.files; - - if (!files || files.length === 0) return; - const file = files[0]; - - void handleUpload(file); - }, - [handleUpload] - ); - - const handleDragOver = useCallback((e: React.DragEvent<HTMLDivElement>) => { - e.preventDefault(); - }, []); - - const errorColor = error ? '#FB006D' : undefined; - - return ( - <div className={'flex flex-col px-5 pt-5'}> - <div - className={'flex-1 cursor-pointer'} - onClick={() => { - if (loading) return; - inputRef.current?.click(); - }} - tabIndex={0} - > - <input onChange={handleChange} ref={inputRef} type='file' className={'hidden'} accept={'image/*'} /> - <div - className={ - 'flex flex-col items-center justify-center rounded-md border border-dashed border-content-blue-300 bg-content-blue-50 py-10 text-content-blue-300' - } - style={{ - borderColor: errorColor, - background: error ? 'rgba(251, 0, 109, 0.08)' : undefined, - color: errorColor, - }} - onDrop={handleDrop} - onDragOver={handleDragOver} - > - <div className={'h-8 w-8'}> - <ImageSvg /> - </div> - <div className={'my-2 p-2'}>{t('document.imageBlock.upload.placeholder')}</div> - </div> - - {loading ? <CircularProgress /> : null} - </div> - <div - style={{ - color: errorColor, - }} - className={`mt-5 text-sm text-text-caption`} - > - {t('document.imageBlock.support')} - </div> - {message.contentHolder} - </div> - ); -} - -export default UploadImage; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/useBindArrowKey.ts b/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/useBindArrowKey.ts deleted file mode 100644 index 29aebe52e9..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/useBindArrowKey.ts +++ /dev/null @@ -1,96 +0,0 @@ -import { useCallback, useEffect, useState } from 'react'; -import { Keyboard } from '$app/constants/document/keyboard'; - -export const useBindArrowKey = ({ - options, - onLeft, - onRight, - onEnter, - onChange, - selectOption, -}: { - options: string[]; - onLeft?: () => void; - onRight?: () => void; - onEnter?: () => void; - onChange?: (key: string) => void; - selectOption?: string | null; -}) => { - const [isRun, setIsRun] = useState(false); - const onUp = useCallback(() => { - const getSelected = () => { - const index = options.findIndex((item) => item === selectOption); - - if (index === -1) return options[0]; - const length = options.length; - - return options[(index + length - 1) % length]; - }; - - onChange?.(getSelected()); - }, [onChange, options, selectOption]); - - const onDown = useCallback(() => { - const getSelected = () => { - const index = options.findIndex((item) => item === selectOption); - - if (index === -1) return options[0]; - const length = options.length; - - return options[(index + 1) % length]; - }; - - onChange?.(getSelected()); - }, [onChange, options, selectOption]); - - const handleArrowKey = useCallback( - (e: KeyboardEvent) => { - if ( - [Keyboard.keys.UP, Keyboard.keys.DOWN, Keyboard.keys.LEFT, Keyboard.keys.RIGHT, Keyboard.keys.ENTER].includes( - e.key - ) - ) { - e.stopPropagation(); - e.preventDefault(); - } - - if (e.key === Keyboard.keys.UP) { - onUp(); - } else if (e.key === Keyboard.keys.DOWN) { - onDown(); - } else if (e.key === Keyboard.keys.LEFT) { - onLeft?.(); - } else if (e.key === Keyboard.keys.RIGHT) { - onRight?.(); - } else if (e.key === Keyboard.keys.ENTER) { - onEnter?.(); - } - }, - [onDown, onEnter, onLeft, onRight, onUp] - ); - - const run = useCallback(() => { - setIsRun(true); - }, []); - - const stop = useCallback(() => { - setIsRun(false); - }, []); - - useEffect(() => { - if (isRun) { - document.addEventListener('keydown', handleArrowKey, true); - } else { - document.removeEventListener('keydown', handleArrowKey, true); - } - - return () => { - document.removeEventListener('keydown', handleArrowKey, true); - }; - }, [handleArrowKey, isRun]); - - return { - run, - stop, - }; -}; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/usePanelSearchText.ts b/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/usePanelSearchText.ts deleted file mode 100644 index 034919e838..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/usePanelSearchText.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { useCallback, useEffect, useRef, useState } from 'react'; -import Delta, { Op } from 'quill-delta'; -import { useSubscribeNode } from '$app/components/document/_shared/SubscribeNode.hooks'; -import { getDeltaText } from '$app/utils/document/delta'; - -export function useSubscribePanelSearchText({ blockId, open }: { blockId: string; open: boolean }) { - const [searchText, setSearchText] = useState<string>(''); - const beforeOpenDeltaRef = useRef<Op[]>([]); - const { delta: deltaStr } = useSubscribeNode(blockId); - const handleSearch = useCallback((newDelta: Delta) => { - const diff = new Delta(beforeOpenDeltaRef.current).diff(newDelta); - const text = getDeltaText(diff); - - setSearchText(text); - }, []); - - useEffect(() => { - if (!open || !deltaStr) return; - - handleSearch(new Delta(JSON.parse(deltaStr))); - }, [handleSearch, deltaStr, open]); - - useEffect(() => { - if (!open) { - beforeOpenDeltaRef.current = []; - return; - } - - if (beforeOpenDeltaRef.current.length > 0) return; - - const delta = new Delta(JSON.parse(deltaStr || "{}")); - - beforeOpenDeltaRef.current = delta.ops; - handleSearch(delta); - }, [deltaStr, handleSearch, open]); - - return { - searchText, - }; -} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/document_header/DocumentHeader.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/document/document_header/DocumentHeader.tsx new file mode 100644 index 0000000000..92744d0e95 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/document_header/DocumentHeader.tsx @@ -0,0 +1,47 @@ +import React, { useCallback } from 'react'; +import { PageIcon } from '$app_reducers/pages/slice'; +import { useAppDispatch, useAppSelector } from '$app/stores/store'; +import ViewTitle from '$app/components/_shared/ViewTitle'; +import { updatePageIcon, updatePageName } from '$app_reducers/pages/async_actions'; + +interface DocumentHeaderProps { + pageId: string; + onSplitTitle: (splitText: string) => void; +} + +export function DocumentHeader({ pageId, onSplitTitle }: DocumentHeaderProps) { + const page = useAppSelector((state) => state.pages.pageMap[pageId]); + const dispatch = useAppDispatch(); + const onTitleChange = useCallback( + (newTitle: string) => { + void dispatch( + updatePageName({ + id: pageId, + name: newTitle, + }) + ); + }, + [dispatch, pageId] + ); + + const onUpdateIcon = useCallback( + (icon: PageIcon) => { + void dispatch( + updatePageIcon({ + id: pageId, + icon: icon.value ? icon : undefined, + }) + ); + }, + [dispatch, pageId] + ); + + if (!page) return null; + return ( + <div className={'document-header px-16 py-4'}> + <ViewTitle onSplitTitle={onSplitTitle} onUpdateIcon={onUpdateIcon} onTitleChange={onTitleChange} view={page} /> + </div> + ); +} + +export default DocumentHeader; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/document_header/index.ts b/frontend/appflowy_tauri/src/appflowy_app/components/document/document_header/index.ts new file mode 100644 index 0000000000..00f48716bf --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/document_header/index.ts @@ -0,0 +1 @@ +export * from './DocumentHeader'; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/index.ts b/frontend/appflowy_tauri/src/appflowy_app/components/document/index.ts new file mode 100644 index 0000000000..a844aa51ad --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/index.ts @@ -0,0 +1 @@ +export * from './Document'; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/index.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/document/index.tsx deleted file mode 100644 index 06c477ac47..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/index.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import React from 'react'; -import { ContainerType, ContainerTypeProvider, useDocument } from '$app/hooks/document.hooks'; -import { DocumentControllerContext } from '$app/stores/effects/document/document_controller'; -import Root from '$app/components/document/Root'; - -interface Props { - documentId: string; - containerType?: ContainerType; - getDocumentTitle?: () => React.ReactNode; -} -function Document({ documentId, getDocumentTitle, containerType = ContainerType.DocumentPage }: Props) { - const { documentData, controller } = useDocument(documentId); - - if (!documentId || !documentData || !controller) return null; - return ( - <ContainerTypeProvider value={containerType}> - <DocumentControllerContext.Provider value={controller}> - <Root getDocumentTitle={getDocumentTitle} documentData={documentData} /> - </DocumentControllerContext.Provider> - </ContainerTypeProvider> - ); -} - -export default Document; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/Editor.hooks.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/Editor.hooks.ts new file mode 100644 index 0000000000..1fc25346d2 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/Editor.hooks.ts @@ -0,0 +1,9 @@ +import { createContext, useContext } from 'react'; + +export const EditorIdContext = createContext(''); + +export const EditorIdProvider = EditorIdContext.Provider; + +export function useEditorId() { + return useContext(EditorIdContext); +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/Editor.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/Editor.tsx new file mode 100644 index 0000000000..caed46c256 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/Editor.tsx @@ -0,0 +1,17 @@ +import React, { memo } from 'react'; +import { EditorProps } from '../../application/document/document.types'; + +import { Toaster } from 'react-hot-toast'; +import { CollaborativeEditor } from '$app/components/editor/components/editor'; +import { EditorIdProvider } from '$app/components/editor/Editor.hooks'; + +export function Editor(props: EditorProps) { + return ( + <EditorIdProvider value={props.id}> + <CollaborativeEditor {...props} /> + <Toaster /> + </EditorIdProvider> + ); +} + +export default memo(Editor); diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/application/notifications/index.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/application/notifications/index.ts deleted file mode 100644 index cb0ff5c3b5..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/application/notifications/index.ts +++ /dev/null @@ -1 +0,0 @@ -export {}; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/command/formula.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/command/formula.ts new file mode 100644 index 0000000000..0a92b7a95c --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/command/formula.ts @@ -0,0 +1,58 @@ +import { ReactEditor } from 'slate-react'; +import { Editor, Element as SlateElement, Range, Transforms } from 'slate'; +import { EditorInlineNodeType } from '$app/application/document/document.types'; + +export function insertFormula(editor: ReactEditor, formula?: string) { + if (editor.selection) { + wrapFormula(editor, formula); + } +} + +export function updateFormula(editor: ReactEditor, formula: string) { + if (isFormulaActive(editor)) { + Transforms.delete(editor); + insertFormula(editor, formula); + } +} + +export function wrapFormula(editor: ReactEditor, formula?: string) { + if (isFormulaActive(editor)) { + unwrapFormula(editor); + } + + const { selection } = editor; + const isCollapsed = selection && Range.isCollapsed(selection); + + const formulaElement = { + type: EditorInlineNodeType.Formula, + data: true, + children: isCollapsed + ? [ + { + text: formula || '', + }, + ] + : [], + }; + + if (isCollapsed) { + Transforms.insertNodes(editor, formulaElement); + } else { + Transforms.wrapNodes(editor, formulaElement, { split: true }); + Transforms.collapse(editor, { edge: 'end' }); + } +} + +export function unwrapFormula(editor: ReactEditor) { + Transforms.unwrapNodes(editor, { + match: (n) => !Editor.isEditor(n) && SlateElement.isElement(n) && n.type === EditorInlineNodeType.Formula, + }); +} + +export function isFormulaActive(editor: ReactEditor) { + const [node] = Editor.nodes(editor, { + match: (n) => !Editor.isEditor(n) && SlateElement.isElement(n) && n.type === EditorInlineNodeType.Formula, + }); + + return !!node; +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/command/index.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/command/index.ts new file mode 100644 index 0000000000..d3ab6499dd --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/command/index.ts @@ -0,0 +1,364 @@ +import { ReactEditor } from 'slate-react'; +import { Editor, Element, Node, NodeEntry, Transforms } from 'slate'; +import { LIST_TYPES, tabBackward, tabForward } from '$app/components/editor/command/tab'; +import { isMarkActive, toggleMark } from '$app/components/editor/command/mark'; +import { insertFormula, isFormulaActive, unwrapFormula, updateFormula } from '$app/components/editor/command/formula'; +import { + EditorInlineNodeType, + EditorNodeType, + CalloutNode, + Mention, + TodoListNode, + ToggleListNode, +} from '$app/application/document/document.types'; +import cloneDeep from 'lodash-es/cloneDeep'; +import { generateId } from '$app/components/editor/provider/utils/convert'; +import { YjsEditor } from '@slate-yjs/core'; + +export const CustomEditor = { + turnToBlock: (editor: ReactEditor, newProperties: Partial<Element>) => { + const selection = editor.selection; + + if (!selection) return; + const [match] = Editor.nodes(editor, { + match: (n) => !Editor.isEditor(n) && Element.isElement(n) && n.blockId !== undefined && n.type !== undefined, + }); + + if (!match) return; + + const [node, path] = match as NodeEntry<Element>; + + const parentId = node.parentId; + const cloneNode = { + ...cloneDeep(node), + blockId: generateId(), + textId: generateId(), + type: newProperties.type || EditorNodeType.Paragraph, + data: newProperties.data || {}, + }; + const isListType = LIST_TYPES.includes(cloneNode.type as EditorNodeType); + const extendId = isListType ? cloneNode.blockId : parentId; + const subordinates = CustomEditor.findNodeSubordinate(editor, node); + + Transforms.insertNodes(editor, cloneNode, { at: [path[0] + 1] }); + + subordinates.forEach((subordinate) => { + const subordinatePath = ReactEditor.findPath(editor, subordinate); + const level = subordinate.level ?? 2; + + const newProperties = { + level: isListType ? level : level - 1, + }; + + if (subordinate.parentId === node.blockId) { + Object.assign(newProperties, { + parentId: extendId, + }); + } + + Transforms.setNodes(editor, newProperties, { + at: [subordinatePath[0] + 1], + }); + }); + + Transforms.removeNodes(editor, { + at: path, + }); + + Transforms.select(editor, selection); + }, + tabForward, + tabBackward, + toggleMark, + isMarkActive, + isFormulaActive, + updateFormula, + toggleInlineElement: (editor: ReactEditor, format: EditorInlineNodeType) => { + if (format === EditorInlineNodeType.Formula) { + if (isFormulaActive(editor)) { + unwrapFormula(editor); + } else { + insertFormula(editor); + } + } + }, + isBlockActive(editor: ReactEditor, format?: string) { + const [match] = Editor.nodes(editor, { + match: (n) => !Editor.isEditor(n) && Element.isElement(n) && n.blockId !== undefined && n.type !== undefined, + }); + + if (format !== undefined) { + return match && (match[0] as Element).type === format; + } + + return !!match; + }, + insertMention(editor: ReactEditor, mention: Mention) { + const mentionElement = { + type: EditorInlineNodeType.Mention, + children: [{ text: '@' }], + data: { + ...mention, + }, + }; + + Transforms.insertNodes(editor, mentionElement); + Transforms.move(editor); + }, + + splitToParagraph(editor: ReactEditor) { + Transforms.splitNodes(editor, { always: true }); + Transforms.setNodes(editor, { type: EditorNodeType.Paragraph }); + }, + + findParentNode(editor: ReactEditor, node: Element) { + const parentId = node.parentId; + + if (!parentId) return null; + + return editor.children.find((child) => (child as Element).blockId === parentId) as Element; + }, + + findNodeSubordinate(editor: ReactEditor, node: Element) { + const index = editor.children.findIndex((child) => (child as Element).blockId === node.blockId); + + const level = node.level ?? 1; + const subordinateNodes: Element[] = []; + + if (index === editor.children.length - 1) return subordinateNodes; + + for (let i = index + 1; i < editor.children.length; i++) { + const nextNode = editor.children[i] as Element & { level: number }; + + if (nextNode.level > level) { + subordinateNodes.push(nextNode); + } else { + break; + } + } + + return subordinateNodes; + }, + + findNextNode(editor: ReactEditor, node: Element, level: number) { + const index = editor.children.findIndex((child) => (child as Element).blockId === node.blockId); + let nextIndex = -1; + + if (index === editor.children.length - 1) return null; + + for (let i = index + 1; i < editor.children.length; i++) { + const nextNode = editor.children[i] as Element & { level: number }; + + if (nextNode.level === level) { + nextIndex = i; + break; + } + + if (nextNode.level < level) break; + } + + const nextNode = editor.children[nextIndex] as Element & { level: number }; + + return nextNode; + }, + + toggleTodo(editor: ReactEditor, node: TodoListNode) { + const checked = node.data.checked; + const path = ReactEditor.findPath(editor, node); + const newProperties = { + data: { + checked: !checked, + }, + } as Partial<Element>; + + Transforms.setNodes(editor, newProperties, { at: path }); + }, + + toggleToggleList(editor: ReactEditor, node: ToggleListNode) { + if (!node.level) return; + const collapsed = !node.data.collapsed; + + const path = ReactEditor.findPath(editor, node); + const newProperties = { + data: { + collapsed, + }, + } as Partial<Element>; + + Transforms.select(editor, path); + Transforms.collapse(editor, { edge: 'end' }); + Transforms.setNodes(editor, newProperties, { at: path }); + + // hide or show the children + const index = path[0]; + + if (index === editor.children.length - 1) return; + + for (let i = index + 1; i < editor.children.length; i++) { + const nextNode = editor.children[i] as Element & { level: number }; + + if (nextNode.level === node.level) break; + if (nextNode.level > node.level) { + const nextPath = ReactEditor.findPath(editor, nextNode); + const nextProperties = { + isHidden: collapsed, + } as Partial<Element>; + + Transforms.setNodes(editor, nextProperties, { at: nextPath }); + } + } + }, + + setCalloutIcon(editor: ReactEditor, node: CalloutNode, newIcon: string) { + const path = ReactEditor.findPath(editor, node); + const newProperties = { + data: { + icon: newIcon, + }, + } as Partial<Element>; + + Transforms.setNodes(editor, newProperties, { at: path }); + }, + + setMathEquationBlockFormula(editor: ReactEditor, node: Element, newFormula: string) { + const path = ReactEditor.findPath(editor, node); + const newProperties = { + data: { + formula: newFormula, + }, + } as Partial<Element>; + + Transforms.setNodes(editor, newProperties, { at: path }); + }, + + setGridBlockViewId(editor: ReactEditor, node: Element, newViewId: string) { + const path = ReactEditor.findPath(editor, node); + const newProperties = { + data: { + viewId: newViewId, + }, + } as Partial<Element>; + + Transforms.setNodes(editor, newProperties, { at: path }); + }, + + findNodeChildren(editor: ReactEditor, node: Node) { + const nodeId = (node as Element).blockId; + + return editor.children.filter((child) => (child as Element).parentId === nodeId) as Element[]; + }, + + duplicateNode(editor: ReactEditor, node: Node) { + const children = CustomEditor.findNodeChildren(editor, node); + const newBlockId = generateId(); + const newTextId = generateId(); + const cloneNode = { + ...cloneDeep(node), + blockId: newBlockId, + textId: newTextId, + }; + + const cloneChildren = children.map((child) => { + const childBlockId = generateId(); + const childTextId = generateId(); + + return { + ...cloneDeep(child), + blockId: childBlockId, + textId: childTextId, + parentId: newBlockId, + }; + }); + + const path = ReactEditor.findPath(editor, node); + const endPath = children.length ? ReactEditor.findPath(editor, children[children.length - 1]) : null; + + Transforms.insertNodes(editor, [cloneNode, ...cloneChildren], { at: [endPath ? endPath[0] + 1 : path[0] + 1] }); + Transforms.move(editor); + }, + + deleteNode(editor: ReactEditor, node: Node) { + const children = CustomEditor.findNodeChildren(editor, node); + const path = ReactEditor.findPath(editor, node); + const endPath = children.length ? ReactEditor.findPath(editor, children[children.length - 1]) : null; + + Transforms.removeNodes(editor, { + at: { + anchor: { path, offset: 0 }, + focus: { path: endPath ?? path, offset: 0 }, + }, + }); + + Transforms.move(editor); + }, + + getBlockType: (editor: ReactEditor) => { + const [match] = Editor.nodes(editor, { + match: (n) => !Editor.isEditor(n) && Element.isElement(n) && n.blockId !== undefined && n.type !== undefined, + }); + + if (!match) return null; + + const [node] = match as NodeEntry<Element>; + + return node.type as EditorNodeType; + }, + + isGridBlock: (editor: ReactEditor) => { + return CustomEditor.getBlockType(editor) === EditorNodeType.GridBlock; + }, + + isCodeBlock: (editor: ReactEditor) => { + return CustomEditor.getBlockType(editor) === EditorNodeType.CodeBlock; + }, + + insertEmptyLineAtEnd: (editor: ReactEditor & YjsEditor) => { + editor.insertNode( + { + type: EditorNodeType.Paragraph, + level: 1, + data: {}, + blockId: generateId(), + textId: generateId(), + parentId: editor.sharedRoot.getAttribute('blockId'), + children: [{ text: '' }], + }, + { + select: true, + at: [editor.children.length], + } + ); + ReactEditor.focus(editor); + Transforms.move(editor); + }, + + insertLineAtStart: (editor: ReactEditor & YjsEditor, node: Element) => { + const blockId = generateId(); + const parentId = editor.sharedRoot.getAttribute('blockId'); + + ReactEditor.focus(editor); + editor.insertNode( + { + ...node, + blockId, + parentId, + textId: generateId(), + level: 1, + }, + { + at: [0], + } + ); + + editor.select({ + anchor: { + path: [0, 0], + offset: 0, + }, + focus: { + path: [0, 0], + offset: 0, + }, + }); + }, +}; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/command/mark.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/command/mark.ts new file mode 100644 index 0000000000..baa606be93 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/command/mark.ts @@ -0,0 +1,26 @@ +import { ReactEditor } from 'slate-react'; +import { Editor } from 'slate'; + +export function toggleMark( + editor: ReactEditor, + mark: { + key: string; + value: string | boolean; + } +) { + const { key, value } = mark; + + const isActive = isMarkActive(editor, key); + + if (isActive) { + Editor.removeMark(editor, key); + } else { + Editor.addMark(editor, key, value); + } +} + +export function isMarkActive(editor: ReactEditor, format: string) { + const marks = Editor.marks(editor) as Record<string, string | boolean> | null; + + return marks ? !!marks[format] : false; +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/command/tab.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/command/tab.ts new file mode 100644 index 0000000000..7727a7e6a9 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/command/tab.ts @@ -0,0 +1,137 @@ +import { Editor, Element, NodeEntry, Transforms } from 'slate'; +import { ReactEditor } from 'slate-react'; +import { EditorNodeType } from '$app/application/document/document.types'; +import { CustomEditor } from '$app/components/editor/command/index'; + +export const LIST_TYPES = [ + EditorNodeType.NumberedListBlock, + EditorNodeType.BulletedListBlock, + EditorNodeType.TodoListBlock, + EditorNodeType.ToggleListBlock, + EditorNodeType.QuoteBlock, + EditorNodeType.Paragraph, +]; + +const LIST_ITEM_TYPES = [ + EditorNodeType.NumberedListBlock, + EditorNodeType.BulletedListBlock, + EditorNodeType.TodoListBlock, + EditorNodeType.ToggleListBlock, + EditorNodeType.QuoteBlock, + EditorNodeType.Paragraph, + EditorNodeType.HeadingBlock, +]; + +/** + * Indent the current list item + * Conditions: + * 1. The current node must be a list item + * 2. The previous node must be a list + * 3. The previous node must be the same level as the current node + * Result: + * 1. The current node will be the child of the previous node + * 2. The current node will be indented + * 3. The children of the current node will be moved to the children of the previous node + * @param editor + */ +export function tabForward(editor: ReactEditor) { + const [match] = Editor.nodes(editor, { + match: (n) => !Editor.isEditor(n) && Element.isElement(n) && Editor.isBlock(editor, n), + }); + + if (!match) return; + + const [node, path] = match as NodeEntry<Element>; + + // the node is not a list item + if (!LIST_ITEM_TYPES.includes(node.type as EditorNodeType)) { + return; + } + + const previous = Editor.previous(editor, { + match: (n) => !Editor.isEditor(n) && Element.isElement(n) && Editor.isBlock(editor, n) && n.level === node.level, + at: path, + }); + + if (!previous) return; + + const [previousNode] = previous as NodeEntry<Element>; + + if (!previousNode) return; + const type = previousNode.type as EditorNodeType; + + // the previous node is not a list + if (!LIST_TYPES.includes(type)) return; + + const previousNodeLevel = previousNode.level; + + if (!previousNodeLevel) return; + + const newParentId = previousNode.blockId; + const children = CustomEditor.findNodeChildren(editor, node); + + children.forEach((child) => { + const childPath = ReactEditor.findPath(editor, child); + + Transforms.setNodes( + editor, + { + parentId: newParentId, + }, + { + at: childPath, + } + ); + }); + + const newProperties = { level: previousNodeLevel + 1, parentId: newParentId }; + + Transforms.setNodes(editor, newProperties); +} + +/** + * Outdent the current list item + * Conditions: + * 1. The current node must be a list item + * 2. The current node must be indented + * Result: + * 1. The current node will be the sibling of the parent node + * 2. The current node will be outdented + * 3. The children of the parent node will be moved to the children of the current node + * @param editor + */ +export function tabBackward(editor: ReactEditor) { + const [match] = Editor.nodes(editor, { + match: (n) => !Editor.isEditor(n) && Element.isElement(n) && Editor.isBlock(editor, n), + }); + + if (!match) return; + + const [node] = match as NodeEntry<Element & { level: number }>; + + const level = node.level; + + if (level === 1) return; + const parent = CustomEditor.findParentNode(editor, node); + + if (!parent) return; + + const newParentId = parent.parentId; + + if (!newParentId) return; + + const newProperties = { level: level - 1, parentId: newParentId }; + + const parentChildren = CustomEditor.findNodeChildren(editor, parent); + + const nodeIndex = parentChildren.findIndex((child) => child.blockId === node.blockId); + + Transforms.setNodes(editor, newProperties); + + for (let i = nodeIndex + 1; i < parentChildren.length; i++) { + const child = parentChildren[i]; + const childPath = ReactEditor.findPath(editor, child); + + Transforms.setNodes(editor, { parentId: node.blockId }, { at: childPath }); + } +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/_shared/Placeholder.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/_shared/Placeholder.tsx new file mode 100644 index 0000000000..a1f9f56613 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/_shared/Placeholder.tsx @@ -0,0 +1,65 @@ +import React, { CSSProperties, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Editor, Element, Range } from 'slate'; +import { useSelected, useSlate } from 'slate-react'; +import { EditorNodeType, HeadingNode } from '$app/application/document/document.types'; + +function Placeholder({ node, className, style }: { node: Element; className?: string; style?: CSSProperties }) { + const editor = useSlate(); + const { t } = useTranslation(); + const isEmpty = Editor.isEmpty(editor, node); + const selected = useSelected() && editor.selection && Range.isCollapsed(editor.selection); + + const unSelectedPlaceholder = useMemo(() => { + switch (node.type) { + case EditorNodeType.ToggleListBlock: + return t('document.plugins.toggleList'); + case EditorNodeType.QuoteBlock: + return t('editor.quote'); + case EditorNodeType.TodoListBlock: + return t('document.plugins.todoList'); + case EditorNodeType.NumberedListBlock: + return t('document.plugins.numberedList'); + case EditorNodeType.BulletedListBlock: + return t('document.plugins.bulletedList'); + case EditorNodeType.HeadingBlock: { + const level = (node as HeadingNode).data.level; + + switch (level) { + case 1: + return t('editor.mobileHeading1'); + case 2: + return t('editor.mobileHeading2'); + case 3: + return t('editor.mobileHeading3'); + default: + return ''; + } + } + + default: + return ''; + } + }, [node, t]); + + const selectedPlaceholder = useMemo(() => { + switch (node.type) { + case EditorNodeType.HeadingBlock: + return unSelectedPlaceholder; + default: + return t('editor.slashPlaceHolder'); + } + }, [node.type, t, unSelectedPlaceholder]); + + return isEmpty ? ( + <span + contentEditable={false} + style={style} + className={`pointer-events-none absolute left-0.5 top-0 whitespace-nowrap text-text-placeholder ${className}`} + > + {selected ? selectedPlaceholder : unSelectedPlaceholder} + </span> + ) : null; +} + +export default React.memo(Placeholder); diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/bulleted_list/BulletedList.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/bulleted_list/BulletedList.tsx new file mode 100644 index 0000000000..b58b203035 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/bulleted_list/BulletedList.tsx @@ -0,0 +1,19 @@ +import React, { forwardRef, memo } from 'react'; +import { EditorElementProps, BulletedListNode } from '$app/application/document/document.types'; +import Placeholder from '$app/components/editor/components/blocks/_shared/Placeholder'; + +export const BulletedList = memo( + forwardRef<HTMLDivElement, EditorElementProps<BulletedListNode>>(({ node, children, ...attributes }, ref) => { + return ( + <div {...attributes} className={`${attributes.className ?? ''} relative`} ref={ref}> + <span contentEditable={false} className={'pr-2 font-medium'}> + • + </span> + <span className={'relative'}> + <Placeholder node={node} /> + {children} + </span> + </div> + ); + }) +); diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/bulleted_list/index.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/bulleted_list/index.ts new file mode 100644 index 0000000000..2095dff308 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/bulleted_list/index.ts @@ -0,0 +1 @@ +export * from './BulletedList'; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/callout/Callout.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/callout/Callout.tsx new file mode 100644 index 0000000000..42e9f1983a --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/callout/Callout.tsx @@ -0,0 +1,20 @@ +import React, { forwardRef, memo } from 'react'; +import { EditorElementProps, CalloutNode } from '$app/application/document/document.types'; +import CalloutIcon from '$app/components/editor/components/blocks/callout/CalloutIcon'; + +export const Callout = memo( + forwardRef<HTMLDivElement, EditorElementProps<CalloutNode>>(({ node, children, ...attributes }, ref) => { + return ( + <div + {...attributes} + className={`${ + attributes.className ?? '' + } relative my-2 flex w-full items-start gap-3 rounded border border-solid border-line-divider bg-content-blue-50 p-2`} + ref={ref} + > + <CalloutIcon node={node} /> + <div className={'flex-1'}>{children}</div> + </div> + ); + }) +); diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/callout/CalloutIcon.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/callout/CalloutIcon.tsx new file mode 100644 index 0000000000..d092d39147 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/callout/CalloutIcon.tsx @@ -0,0 +1,60 @@ +import React, { useCallback, useRef, useState } from 'react'; +import { IconButton } from '@mui/material'; +import { CalloutNode } from '$app/application/document/document.types'; +import EmojiPicker from '$app/components/_shared/EmojiPicker'; +import Popover from '@mui/material/Popover'; +import { PopoverCommonProps } from '$app/components/editor/components/tools/popover'; +import { useSlateStatic } from 'slate-react'; +import { CustomEditor } from '$app/components/editor/command'; + +function CalloutIcon({ node }: { node: CalloutNode }) { + const ref = useRef<HTMLButtonElement>(null); + const [open, setOpen] = useState(false); + + const editor = useSlateStatic(); + const handleEmojiSelect = useCallback( + (emoji: string) => { + CustomEditor.setCalloutIcon(editor, node, emoji); + }, + [editor, node] + ); + + return ( + <> + <IconButton + contentEditable={false} + ref={ref} + onClick={() => { + setOpen(true); + }} + className={`p-1`} + > + {node.data.icon} + </IconButton> + {open && ( + <Popover + {...PopoverCommonProps} + className={'border-none bg-transparent shadow-none'} + anchorEl={ref.current} + disableAutoFocus={true} + open={open} + onClose={() => { + setOpen(false); + }} + anchorOrigin={{ + vertical: 'top', + horizontal: 'right', + }} + transformOrigin={{ + vertical: 'top', + horizontal: 'left', + }} + > + <EmojiPicker onEmojiSelect={handleEmojiSelect} /> + </Popover> + )} + </> + ); +} + +export default React.memo(CalloutIcon); diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/callout/index.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/callout/index.ts new file mode 100644 index 0000000000..4ca74e4be8 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/callout/index.ts @@ -0,0 +1 @@ +export * from './Callout'; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/code/Code.hooks.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/code/Code.hooks.ts new file mode 100644 index 0000000000..0b043f4579 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/code/Code.hooks.ts @@ -0,0 +1,27 @@ +import { useCallback } from 'react'; +import { ReactEditor, useSlateStatic } from 'slate-react'; +import { Element as SlateElement, Transforms } from 'slate'; +import { CodeNode } from '$app/application/document/document.types'; + +export function useCodeBlock(node: CodeNode) { + const language = node.data.language; + const editor = useSlateStatic() as ReactEditor; + const handleChangeLanguage = useCallback( + (newLang: string) => { + const path = ReactEditor.findPath(editor, node); + const newProperties = { + data: { + language: newLang, + }, + } as Partial<SlateElement>; + + Transforms.setNodes(editor, newProperties, { at: path }); + }, + [editor, node] + ); + + return { + language, + handleChangeLanguage, + }; +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/code/Code.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/code/Code.tsx new file mode 100644 index 0000000000..e807c3de08 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/code/Code.tsx @@ -0,0 +1,29 @@ +import { forwardRef, memo } from 'react'; +import { EditorElementProps, CodeNode } from '$app/application/document/document.types'; +import LanguageSelect from './SelectLanguage'; + +import { useCodeBlock } from '$app/components/editor/components/blocks/code/Code.hooks'; + +export const Code = memo( + forwardRef<HTMLDivElement, EditorElementProps<CodeNode>>(({ node, children, ...attributes }, ref) => { + const { language, handleChangeLanguage } = useCodeBlock(node); + + return ( + <div + {...attributes} + ref={ref} + className={`${ + attributes.className ?? '' + } my-2 w-full rounded border border-solid border-line-divider bg-content-blue-50 p-6`} + > + <div contentEditable={false} className={'mb-2 w-full'}> + <LanguageSelect language={language} onChangeLanguage={handleChangeLanguage} /> + </div> + + <pre className='code-block-element'> + <code>{children}</code> + </pre> + </div> + ); + }) +); diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/code/SelectLanguage.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/code/SelectLanguage.tsx new file mode 100644 index 0000000000..501304d287 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/code/SelectLanguage.tsx @@ -0,0 +1,37 @@ +import React from 'react'; +import MenuItem from '@mui/material/MenuItem'; +import FormControl from '@mui/material/FormControl'; +import Select, { SelectChangeEvent } from '@mui/material/Select'; +import { useTranslation } from 'react-i18next'; +import { supportLanguage } from './constants'; + +function SelectLanguage({ + language, + onChangeLanguage, +}: { + language: string; + onChangeLanguage: (language: string) => void; +}) { + const { t } = useTranslation(); + + return ( + <FormControl variant='standard'> + <Select + size={'small'} + className={'h-[28px] w-[150px]'} + value={language || 'javascript'} + onChange={(event: SelectChangeEvent) => onChangeLanguage(event.target.value)} + placeholder={t('document.codeBlock.language.placeholder')} + label={t('document.codeBlock.language.label')} + > + {supportLanguage.map((item) => ( + <MenuItem key={item.id} value={item.id}> + {item.title} + </MenuItem> + ))} + </Select> + </FormControl> + ); +} + +export default SelectLanguage; diff --git a/frontend/appflowy_tauri/src/appflowy_app/constants/document/code.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/code/constants.ts similarity index 100% rename from frontend/appflowy_tauri/src/appflowy_app/constants/document/code.ts rename to frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/code/constants.ts diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/code/index.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/code/index.ts new file mode 100644 index 0000000000..c3aa9443d1 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/code/index.ts @@ -0,0 +1 @@ +export * from './Code'; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SlateEditor/decorateCode.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/code/utils.ts similarity index 100% rename from frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SlateEditor/decorateCode.ts rename to frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/code/utils.ts diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/database/DatabaseEmpty.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/database/DatabaseEmpty.tsx new file mode 100644 index 0000000000..172e3784ad --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/database/DatabaseEmpty.tsx @@ -0,0 +1,51 @@ +import React, { useRef } from 'react'; +import CreateNewFolderIcon from '@mui/icons-material/CreateNewFolder'; + +import { GridNode } from '$app/application/document/document.types'; +import { useTranslation } from 'react-i18next'; + +import Drawer from '$app/components/editor/components/blocks/database/Drawer'; + +function DatabaseEmpty({ node }: { node: GridNode }) { + const { t } = useTranslation(); + const ref = useRef<HTMLDivElement>(null); + + const [open, setOpen] = React.useState(false); + + const toggleDrawer = (open: boolean) => (event: React.KeyboardEvent | React.MouseEvent) => { + if ( + event && + event.type === 'keydown' && + ((event as React.KeyboardEvent).key === 'Tab' || (event as React.KeyboardEvent).key === 'Shift') + ) { + return; + } + + if (event?.type === 'click') { + event.stopPropagation(); + } + + setOpen(open); + }; + + return ( + <div + ref={ref} + onClick={toggleDrawer(false)} + className='relative flex w-full flex-1 flex-col items-center justify-center text-text-caption' + > + <CreateNewFolderIcon className={'h-10 w-10'} /> + <div className={'mb-2 text-base'}>{t('document.plugins.database.noDataSource')}</div> + <div> + <span onClick={toggleDrawer(true)} className={'mx-2 cursor-pointer underline'}> + {t('document.plugins.database.selectADataSource')} + </span> + {t('document.plugins.database.toContinue')} + </div> + + <Drawer toggleDrawer={toggleDrawer} open={open} node={node} /> + </div> + ); +} + +export default React.memo(DatabaseEmpty); diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/DatabaseList/index.hooks.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/database/DatabaseList.hooks.ts similarity index 100% rename from frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/DatabaseList/index.hooks.ts rename to frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/database/DatabaseList.hooks.ts diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/database/DatabaseList.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/database/DatabaseList.tsx new file mode 100644 index 0000000000..d248fc851e --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/database/DatabaseList.tsx @@ -0,0 +1,111 @@ +import React, { useCallback, useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; +import { List, MenuItem, TextField } from '@mui/material'; +import { useLoadDatabaseList } from '$app/components/editor/components/blocks/database/DatabaseList.hooks'; +import { ViewLayoutPB } from '@/services/backend'; +import { ReactComponent as GridSvg } from '$app/assets/grid.svg'; +import { useSlateStatic } from 'slate-react'; +import { CustomEditor } from '$app/components/editor/command'; +import { GridNode } from '$app/application/document/document.types'; + +function DatabaseList({ node }: { node: GridNode }) { + const editor = useSlateStatic(); + const { t } = useTranslation(); + const [searchText, setSearchText] = React.useState<string>(''); + const [hovered, setHovered] = React.useState<string | null>(null); + const { list } = useLoadDatabaseList({ + searchText: searchText || '', + layout: ViewLayoutPB.Grid, + }); + + const handleSelected = useCallback( + (id: string) => { + CustomEditor.setGridBlockViewId(editor, node, id); + }, + [editor, node] + ); + + useEffect(() => { + if (list.length > 0) { + setHovered(list[0].id); + } + }, [list]); + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent<HTMLDivElement>) => { + const index = list.findIndex((item) => item.id === hovered); + const prevIndex = index - 1; + const nextIndex = index + 1; + + switch (e.key) { + case 'ArrowDown': + e.stopPropagation(); + e.preventDefault(); + if (nextIndex < list.length) { + setHovered(list[nextIndex].id); + } + + break; + case 'ArrowUp': + e.stopPropagation(); + e.preventDefault(); + if (prevIndex >= 0) { + setHovered(list[prevIndex].id); + } + + break; + case 'Enter': + e.stopPropagation(); + if (hovered) { + handleSelected(hovered); + } + + break; + } + }, + [handleSelected, hovered, list] + ); + + return ( + <div className={'relative overflow-y-auto overflow-x-hidden p-2'}> + <TextField + onKeyDown={handleKeyDown} + variant={'standard'} + autoFocus={true} + className={'sticky top-0 z-10 px-2'} + value={searchText} + onChange={(e) => { + setSearchText((e.currentTarget as HTMLInputElement).value); + }} + inputProps={{ + className: 'py-2 text-sm', + }} + placeholder={t('document.plugins.database.linkToDatabase')} + /> + <List> + {list.map((item) => { + return ( + <MenuItem + onMouseEnter={() => { + setHovered(item.id); + }} + selected={hovered === item.id} + key={item.id} + className={'flex items-center justify-between'} + onClick={() => { + handleSelected(item.id); + }} + > + <div className={'flex items-center text-text-title'}> + <GridSvg className={'mr-2 h-4 w-4'} /> + <div className={'truncate'}>{item.name || t('document.title.placeholder')}</div> + </div> + </MenuItem> + ); + })} + </List> + </div> + ); +} + +export default DatabaseList; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/database/Drawer.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/database/Drawer.tsx new file mode 100644 index 0000000000..e5377b9830 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/database/Drawer.tsx @@ -0,0 +1,64 @@ +import React, { useCallback } from 'react'; +import { Button, IconButton } from '@mui/material'; +import { useTranslation } from 'react-i18next'; +import { createGrid } from '$app/components/editor/components/blocks/database/utils'; +import { CustomEditor } from '$app/components/editor/command'; +import { useSlateStatic } from 'slate-react'; +import { useEditorId } from '$app/components/editor/Editor.hooks'; +import { GridNode } from '$app/application/document/document.types'; +import { ReactComponent as CloseSvg } from '$app/assets/close.svg'; +import { ReactComponent as AddSvg } from '$app/assets/add.svg'; +import DatabaseList from '$app/components/editor/components/blocks/database/DatabaseList'; + +function Drawer({ + open, + toggleDrawer, + node, +}: { + open: boolean; + toggleDrawer: (open: boolean) => (event: React.KeyboardEvent | React.MouseEvent) => void; + node: GridNode; +}) { + const editor = useSlateStatic(); + const id = useEditorId(); + const { t } = useTranslation(); + const handleCreateGrid = useCallback(async () => { + const gridId = await createGrid(id); + + CustomEditor.setGridBlockViewId(editor, node, gridId); + }, [id, editor, node]); + + return ( + <div + onClick={(e) => { + e.stopPropagation(); + }} + className={'absolute right-0 top-0 h-full transform overflow-hidden'} + style={{ + width: open ? '250px' : '0px', + transition: 'width 0.3s ease-in-out', + }} + > + <div className={'flex h-full w-[250px] flex-col border-l border-line-divider'}> + <div className={'flex h-[48px] w-full items-center justify-between p-2'}> + <div className={'px-2 font-medium'}>{t('document.plugins.database.selectDataSource')}</div> + <IconButton onClick={toggleDrawer(false)}> + <CloseSvg /> + </IconButton> + </div> + <div className={'flex-1'}>{open && <DatabaseList node={node} />}</div> + + <div + onClick={handleCreateGrid} + className={'sticky bottom-0 left-0 h-[48px] w-full border-t border-line-divider p-2'} + > + <Button color={'inherit'} className={'w-full justify-start'} startIcon={<AddSvg />}> + {t('document.plugins.database.newDatabase')} + </Button> + </div> + </div> + </div> + ); +} + +export default Drawer; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/database/GridBlock.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/database/GridBlock.tsx new file mode 100644 index 0000000000..dbc5a10586 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/database/GridBlock.tsx @@ -0,0 +1,25 @@ +import React, { forwardRef, memo } from 'react'; +import { EditorElementProps, GridNode } from '$app/application/document/document.types'; + +import GridView from '$app/components/editor/components/blocks/database/GridView'; +import DatabaseEmpty from '$app/components/editor/components/blocks/database/DatabaseEmpty'; + +export const GridBlock = memo( + forwardRef<HTMLDivElement, EditorElementProps<GridNode>>(({ node, children }, ref) => { + const viewId = node.data.viewId; + + return ( + <div + contentEditable={false} + className='relative flex h-[400px] overflow-hidden border-b border-t border-line-divider caret-text-title' + ref={ref} + > + {viewId ? <GridView viewId={viewId} /> : <DatabaseEmpty node={node} />} + + <div className={'invisible absolute'}>{children}</div> + </div> + ); + }) +); + +export default GridBlock; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/database/GridView.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/database/GridView.tsx new file mode 100644 index 0000000000..5b2303c126 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/database/GridView.tsx @@ -0,0 +1,15 @@ +import React, { useState } from 'react'; +import { Database } from '$app/components/database'; +import { ViewIdProvider } from '$app/hooks'; + +function GridView({ viewId }: { viewId: string }) { + const [selectedViewId, onChangeSelectedViewId] = useState(viewId); + + return ( + <ViewIdProvider value={viewId}> + <Database selectedViewId={selectedViewId} setSelectedViewId={onChangeSelectedViewId} /> + </ViewIdProvider> + ); +} + +export default React.memo(GridView); diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/database/index.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/database/index.ts new file mode 100644 index 0000000000..c1d8722a8d --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/database/index.ts @@ -0,0 +1,2 @@ +export * from './GridBlock'; +export * from './withDatabaseBlockPlugin'; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/database/utils.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/database/utils.ts new file mode 100644 index 0000000000..c56092f175 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/database/utils.ts @@ -0,0 +1,12 @@ +import { PageController } from '$app/stores/effects/workspace/page/page_controller'; +import { ViewLayoutPB } from '@/services/backend'; + +export async function createGrid(pageId: string) { + const pageController = new PageController(pageId); + const newViewId = await pageController.createPage({ + layout: ViewLayoutPB.Grid, + name: '', + }); + + return newViewId; +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/database/withDatabaseBlockPlugin.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/database/withDatabaseBlockPlugin.ts new file mode 100644 index 0000000000..634e308dcf --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/database/withDatabaseBlockPlugin.ts @@ -0,0 +1,20 @@ +import { ReactEditor } from 'slate-react'; +import { EditorNodeType } from '$app/application/document/document.types'; + +export function withDatabaseBlockPlugin(editor: ReactEditor) { + const { isElementReadOnly, isSelectable, isEmpty } = editor; + + editor.isElementReadOnly = (element) => { + return element.type === EditorNodeType.GridBlock || isElementReadOnly(element); + }; + + editor.isSelectable = (element) => { + return element.type !== EditorNodeType.GridBlock || isSelectable(element); + }; + + editor.isEmpty = (element) => { + return element.type !== EditorNodeType.GridBlock && isEmpty(element); + }; + + return editor; +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/divider/DividerNode.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/divider/DividerNode.tsx new file mode 100644 index 0000000000..2f6fd76656 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/divider/DividerNode.tsx @@ -0,0 +1,22 @@ +import React, { forwardRef, memo } from 'react'; +import { EditorElementProps, DividerNode as DividerNodeType } from '$app/application/document/document.types'; + +export const DividerNode = memo( + forwardRef<HTMLDivElement, EditorElementProps<DividerNodeType>>( + ({ node: _node, children: children, ...attributes }, ref) => { + return ( + <div + {...attributes} + ref={ref} + contentEditable={false} + className={`${attributes.className ?? ''} relative w-full`} + > + <div className={'w-full py-2.5 text-line-divider'}> + <hr /> + </div> + <span className={'absolute left-0 top-0 h-0 w-0 opacity-0'}>{children}</span> + </div> + ); + } + ) +); diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/divider/index.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/divider/index.ts new file mode 100644 index 0000000000..8f6141749a --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/divider/index.ts @@ -0,0 +1 @@ +export * from './DividerNode'; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/heading/Heading.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/heading/Heading.tsx new file mode 100644 index 0000000000..a49dbc4b25 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/heading/Heading.tsx @@ -0,0 +1,23 @@ +import React, { forwardRef, memo } from 'react'; +import { EditorElementProps, HeadingNode } from '$app/application/document/document.types'; +import Placeholder from '$app/components/editor/components/blocks/_shared/Placeholder'; +import { getHeadingCssProperty } from '$app/components/editor/plugins/utils'; + +export const Heading = memo( + forwardRef<HTMLDivElement, EditorElementProps<HeadingNode>>(({ node, children, ...attributes }, ref) => { + const { data } = node; + const { level } = data; + const fontSizeCssProperty = getHeadingCssProperty(level); + + return ( + <div + {...attributes} + ref={ref} + className={`${attributes.className ?? ''} leading-1 relative font-bold ${fontSizeCssProperty}`} + > + <Placeholder node={node} className={fontSizeCssProperty} /> + {children} + </div> + ); + }) +); diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/heading/index.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/heading/index.ts new file mode 100644 index 0000000000..6406e7b07f --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/heading/index.ts @@ -0,0 +1 @@ +export * from './Heading'; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/math_equation/EditPopover.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/math_equation/EditPopover.tsx new file mode 100644 index 0000000000..40b663a8c3 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/math_equation/EditPopover.tsx @@ -0,0 +1,85 @@ +import React, { useState } from 'react'; +import Popover from '@mui/material/Popover'; +import { PopoverCommonProps } from '$app/components/editor/components/tools/popover'; +import { TextareaAutosize } from '@mui/material'; +import Button from '@mui/material/Button'; +import { useTranslation } from 'react-i18next'; +import { CustomEditor } from '$app/components/editor/command'; +import { Element } from 'slate'; +import { KeyboardReturnOutlined } from '@mui/icons-material'; +import { useSlateStatic } from 'slate-react'; + +function EditPopover({ + open, + anchorEl, + onClose, + node, +}: { + open: boolean; + node: Element | null; + anchorEl: HTMLDivElement | null; + onClose: () => void; +}) { + const editor = useSlateStatic(); + + const { t } = useTranslation(); + const [value, setValue] = useState<string>(''); + const onInput = (event: React.FormEvent<HTMLTextAreaElement>) => { + setValue(event.currentTarget.value); + }; + + const handleDone = () => { + if (!node) return; + CustomEditor.setMathEquationBlockFormula(editor, node, value); + onClose(); + }; + + const handleEnter = (e: React.KeyboardEvent<HTMLTextAreaElement>) => { + const shift = e.shiftKey; + + // If shift is pressed, allow the user to enter a new line, otherwise close the popover + if (!shift && e.key === 'Enter') { + e.preventDefault(); + e.stopPropagation(); + handleDone(); + } + }; + + return ( + <Popover + {...PopoverCommonProps} + open={open} + anchorEl={anchorEl} + transformOrigin={{ + vertical: 'top', + horizontal: 'center', + }} + anchorOrigin={{ + vertical: 'bottom', + horizontal: 'center', + }} + onClose={onClose} + > + <div className={'flex flex-col gap-3 p-4'}> + <TextareaAutosize + className='w-full resize-none whitespace-break-spaces break-all rounded border p-2 text-sm' + autoFocus + autoCorrect='off' + value={value} + minRows={3} + onInput={onInput} + onKeyDown={handleEnter} + placeholder={`|x| = \\begin{cases} + x, &\\quad x \\geq 0 \\\\ + -x, &\\quad x < 0 +\\end{cases}`} + /> + <Button endIcon={<KeyboardReturnOutlined />} variant={'outlined'} onClick={handleDone}> + {t('button.done')} + </Button> + </div> + </Popover> + ); +} + +export default EditPopover; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/math_equation/MathEquation.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/math_equation/MathEquation.tsx new file mode 100644 index 0000000000..b78517485f --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/math_equation/MathEquation.tsx @@ -0,0 +1,53 @@ +import { forwardRef, memo, useState } from 'react'; +import { EditorElementProps, MathEquationNode } from '$app/application/document/document.types'; +import KatexMath from '$app/components/_shared/KatexMath'; +import { useTranslation } from 'react-i18next'; +import { FunctionsOutlined } from '@mui/icons-material'; +import EditPopover from '$app/components/editor/components/blocks/math_equation/EditPopover'; + +export const MathEquation = memo( + forwardRef<HTMLDivElement, EditorElementProps<MathEquationNode>>( + ({ node, children, className, ...attributes }, ref) => { + const formula = node.data.formula; + const { t } = useTranslation(); + const [anchorEl, setAnchorEl] = useState<HTMLDivElement | null>(null); + const open = Boolean(anchorEl); + + return ( + <> + <div + contentEditable={false} + ref={ref} + {...attributes} + onClick={(e) => { + setAnchorEl(e.currentTarget); + }} + className={`${ + className ?? '' + } relative cursor-pointer rounded border border-line-divider bg-content-blue-50 px-3 `} + > + {formula ? ( + <KatexMath latex={formula} /> + ) : ( + <div className={'relative flex h-[48px] w-full items-center gap-[10px] text-text-caption'}> + <FunctionsOutlined /> + {t('document.plugins.mathEquation.addMathEquation')} + </div> + )} + <div className={'invisible absolute'}>{children}</div> + </div> + {open && ( + <EditPopover + onClose={() => { + setAnchorEl(null); + }} + node={node} + open={open} + anchorEl={anchorEl} + /> + )} + </> + ); + } + ) +); diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/math_equation/index.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/math_equation/index.ts new file mode 100644 index 0000000000..ccf01a9ca9 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/math_equation/index.ts @@ -0,0 +1,2 @@ +export * from './withMathEquationPlugin'; +export * from './MathEquation'; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/math_equation/withMathEquationPlugin.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/math_equation/withMathEquationPlugin.ts new file mode 100644 index 0000000000..51601b325c --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/math_equation/withMathEquationPlugin.ts @@ -0,0 +1,20 @@ +import { ReactEditor } from 'slate-react'; +import { EditorNodeType } from '$app/application/document/document.types'; + +export function withMathEquationPlugin(editor: ReactEditor) { + const { isElementReadOnly, isSelectable, isEmpty } = editor; + + editor.isElementReadOnly = (element) => { + return element.type === EditorNodeType.EquationBlock || isElementReadOnly(element); + }; + + editor.isSelectable = (element) => { + return element.type !== EditorNodeType.EquationBlock || isSelectable(element); + }; + + editor.isEmpty = (element) => { + return element.type !== EditorNodeType.EquationBlock && isEmpty(element); + }; + + return editor; +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/numbered_list/NumberedList.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/numbered_list/NumberedList.tsx new file mode 100644 index 0000000000..485dc10353 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/numbered_list/NumberedList.tsx @@ -0,0 +1,51 @@ +import React, { forwardRef, memo, useMemo } from 'react'; +import { EditorElementProps, NumberedListNode } from '$app/application/document/document.types'; + +import { ReactEditor, useSlateStatic } from 'slate-react'; +import { Editor, Element } from 'slate'; +import Placeholder from '$app/components/editor/components/blocks/_shared/Placeholder'; + +export const NumberedList = memo( + forwardRef<HTMLDivElement, EditorElementProps<NumberedListNode>>(({ node, children, ...attributes }, ref) => { + const editor = useSlateStatic(); + + const index = useMemo(() => { + let index = 1; + const path = ReactEditor.findPath(editor, node); + + let prevEntry = Editor.previous(editor, { + at: path, + }); + + while (prevEntry) { + const prevNode = prevEntry[0]; + + if (Element.isElement(prevNode) && !Editor.isEditor(prevNode)) { + if (prevNode.type === node.type && prevNode.level === node.level) { + index += 1; + } else { + break; + } + } + + prevEntry = Editor.previous(editor, { + at: prevEntry[1], + }); + } + + return index; + }, [editor, node]); + + return ( + <div {...attributes} className={`${attributes.className ?? ''} relative`} ref={ref}> + <span contentEditable={false} className={'pr-2 font-medium'}> + {index}. + </span> + <span className={'relative'}> + <Placeholder node={node} /> + {children} + </span> + </div> + ); + }) +); diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/numbered_list/index.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/numbered_list/index.ts new file mode 100644 index 0000000000..6e985ae25b --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/numbered_list/index.ts @@ -0,0 +1 @@ +export * from './NumberedList'; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/paragraph/Paragraph.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/paragraph/Paragraph.tsx new file mode 100644 index 0000000000..dce018efb9 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/paragraph/Paragraph.tsx @@ -0,0 +1,18 @@ +import React, { forwardRef, memo } from 'react'; +import Placeholder from '$app/components/editor/components/blocks/_shared/Placeholder'; +import { EditorElementProps, ParagraphNode } from '$app/application/document/document.types'; + +export const Paragraph = memo( + forwardRef<HTMLDivElement, EditorElementProps<ParagraphNode>>(({ node, children, ...attributes }, ref) => { + { + return ( + <div ref={ref} {...attributes} className={`${attributes.className ?? ''}`}> + <span className={'relative'}> + <Placeholder node={node} /> + {children} + </span> + </div> + ); + } + }) +); diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/paragraph/index.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/paragraph/index.ts new file mode 100644 index 0000000000..01752c914c --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/paragraph/index.ts @@ -0,0 +1 @@ +export * from './Paragraph'; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/quote/Quote.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/quote/Quote.tsx new file mode 100644 index 0000000000..3e7f4deb80 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/quote/Quote.tsx @@ -0,0 +1,20 @@ +import React, { forwardRef, memo, useMemo } from 'react'; +import { EditorElementProps, QuoteNode } from '$app/application/document/document.types'; +import Placeholder from '$app/components/editor/components/blocks/_shared/Placeholder'; + +export const QuoteList = memo( + forwardRef<HTMLDivElement, EditorElementProps<QuoteNode>>(({ node, children, ...attributes }, ref) => { + const className = useMemo(() => { + return `${attributes.className ?? ''} relative border-l-4 border-fill-default`; + }, [attributes.className]); + + return ( + <div {...attributes} ref={ref} className={className}> + <span className={'relative left-2'}> + <Placeholder node={node} /> + {children} + </span> + </div> + ); + }) +); diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/quote/index.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/quote/index.ts new file mode 100644 index 0000000000..c88e677a53 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/quote/index.ts @@ -0,0 +1 @@ +export * from './Quote'; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/todo_list/TodoList.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/todo_list/TodoList.tsx new file mode 100644 index 0000000000..d5411c3e45 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/todo_list/TodoList.tsx @@ -0,0 +1,38 @@ +import React, { forwardRef, memo, useCallback, useMemo } from 'react'; +import { EditorElementProps, TodoListNode } from '$app/application/document/document.types'; +import { ReactComponent as CheckboxCheckSvg } from '$app/assets/database/checkbox-check.svg'; +import { ReactComponent as CheckboxUncheckSvg } from '$app/assets/database/checkbox-uncheck.svg'; +import { useSlateStatic } from 'slate-react'; +import Placeholder from '$app/components/editor/components/blocks/_shared/Placeholder'; +import { CustomEditor } from '$app/components/editor/command'; + +export const TodoList = memo( + forwardRef<HTMLDivElement, EditorElementProps<TodoListNode>>(({ node, children, ...attributes }, ref) => { + const { checked } = node.data; + const editor = useSlateStatic(); + const className = useMemo(() => { + return `relative ${attributes.className ?? ''}`; + }, [attributes.className]); + const toggleTodo = useCallback(() => { + CustomEditor.toggleTodo(editor, node); + }, [editor, node]); + + return ( + <div {...attributes} ref={ref} className={className}> + <span + data-playwright-selected={false} + contentEditable={false} + onClick={toggleTodo} + className='absolute left-0 top-0 inline-flex cursor-pointer text-xl text-fill-default' + > + {checked ? <CheckboxCheckSvg /> : <CheckboxUncheckSvg />} + </span> + + <span className={`relative ml-6 ${checked ? 'text-text-caption line-through' : ''}`}> + <Placeholder node={node} /> + {children} + </span> + </div> + ); + }) +); diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/todo_list/index.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/todo_list/index.ts new file mode 100644 index 0000000000..f239f43459 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/todo_list/index.ts @@ -0,0 +1 @@ +export * from './TodoList'; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/toggle_list/ToggleList.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/toggle_list/ToggleList.tsx new file mode 100644 index 0000000000..c998af7f5b --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/toggle_list/ToggleList.tsx @@ -0,0 +1,36 @@ +import React, { forwardRef, memo, useCallback, useMemo } from 'react'; +import { EditorElementProps, ToggleListNode } from '$app/application/document/document.types'; +import { ReactEditor, useSlateStatic } from 'slate-react'; +import { ReactComponent as RightSvg } from '$app/assets/more.svg'; +import Placeholder from '$app/components/editor/components/blocks/_shared/Placeholder'; +import { CustomEditor } from '$app/components/editor/command'; + +export const ToggleList = memo( + forwardRef<HTMLDivElement, EditorElementProps<ToggleListNode>>(({ node, children, ...attributes }, ref) => { + const { collapsed } = node.data; + const editor = useSlateStatic() as ReactEditor; + const className = useMemo(() => { + return `relative ${attributes.className ?? ''}`; + }, [attributes.className]); + const toggleToggleList = useCallback(() => { + CustomEditor.toggleToggleList(editor, node); + }, [editor, node]); + + return ( + <div {...attributes} ref={ref} className={className}> + <span + contentEditable={false} + onClick={toggleToggleList} + className='absolute left-0 top-0 inline-block cursor-pointer rounded text-xl text-text-title hover:bg-fill-list-hover' + > + {collapsed ? <RightSvg /> : <RightSvg className={'rotate-90 transform'} />} + </span> + <span className={'z-1 relative ml-6'}> + <Placeholder node={node} /> + + {children} + </span> + </div> + ); + }) +); diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/toggle_list/index.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/toggle_list/index.ts new file mode 100644 index 0000000000..833bdb5210 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/toggle_list/index.ts @@ -0,0 +1 @@ +export * from './ToggleList'; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/editor/CollaborativeEditor.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/editor/CollaborativeEditor.tsx new file mode 100644 index 0000000000..1ef7a664b8 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/editor/CollaborativeEditor.tsx @@ -0,0 +1,34 @@ +import { useEffect, useMemo, useState } from 'react'; + +import Editor from '$app/components/editor/components/editor/Editor'; +import { EditorProps } from '$app/application/document/document.types'; +import { Provider } from '$app/components/editor/provider'; +import { YXmlText } from 'yjs/dist/src/types/YXmlText'; + +export const CollaborativeEditor = (props: EditorProps) => { + const [sharedType, setSharedType] = useState<YXmlText | null>(null); + const provider = useMemo(() => { + setSharedType(null); + return new Provider(props.id); + }, [props.id]); + + useEffect(() => { + provider.connect(); + const handleConnected = () => { + setSharedType(provider.sharedType); + }; + + provider.on('ready', handleConnected); + return () => { + setSharedType(null); + provider.off('ready', handleConnected); + provider.disconnect(); + }; + }, [provider]); + + if (!sharedType || props.id !== provider.id) { + return null; + } + + return <Editor {...props} sharedType={sharedType || undefined} />; +}; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/editor/CustomEditable.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/editor/CustomEditable.tsx new file mode 100644 index 0000000000..56e847a810 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/editor/CustomEditable.tsx @@ -0,0 +1,32 @@ +import React, { ComponentProps } from 'react'; +import { Editable, ReactEditor, useSlate } from 'slate-react'; +import Element from './Element'; +import { Leaf } from './Leaf'; +import { useTranslation } from 'react-i18next'; + +type CustomEditableProps = Omit<ComponentProps<typeof Editable>, 'renderElement' | 'renderLeaf'> & + Partial<Pick<ComponentProps<typeof Editable>, 'renderElement' | 'renderLeaf'>>; + +export function CustomEditable({ renderElement = Element, renderLeaf = Leaf, ...props }: CustomEditableProps) { + const editor = useSlate(); + const { t } = useTranslation(); + + return ( + <Editable + {...props} + placeholder={t('editor.slashPlaceHolder')} + renderPlaceholder={({ attributes, children }) => { + const focused = ReactEditor.isFocused(editor); + + if (focused) return <></>; + return ( + <div {...attributes} className={`h-full whitespace-nowrap`}> + <div className={'flex h-full items-center pl-1'}>{children}</div> + </div> + ); + }} + renderElement={renderElement} + renderLeaf={renderLeaf} + /> + ); +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/editor/Editor.hooks.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/editor/Editor.hooks.ts new file mode 100644 index 0000000000..4156fd4f12 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/editor/Editor.hooks.ts @@ -0,0 +1,140 @@ +import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'; +import { EditorNodeType, CodeNode } from '$app/application/document/document.types'; + +import { createEditor, NodeEntry, BaseRange, Editor, Transforms, Element } from 'slate'; +import { ReactEditor, withReact } from 'slate-react'; +import { withBlockPlugins } from '$app/components/editor/plugins/withBlockPlugins'; +import { decorateCode } from '$app/components/editor/components/blocks/code/utils'; +import { withShortcuts } from '$app/components/editor/components/editor/shortcuts'; +import { withInlines } from '$app/components/editor/components/inline_nodes'; +import { withYjs, YjsEditor, withYHistory } from '@slate-yjs/core'; +import * as Y from 'yjs'; +import { CustomEditor } from '$app/components/editor/command'; + +export function useEditor(sharedType?: Y.XmlText) { + const editor = useMemo(() => { + if (!sharedType) return null; + const e = withShortcuts(withBlockPlugins(withInlines(withReact(withYHistory(withYjs(createEditor(), sharedType)))))); + + // Ensure editor always has at least 1 valid child + const { normalizeNode } = e; + + e.normalizeNode = (entry) => { + const [node] = entry; + + if (!Editor.isEditor(node) || node.children.length > 0) { + return normalizeNode(entry); + } + + Transforms.insertNodes( + e, + [ + { + type: EditorNodeType.Paragraph, + children: [{ text: '' }], + }, + ], + { at: [0] } + ); + }; + + return e; + }, [sharedType]) as ReactEditor & YjsEditor; + + const initialValue = useMemo(() => { + return []; + }, []); + + // Connect editor in useEffect to comply with concurrent mode requirements. + useEffect(() => { + YjsEditor.connect(editor); + return () => { + YjsEditor.disconnect(editor); + }; + }, [editor]); + + const handleOnClickEnd = useCallback(() => { + CustomEditor.insertEmptyLineAtEnd(editor); + }, [editor]); + + return { + editor, + initialValue, + handleOnClickEnd, + }; +} + +export function useDecorate(editor: ReactEditor) { + return useCallback( + (entry: NodeEntry): BaseRange[] => { + const path = entry[1]; + + const blockEntry = path.length > 1 ? editor.node([path[0]]) : editor.node(path); + + const block = blockEntry[0] as CodeNode; + + if (block.type === EditorNodeType.CodeBlock) { + const language = block.data.language; + + return decorateCode(entry, language, false); + } + + return []; + }, + [editor] + ); +} + +export const EditorSelectedBlockContext = createContext<string[]>([]); + +export function useSelectedBlock(blockId?: string) { + const blockIds = useContext(EditorSelectedBlockContext); + + if (blockId === undefined) { + return false; + } + + return blockIds.includes(blockId); +} + +export const EditorSelectedBlockProvider = EditorSelectedBlockContext.Provider; + +export function useEditorSelectedBlock(editor: ReactEditor) { + const [selectedBlockId, setSelectedBlockId] = useState<string[]>([]); + const onSelectedBlock = useCallback( + (blockId: string) => { + const children = editor.children.filter((node) => (node as Element).parentId === blockId); + const blockIds = [blockId, ...children.map((node) => (node as Element).blockId as string)]; + const node = editor.children.find((node) => (node as Element).blockId === blockId); + + if (node) { + const path = ReactEditor.findPath(editor, node); + + ReactEditor.focus(editor); + editor.select(path); + editor.collapse({ + edge: 'start', + }); + } + + setSelectedBlockId(blockIds); + }, + [editor] + ); + + useEffect(() => { + const handleClick = () => { + if (selectedBlockId.length === 0) return; + setSelectedBlockId([]); + }; + + document.addEventListener('click', handleClick); + return () => { + document.removeEventListener('click', handleClick); + }; + }, [selectedBlockId]); + return { + selectedBlockId, + onSelectedBlock, + }; +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/editor/Editor.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/editor/Editor.tsx new file mode 100644 index 0000000000..90d6e49067 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/editor/Editor.tsx @@ -0,0 +1,64 @@ +import React, { useEffect } from 'react'; +import { + EditorSelectedBlockProvider, + useDecorate, + useEditor, + useEditorSelectedBlock, +} from '$app/components/editor/components/editor/Editor.hooks'; +import { Slate } from 'slate-react'; +import { CustomEditable } from '$app/components/editor/components/editor/CustomEditable'; +import { EditorNodeType, EditorProps } from '$app/application/document/document.types'; +import { SelectionToolbar } from '$app/components/editor/components/tools/selection_toolbar'; +import { useShortcuts } from '$app/components/editor/components/editor/shortcuts'; +import { BlockActionsToolbar } from '$app/components/editor/components/tools/block_actions'; +import { SlashCommandPanel } from '$app/components/editor/components/tools/command_panel/slash_command_panel'; +import { MentionPanel } from '$app/components/editor/components/tools/command_panel/mention_panel'; +import { CircularProgress } from '@mui/material'; +import { CustomEditor } from '$app/components/editor/command'; + +function Editor({ sharedType, appendTextRef }: EditorProps) { + const { editor, initialValue, handleOnClickEnd, ...props } = useEditor(sharedType); + const decorate = useDecorate(editor); + const { onDOMBeforeInput, onKeyDown: onShortcutsKeyDown } = useShortcuts(editor); + + useEffect(() => { + if (!appendTextRef) return; + appendTextRef.current = (text: string) => { + CustomEditor.insertLineAtStart(editor, { + type: EditorNodeType.Paragraph, + children: [{ text }], + }); + }; + + return () => { + appendTextRef.current = null; + }; + }, [appendTextRef, editor]); + + const { onSelectedBlock, selectedBlockId } = useEditorSelectedBlock(editor); + + if (editor.sharedRoot.length === 0) { + return <CircularProgress className='m-auto' />; + } + + return ( + <EditorSelectedBlockProvider value={selectedBlockId}> + <Slate editor={editor} initialValue={initialValue}> + <SelectionToolbar /> + <BlockActionsToolbar onSelectedBlock={onSelectedBlock} /> + <CustomEditable + {...props} + onDOMBeforeInput={onDOMBeforeInput} + onKeyDown={onShortcutsKeyDown} + decorate={decorate} + className={'caret-text-title outline-none focus:outline-none'} + /> + <SlashCommandPanel /> + <MentionPanel /> + <div onClick={handleOnClickEnd} className={'relative bottom-0 left-0 h-10 w-full cursor-text'} /> + </Slate> + </EditorSelectedBlockProvider> + ); +} + +export default Editor; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/editor/Element.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/editor/Element.tsx new file mode 100644 index 0000000000..8217210d58 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/editor/Element.tsx @@ -0,0 +1,96 @@ +import React, { FC, HTMLAttributes, useMemo } from 'react'; +import { RenderElementProps } from 'slate-react'; +import { EditorElementProps, EditorInlineNodeType, EditorNodeType } from '$app/application/document/document.types'; +import { Paragraph } from '$app/components/editor/components/blocks/paragraph'; +import { Heading } from '$app/components/editor/components/blocks/heading'; +import { TodoList } from '$app/components/editor/components/blocks/todo_list'; +import { Code } from '$app/components/editor/components/blocks/code'; +import { QuoteList } from '$app/components/editor/components/blocks/quote'; +import { NumberedList } from '$app/components/editor/components/blocks/numbered_list'; +import { BulletedList } from '$app/components/editor/components/blocks/bulleted_list'; +import { DividerNode } from '$app/components/editor/components/blocks/divider'; +import { InlineFormula } from '$app/components/editor/components/inline_nodes/inline_formula'; +import { ToggleList } from '$app/components/editor/components/blocks/toggle_list'; +import { Callout } from '$app/components/editor/components/blocks/callout'; +import { Mention } from '$app/components/editor/components/inline_nodes/mention'; +import { GridBlock } from '$app/components/editor/components/blocks/database'; +import { MathEquation } from '$app/components/editor/components/blocks/math_equation'; +import { useSelectedBlock } from '$app/components/editor/components/editor/Editor.hooks'; + +function Element({ element, attributes, children }: RenderElementProps) { + const node = element; + + const InlineComponent = useMemo(() => { + switch (node.type) { + case EditorInlineNodeType.Formula: + return InlineFormula; + case EditorInlineNodeType.Mention: + return Mention; + default: + return null; + } + }, [node.type]) as FC<EditorElementProps>; + + const Component = useMemo(() => { + switch (node.type) { + case EditorNodeType.HeadingBlock: + return Heading; + case EditorNodeType.TodoListBlock: + return TodoList; + case EditorNodeType.Paragraph: + return Paragraph; + case EditorNodeType.CodeBlock: + return Code; + case EditorNodeType.QuoteBlock: + return QuoteList; + case EditorNodeType.NumberedListBlock: + return NumberedList; + case EditorNodeType.BulletedListBlock: + return BulletedList; + case EditorNodeType.DividerBlock: + return DividerNode; + case EditorNodeType.ToggleListBlock: + return ToggleList; + case EditorNodeType.CalloutBlock: + return Callout; + case EditorNodeType.GridBlock: + return GridBlock; + case EditorNodeType.EquationBlock: + return MathEquation; + default: + return Paragraph; + } + }, [node.type]) as FC<EditorElementProps & HTMLAttributes<HTMLElement>>; + + const marginLeft = useMemo(() => { + if (!node.level) return; + + return (node.level - 1) * 24; + }, [node.level]); + + const isSelected = useSelectedBlock(node.blockId); + + if (InlineComponent) { + return ( + <span {...attributes}> + <InlineComponent node={node}>{children}</InlineComponent> + </span> + ); + } + + return ( + <div + {...attributes} + style={{ + marginLeft, + }} + className={`${node.isHidden ? 'hidden' : 'inline-block'} block-element leading-1 my-0.5 w-full px-16`} + > + <Component className={`${isSelected ? 'bg-content-blue-100' : ''}`} node={node}> + {children} + </Component> + </div> + ); +} + +export default Element; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/editor/Leaf.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/editor/Leaf.tsx new file mode 100644 index 0000000000..9e36d93e13 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/editor/Leaf.tsx @@ -0,0 +1,53 @@ +import React, { CSSProperties } from 'react'; +import { RenderLeafProps } from 'slate-react'; +import { Link } from '$app/components/editor/components/marks'; + +export function Leaf({ attributes, children, leaf }: RenderLeafProps) { + let newChildren = children; + + const classList = [leaf.prism_token, leaf.prism_token && 'token'].filter(Boolean); + + if (leaf.code) { + newChildren = ( + <code className={'mx-0.5 rounded-sm bg-gray-300 bg-opacity-50 px-1 text-xs font-normal text-[#EB5757]'}> + {newChildren} + </code> + ); + } + + if (leaf.underline) { + newChildren = <u>{newChildren}</u>; + } + + if (leaf.strikethrough) { + newChildren = <s>{newChildren}</s>; + } + + if (leaf.italic) { + newChildren = <em>{newChildren}</em>; + } + + if (leaf.bold) { + newChildren = <strong>{newChildren}</strong>; + } + + const style: CSSProperties = {}; + + if (leaf.font_color) { + style['color'] = leaf.font_color.replace('0x', '#'); + } + + if (leaf.bg_color) { + style['backgroundColor'] = leaf.bg_color.replace('0x', '#'); + } + + if (leaf.href) { + newChildren = <Link leaf={leaf}>{newChildren}</Link>; + } + + return ( + <span {...attributes} style={style} className={`${classList.join(' ')}`}> + {newChildren} + </span> + ); +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/editor/index.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/editor/index.ts new file mode 100644 index 0000000000..1a0dfa04d5 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/editor/index.ts @@ -0,0 +1,2 @@ +export * from './CollaborativeEditor'; +export * from './Editor'; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/editor/shortcuts/index.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/editor/shortcuts/index.ts new file mode 100644 index 0000000000..7cfd550743 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/editor/shortcuts/index.ts @@ -0,0 +1,2 @@ +export * from './shortcuts.hooks'; +export * from './withShortcuts'; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/editor/shortcuts/shortcuts.hooks.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/editor/shortcuts/shortcuts.hooks.ts new file mode 100644 index 0000000000..af90e768dc --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/editor/shortcuts/shortcuts.hooks.ts @@ -0,0 +1,92 @@ +import { ReactEditor } from 'slate-react'; +import { useCallback, KeyboardEvent } from 'react'; +import { EditorMarkFormat, EditorNodeType } from '$app/application/document/document.types'; +import isHotkey from 'is-hotkey'; + +import { getBlock } from '$app/components/editor/plugins/utils'; +import { SOFT_BREAK_TYPES } from '$app/components/editor/plugins/constants'; +import { CustomEditor } from '$app/components/editor/command'; + +const inputTypeToFormat: Record<string, EditorMarkFormat> = { + formatBold: EditorMarkFormat.Bold, + formatItalic: EditorMarkFormat.Italic, + formatUnderline: EditorMarkFormat.Underline, + formatStrikethrough: EditorMarkFormat.StrikeThrough, + formatCode: EditorMarkFormat.Code, +}; + +const hotKeys = { + formatBold: 'Mod+b', + formatItalic: 'Mod+i', + formatUnderline: 'Mod+u', + formatStrikethrough: 'Mod+Shift+s', + formatCode: 'Mod+Shift+c', +}; + +export function useShortcuts(editor: ReactEditor) { + const onDOMBeforeInput = useCallback( + (e: InputEvent) => { + const inputType = e.inputType; + + const format = inputTypeToFormat[inputType]; + + if (format) { + e.preventDefault(); + return CustomEditor.toggleMark(editor, { + key: format, + value: true, + }); + } + }, + [editor] + ); + + const onKeyDown = useCallback( + (e: KeyboardEvent<HTMLDivElement>) => { + const isAppleWebkit = navigator.userAgent.includes('AppleWebKit'); + + // Apple Webkit does not support the input event for formatting + if (isAppleWebkit) { + Object.entries(hotKeys).forEach(([key, hotkey]) => { + if (isHotkey(hotkey, e)) { + e.preventDefault(); + CustomEditor.toggleMark(editor, { + key: inputTypeToFormat[key], + value: true, + }); + } + }); + } + + if (isHotkey('Tab', e)) { + e.preventDefault(); + return CustomEditor.tabForward(editor); + } + + if (isHotkey('shift+Tab', e)) { + e.preventDefault(); + return CustomEditor.tabBackward(editor); + } + + const node = getBlock(editor); + + if (isHotkey('shift+Enter', e) && node) { + if (SOFT_BREAK_TYPES.includes(node.type as EditorNodeType)) { + e.preventDefault(); + CustomEditor.splitToParagraph(editor); + } else if (node.type === EditorNodeType.Paragraph) { + e.preventDefault(); + editor.insertText('\n'); + } + + return; + } + }, + [editor] + ); + + return { + onDOMBeforeInput, + onKeyDown, + }; +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/editor/shortcuts/withCommandShortcuts.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/editor/shortcuts/withCommandShortcuts.ts new file mode 100644 index 0000000000..6bd7afdbbb --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/editor/shortcuts/withCommandShortcuts.ts @@ -0,0 +1,99 @@ +import { ReactEditor } from 'slate-react'; +import { Editor, Range } from 'slate'; +import { getBlockEntry, isDeleteBackwardAtStartOfBlock } from '$app/components/editor/plugins/utils'; +import { EditorNodeType } from '$app/application/document/document.types'; +import { CustomEditor } from '$app/components/editor/command'; + +export enum EditorCommand { + Mention = '@', + SlashCommand = '/', +} + +// pop mention panel when @ is typed +// pop slash command panel when / is typed +const commands = [EditorCommand.Mention, EditorCommand.SlashCommand] as string[]; + +export const commandPanelClsSelector: Record<string, string> = { + [EditorCommand.Mention]: '.mention-panel', + [EditorCommand.SlashCommand]: '.slash-command-panel', +}; + +export const commandPanelShowProperty = 'is-show'; + +export function withCommandShortcuts(editor: ReactEditor) { + const { insertText, deleteBackward } = editor; + + editor.insertText = (text) => { + if (CustomEditor.isCodeBlock(editor)) { + insertText(text); + return; + } + + const { selection } = editor; + + const endOfPanelChar = commands.find((char) => { + return text.endsWith(char); + }); + + if (endOfPanelChar !== undefined && selection && Range.isCollapsed(selection)) { + const block = getBlockEntry(editor); + const path = block ? block[1] : []; + const { anchor } = selection; + const beforeText = Editor.string(editor, { anchor, focus: Editor.start(editor, path) }) + text.slice(0, -1); + // show the panel when insert char at after space or at start of element + const showPanel = !beforeText || beforeText.endsWith(' '); + + if (showPanel) { + const slateDom = ReactEditor.toDOMNode(editor, editor); + + if (commands.includes(endOfPanelChar)) { + const selector = commandPanelClsSelector[endOfPanelChar] || ''; + + slateDom.parentElement?.querySelector(selector)?.classList.add(commandPanelShowProperty); + } + } + } + + insertText(text); + }; + + editor.deleteBackward = (...args) => { + if (CustomEditor.isCodeBlock(editor)) { + deleteBackward(...args); + return; + } + + const { selection } = editor; + + if (selection && Range.isCollapsed(selection)) { + const { anchor } = selection; + const block = getBlockEntry(editor); + const path = block ? block[1] : []; + const beforeText = Editor.string(editor, { anchor, focus: Editor.start(editor, path) }); + + // if delete backward at start of panel char, and then it will be deleted, so we should close the panel if it is open + if (commands.includes(beforeText)) { + const slateDom = ReactEditor.toDOMNode(editor, editor); + + const selector = commandPanelClsSelector[beforeText] || ''; + + slateDom.parentElement?.querySelector(selector)?.classList.remove(commandPanelShowProperty); + } + + // if delete backward at start of paragraph, and then it will be deleted, so we should close the panel if it is open + if (isDeleteBackwardAtStartOfBlock(editor, EditorNodeType.Paragraph)) { + const slateDom = ReactEditor.toDOMNode(editor, editor); + + commands.forEach((char) => { + const selector = commandPanelClsSelector[char] || ''; + + slateDom.parentElement?.querySelector(selector)?.classList.remove(commandPanelShowProperty); + }); + } + } + + deleteBackward(...args); + }; + + return editor; +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/editor/shortcuts/withMarkdownShortcuts.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/editor/shortcuts/withMarkdownShortcuts.ts new file mode 100644 index 0000000000..e6f7bb9caa --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/editor/shortcuts/withMarkdownShortcuts.ts @@ -0,0 +1,267 @@ +import { ReactEditor } from 'slate-react'; +import { Editor, Range, Element as SlateElement, Transforms } from 'slate'; +import { EditorMarkFormat, EditorNodeType } from '$app/application/document/document.types'; +import { CustomEditor } from '$app/components/editor/command'; + +const regexMap: Record< + string, + { + pattern: RegExp; + data?: Record<string, unknown>; + }[] +> = { + [EditorNodeType.BulletedListBlock]: [ + { + pattern: /^(\*|-|\+)$/, + }, + ], + [EditorNodeType.ToggleListBlock]: [ + { + pattern: /^>$/, + data: { + collapsed: false, + }, + }, + ], + [EditorNodeType.QuoteBlock]: [ + { + pattern: /^("|“|”)$/, + }, + ], + [EditorNodeType.TodoListBlock]: [ + { + pattern: /^\[ \]$/, + data: { + checked: false, + }, + }, + { + pattern: /^\[x\]$/, + data: { + checked: true, + }, + }, + { + pattern: /^\[\]$/, + data: { + checked: false, + }, + }, + ], + [EditorNodeType.NumberedListBlock]: [ + { + pattern: /^(\d+)\.$/, + }, + ], + [EditorNodeType.HeadingBlock]: [ + { + pattern: /^#{1}$/, + data: { + level: 1, + }, + }, + { + pattern: /^#{2}$/, + data: { + level: 2, + }, + }, + { + pattern: /^#{3}$/, + data: { + level: 3, + }, + }, + ], + [EditorNodeType.CodeBlock]: [ + { + pattern: /^```$/, + data: { + language: 'javascript', + }, + }, + ], + [EditorNodeType.CalloutBlock]: [ + { + pattern: /^(\[!)(TIP|INFO|WARNING|DANGER)(\])$/, + }, + ], + [EditorNodeType.DividerBlock]: [ + { + pattern: /^(-{3,})$/, + }, + ], + [EditorNodeType.EquationBlock]: [ + { + pattern: /^(\${2})(\s)*(.+)(\s)*(\${2})$/, + }, + ], +}; + +const CharToMarkTypeMap: Record<string, EditorMarkFormat> = { + '**': EditorMarkFormat.Bold, + __: EditorMarkFormat.Bold, + '*': EditorMarkFormat.Italic, + _: EditorMarkFormat.Italic, + '~': EditorMarkFormat.StrikeThrough, + '~~': EditorMarkFormat.StrikeThrough, + '`': EditorMarkFormat.Code, +}; + +const matchShortcutType = (beforeText: string, endChar: string) => { + if (endChar === '-') { + const dividerRegex = regexMap[EditorNodeType.DividerBlock][0]; + + return dividerRegex.pattern.test(beforeText + endChar) + ? { + type: EditorNodeType.DividerBlock, + data: {}, + } + : null; + } + + for (const [type, regexes] of Object.entries(regexMap)) { + for (const regex of regexes) { + if (regex.pattern.test(beforeText)) { + return { + type, + data: regex.data, + }; + } + } + } + + return null; +}; + +export const withMarkdownShortcuts = (editor: ReactEditor) => { + const { insertText } = editor; + + editor.insertText = (text) => { + if (CustomEditor.isCodeBlock(editor)) { + insertText(text); + return; + } + + const { selection } = editor; + + if (!selection || !Range.isCollapsed(selection)) { + insertText(text); + return; + } + + // end with inline mark char: * or _ or ~ or ` + // eg: **bold** or *italic* or ~strikethrough~ or `code` or _italic_ or __bold__ or ~~strikethrough~~ + const keyword = ['*', '_', '~', '`'].find((char) => text.endsWith(char)); + + if (keyword !== undefined) { + const { focus } = selection; + const start = { + path: focus.path, + offset: 0, + }; + const range = { anchor: start, focus }; + + const rangeText = Editor.string(editor, range); + + if (!rangeText.includes(keyword)) { + insertText(text); + return; + } + + const fullText = rangeText + keyword; + + let matchChar = keyword; + + if (['*', '_', '~'].includes(keyword)) { + const doubleKeyword = `${keyword}${keyword}`; + + if (rangeText.includes(doubleKeyword)) { + const match = fullText.match(new RegExp(`\\${keyword}{2}(.*)\\${keyword}{2}`)); + + if (!match) { + insertText(text); + return; + } + + matchChar = doubleKeyword; + } + } + + const markType = CharToMarkTypeMap[matchChar]; + + const startIndex = rangeText.lastIndexOf(matchChar); + const beforeText = rangeText.slice(startIndex + matchChar.length, matchChar.length > 1 ? -1 : undefined); + + if (!beforeText) { + insertText(text); + return; + } + + const anchor = { path: start.path, offset: start.offset + startIndex }; + + const at = { + anchor, + focus, + }; + + editor.select(at); + editor.addMark(markType, true); + editor.insertText(beforeText); + editor.collapse({ + edge: 'end', + }); + return; + } + + // end with space + if (text.endsWith(' ') || text.endsWith('-')) { + const endChar = text.slice(-1); + const [match] = Editor.nodes(editor, { + match: (n) => !Editor.isEditor(n) && SlateElement.isElement(n) && n.type !== undefined, + }); + + if (!match) { + insertText(text); + return; + } + + const [, path] = match; + + const { anchor } = selection; + const start = Editor.start(editor, path); + const range = { anchor, focus: start }; + const beforeText = Editor.string(editor, range) + text.slice(0, -1); + + if (beforeText === undefined) { + insertText(text); + return; + } + + const matchItem = matchShortcutType(beforeText, endChar); + + if (matchItem) { + const { type, data } = matchItem; + + Transforms.select(editor, range); + + if (!Range.isCollapsed(range)) { + Transforms.delete(editor); + } + + const newProperties: Partial<SlateElement> = { + type, + data, + }; + + CustomEditor.turnToBlock(editor, newProperties); + + return; + } + } + + insertText(text); + }; + + return editor; +}; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/editor/shortcuts/withShortcuts.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/editor/shortcuts/withShortcuts.ts new file mode 100644 index 0000000000..04d03a11af --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/editor/shortcuts/withShortcuts.ts @@ -0,0 +1,7 @@ +import { ReactEditor } from 'slate-react'; +import { withMarkdownShortcuts } from '$app/components/editor/components/editor/shortcuts/withMarkdownShortcuts'; +import { withCommandShortcuts } from '$app/components/editor/components/editor/shortcuts/withCommandShortcuts'; + +export function withShortcuts(editor: ReactEditor) { + return withMarkdownShortcuts(withCommandShortcuts(editor)); +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/editor/utils.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/editor/utils.ts new file mode 100644 index 0000000000..cc6960cdf4 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/editor/utils.ts @@ -0,0 +1,31 @@ +import { BasePoint, Editor, Transforms } from 'slate'; +import { ReactEditor } from 'slate-react'; + +export function getNodePath(editor: ReactEditor, target: HTMLElement) { + const slateNode = ReactEditor.toSlateNode(editor, target); + const path = ReactEditor.findPath(editor, slateNode); + + return path; +} + +export function moveCursorToNodeEnd(editor: ReactEditor, target: HTMLElement) { + const path = getNodePath(editor, target); + const afterPath = Editor.after(editor, path); + + ReactEditor.focus(editor); + + if (afterPath) { + const afterStart = Editor.start(editor, afterPath); + + moveCursorToPoint(editor, afterStart); + } else { + const beforeEnd = Editor.end(editor, path); + + moveCursorToPoint(editor, beforeEnd); + } +} + +export function moveCursorToPoint(editor: ReactEditor, point: BasePoint) { + ReactEditor.focus(editor); + Transforms.select(editor, point); +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/inline_nodes/InlineChromiumBugfix.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/inline_nodes/InlineChromiumBugfix.tsx new file mode 100644 index 0000000000..8ac67c368b --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/inline_nodes/InlineChromiumBugfix.tsx @@ -0,0 +1,15 @@ +import React from 'react'; + +// Put this at the start and end of an inline component to work around this Chromium bug: +// https://bugs.chromium.org/p/chromium/issues/detail?id=1249405 + +export const InlineChromiumBugfix = () => ( + <span + contentEditable={false} + style={{ + fontSize: 0, + }} + > + {String.fromCodePoint(160) /* Non-breaking space */} + </span> +); diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/inline_nodes/index.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/inline_nodes/index.ts new file mode 100644 index 0000000000..29f27984f7 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/inline_nodes/index.ts @@ -0,0 +1,2 @@ +export * from './withInline'; +export * from './inline_formula'; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/inline_nodes/inline_formula/FormulaEditPopover.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/inline_nodes/inline_formula/FormulaEditPopover.tsx new file mode 100644 index 0000000000..f24b267a09 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/inline_nodes/inline_formula/FormulaEditPopover.tsx @@ -0,0 +1,60 @@ +import React, { useState } from 'react'; + +import Popover from '@mui/material/Popover'; +import { PopoverCommonProps } from '$app/components/editor/components/tools/popover'; +import Button from '@mui/material/Button'; +import { useTranslation } from 'react-i18next'; +import TextField from '@mui/material/TextField'; + +function FormulaEditPopover({ + defaultText, + open, + anchorEl, + onClose, + onDone, +}: { + defaultText: string; + open: boolean; + anchorEl: HTMLElement | null; + onClose: () => void; + onDone: (formula: string) => void; +}) { + const [text, setText] = useState<string>(defaultText); + const { t } = useTranslation(); + + return ( + <Popover + {...PopoverCommonProps} + open={open} + anchorEl={anchorEl} + onClose={onClose} + anchorOrigin={{ + vertical: 'bottom', + horizontal: 'center', + }} + transformOrigin={{ + vertical: 'top', + horizontal: 'center', + }} + > + <div className='flex p-2 '> + <TextField + variant={'standard'} + size={'small'} + autoFocus={true} + value={text} + placeholder={'E = mc^2'} + onChange={(e) => setText(e.target.value)} + fullWidth={true} + /> + <div className={'ml-2'}> + <Button size={'small'} variant={'text'} onClick={() => onDone(text)}> + {t('button.done')} + </Button> + </div> + </div> + </Popover> + ); +} + +export default FormulaEditPopover; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/inline_nodes/inline_formula/FormulaLeaf.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/inline_nodes/inline_formula/FormulaLeaf.tsx new file mode 100644 index 0000000000..f24a8681a4 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/inline_nodes/inline_formula/FormulaLeaf.tsx @@ -0,0 +1,13 @@ +import React from 'react'; +import KatexMath from '$app/components/_shared/KatexMath'; + +function FormulaLeaf({ text, children }: { text: string; children: React.ReactNode }) { + return ( + <span className={'relative'}> + <KatexMath latex={text || ''} isInline /> + <span className={'absolute left-0 right-0 h-0 w-0 opacity-0'}>{children}</span> + </span> + ); +} + +export default FormulaLeaf; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/inline_nodes/inline_formula/InlineFormula.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/inline_nodes/inline_formula/InlineFormula.tsx new file mode 100644 index 0000000000..2043a61beb --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/inline_nodes/inline_formula/InlineFormula.tsx @@ -0,0 +1,95 @@ +import React, { forwardRef, memo, useCallback, MouseEvent, useRef, useEffect, useState } from 'react'; +import { ReactEditor, useSlate } from 'slate-react'; +import { Text, Transforms } from 'slate'; +import { EditorElementProps, FormulaNode } from '$app/application/document/document.types'; +import FormulaLeaf from '$app/components/editor/components/inline_nodes/inline_formula/FormulaLeaf'; +import { InlineChromiumBugfix } from '$app/components/editor/components/inline_nodes/InlineChromiumBugfix'; +import FormulaEditPopover from '$app/components/editor/components/inline_nodes/inline_formula/FormulaEditPopover'; +import { getNodePath, moveCursorToNodeEnd } from '$app/components/editor/components/editor/utils'; +import { useElementFocused } from '$app/components/editor/components/inline_nodes/useElementFocused'; +import { CustomEditor } from '$app/components/editor/command'; + +export const InlineFormula = memo( + forwardRef<HTMLSpanElement, EditorElementProps<FormulaNode>>(({ node, children, ...attributes }, ref) => { + const editor = useSlate(); + const text = (node.children[0] as Text).text; + const focused = useElementFocused(node); + + const anchor = useRef<HTMLSpanElement | null>(null); + const [openEditPopover, setOpenEditPopover] = useState<boolean>(false); + + const handleClick = useCallback( + (e: MouseEvent<HTMLSpanElement>) => { + const target = e.currentTarget; + const path = getNodePath(editor, target); + + setOpenEditPopover(true); + ReactEditor.focus(editor); + Transforms.select(editor, path); + }, + [editor] + ); + + useEffect(() => { + if (focused) { + setOpenEditPopover(true); + } else { + setOpenEditPopover(false); + } + }, [focused]); + + const handleEditPopoverClose = useCallback(() => { + setOpenEditPopover(false); + if (anchor.current === null) { + return; + } + + moveCursorToNodeEnd(editor, anchor.current); + }, [editor]); + + return ( + <> + <span + {...attributes} + ref={(el) => { + anchor.current = el; + if (ref) { + if (typeof ref === 'function') { + ref(el); + } else { + ref.current = el; + } + } + }} + contentEditable={false} + onDoubleClick={handleClick} + onClick={handleClick} + className={`relative rounded px-1 py-0.5 text-xs ${focused ? 'bg-fill-list-active' : ''}`} + data-playwright-selected={focused} + > + <InlineChromiumBugfix /> + <FormulaLeaf text={text}>{children}</FormulaLeaf> + <InlineChromiumBugfix /> + </span> + {openEditPopover && ( + <FormulaEditPopover + defaultText={text} + onDone={(formula) => { + if (anchor.current === null) return; + const path = getNodePath(editor, anchor.current); + + // select the node before updating the formula + Transforms.select(editor, path); + CustomEditor.updateFormula(editor, formula); + + handleEditPopoverClose(); + }} + anchorEl={anchor.current} + open={openEditPopover} + onClose={handleEditPopoverClose} + /> + )} + </> + ); + }) +); diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/inline_nodes/inline_formula/index.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/inline_nodes/inline_formula/index.ts new file mode 100644 index 0000000000..5643ae8943 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/inline_nodes/inline_formula/index.ts @@ -0,0 +1,2 @@ +export * from './InlineFormula'; +export * from './FormulaLeaf'; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/inline_nodes/mention/Mention.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/inline_nodes/mention/Mention.tsx new file mode 100644 index 0000000000..bd56037db9 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/inline_nodes/mention/Mention.tsx @@ -0,0 +1,26 @@ +import React, { forwardRef, memo } from 'react'; +import { EditorElementProps, MentionNode } from '$app/application/document/document.types'; + +import MentionLeaf from '$app/components/editor/components/inline_nodes/mention/MentionLeaf'; +import { useElementFocused } from '$app/components/editor/components/inline_nodes/useElementFocused'; + +export const Mention = memo( + forwardRef<HTMLSpanElement, EditorElementProps<MentionNode>>(({ node, children, ...attributes }, ref) => { + const focused = useElementFocused(node); + + return ( + <span + {...attributes} + data-playwright-selected={focused} + contentEditable={false} + className={`${attributes.className ?? ''} text-sx relative rounded px-1 hover:bg-content-blue-100`} + ref={ref} + style={{ + backgroundColor: focused ? 'var(--content-blue-100)' : undefined, + }} + > + <MentionLeaf mention={node.data}>{children}</MentionLeaf> + </span> + ); + }) +); diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/inline_nodes/mention/MentionLeaf.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/inline_nodes/mention/MentionLeaf.tsx new file mode 100644 index 0000000000..2763a391bd --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/inline_nodes/mention/MentionLeaf.tsx @@ -0,0 +1,44 @@ +import React, { useCallback, useEffect, useState } from 'react'; +import { Mention, MentionPage } from '$app/application/document/document.types'; +import { PageController } from '$app/stores/effects/workspace/page/page_controller'; +import { ReactComponent as DocumentSvg } from '$app/assets/document.svg'; +import { useTranslation } from 'react-i18next'; +import { useNavigate } from 'react-router-dom'; +import { pageTypeMap } from '$app_reducers/pages/slice'; + +export function MentionLeaf({ children, mention }: { mention: Mention; children: React.ReactNode }) { + const { t } = useTranslation(); + const [page, setPage] = useState<MentionPage | null>(null); + const navigate = useNavigate(); + const loadPage = useCallback(async () => { + if (!mention.page) return; + const page = await new PageController(mention.page).getPage(); + + setPage(page); + }, [mention.page]); + + useEffect(() => { + void loadPage(); + }, [loadPage]); + + const openPage = useCallback(() => { + if (!page) return; + const pageType = pageTypeMap[page.layout]; + + navigate(`/page/${pageType}/${page.id}`); + }, [navigate, page]); + + return ( + <> + {page && ( + <span className={'inline-flex cursor-pointer items-center'} onClick={openPage}> + <span className={'mr-1 inline-flex items-center'}>{page.icon?.value || <DocumentSvg />}</span> + <span className={'text-sx underline'}>{page.name || t('document.title.placeholder')}</span> + </span> + )} + <span className={'absolute left-0 right-0 h-0 w-0 opacity-0'}>{children}</span> + </> + ); +} + +export default MentionLeaf; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/inline_nodes/mention/index.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/inline_nodes/mention/index.ts new file mode 100644 index 0000000000..d3ee18034d --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/inline_nodes/mention/index.ts @@ -0,0 +1 @@ +export * from './Mention'; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/inline_nodes/useElementFocused.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/inline_nodes/useElementFocused.ts new file mode 100644 index 0000000000..af81ff8690 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/inline_nodes/useElementFocused.ts @@ -0,0 +1,22 @@ +import { ReactEditor, useSelected, useSlate } from 'slate-react'; +import { useMemo } from 'react'; +import { Range, Text, Element } from 'slate'; + +export function useElementFocused(node: Element) { + const editor = useSlate(); + const text = (node.children[0] as Text).text; + const selected = useSelected(); + + const focused = useMemo(() => { + if (!selected) return false; + const selection = editor.selection; + + if (!selection) return false; + const path = ReactEditor.findPath(editor, node); + const range = { anchor: { path, offset: 0 }, focus: { path, offset: text.length } } as Range; + + return Range.includes(range, selection); + }, [editor, selected, node, text.length]); + + return focused; +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/inline_nodes/withInline.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/inline_nodes/withInline.ts new file mode 100644 index 0000000000..2859c1f0a8 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/inline_nodes/withInline.ts @@ -0,0 +1,31 @@ +import { ReactEditor } from 'slate-react'; +import { EditorInlineNodeType, inlineNodeTypes } from '$app/application/document/document.types'; +import { Element } from 'slate'; + +export function withInlines(editor: ReactEditor) { + const { isInline, isElementReadOnly, isSelectable, isVoid, markableVoid } = editor; + + const matchInlineType = (element: Element) => { + return inlineNodeTypes.includes(element.type as EditorInlineNodeType); + }; + + editor.isInline = (element) => { + return matchInlineType(element) || isInline(element); + }; + + editor.isVoid = (element) => { + return matchInlineType(element) || isVoid(element); + }; + + editor.markableVoid = (element) => { + return matchInlineType(element) || markableVoid(element); + }; + + editor.isElementReadOnly = (element) => + inlineNodeTypes.includes(element.type as EditorInlineNodeType) || isElementReadOnly(element); + + editor.isSelectable = (element) => + !inlineNodeTypes.includes(element.type as EditorInlineNodeType) && isSelectable(element); + + return editor; +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/marks/index.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/marks/index.ts new file mode 100644 index 0000000000..e33728e03e --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/marks/index.ts @@ -0,0 +1 @@ +export * from './link'; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/marks/link/Link.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/marks/link/Link.tsx new file mode 100644 index 0000000000..72cae31a1a --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/marks/link/Link.tsx @@ -0,0 +1,87 @@ +import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { ReactEditor, useSelected, useSlate } from 'slate-react'; +import { getNodePath, moveCursorToNodeEnd, moveCursorToPoint } from '$app/components/editor/components/editor/utils'; +import { BasePoint, Transforms, Text, Range, Point } from 'slate'; +import { LinkEditPopover } from '$app/components/editor/components/marks/link/LinkEditPopover'; + +export const Link = memo(({ leaf, children }: { leaf: Text; children: React.ReactNode }) => { + const nodeSelected = useSelected(); + + const editor = useSlate(); + + const ref = useRef<HTMLSpanElement | null>(null); + const [openEditPopover, setOpenEditPopover] = useState<boolean>(false); + + const selected = useMemo(() => { + if (!editor.selection || !nodeSelected || !ref.current) return false; + + const node = ReactEditor.toSlateNode(editor, ref.current); + const path = ReactEditor.findPath(editor, node); + const range = { anchor: { path, offset: 0 }, focus: { path, offset: leaf.text.length } }; + const isContained = Range.includes(range, editor.selection); + const selectionIsCollapsed = Range.isCollapsed(editor.selection); + const point = Range.start(editor.selection); + + if ((selectionIsCollapsed && point && Point.equals(point, range.focus)) || Point.equals(point, range.anchor)) { + return false; + } + + return isContained; + }, [editor, nodeSelected, leaf.text.length]); + + useEffect(() => { + if (selected) { + setOpenEditPopover(true); + } else { + setOpenEditPopover(false); + } + }, [selected]); + + const handleClick = useCallback(() => { + if (ref.current === null) { + return; + } + + const path = getNodePath(editor, ref.current); + + setOpenEditPopover(true); + ReactEditor.focus(editor); + Transforms.select(editor, path); + }, [editor]); + + const handleEditPopoverClose = useCallback( + (at?: BasePoint) => { + setOpenEditPopover(false); + if (ref.current === null) { + return; + } + + if (!at) { + moveCursorToNodeEnd(editor, ref.current); + } else { + moveCursorToPoint(editor, at); + } + }, + [editor] + ); + + return ( + <> + <span + ref={ref} + onClick={handleClick} + className={`rounded px-1 py-0.5 text-fill-default underline ${selected ? 'bg-content-blue-50' : ''}`} + > + {children} + </span> + {openEditPopover && ( + <LinkEditPopover + open={openEditPopover} + anchorEl={ref.current} + onClose={handleEditPopoverClose} + defaultHref={leaf.href || ''} + /> + )} + </> + ); +}); diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/marks/link/LinkEditPopover.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/marks/link/LinkEditPopover.tsx new file mode 100644 index 0000000000..c9fb017dbe --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/marks/link/LinkEditPopover.tsx @@ -0,0 +1,139 @@ +import React, { useCallback, useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import Popover from '@mui/material/Popover'; +import { PopoverCommonProps } from '$app/components/editor/components/tools/popover'; +import Button from '@mui/material/Button'; +import { getNodePath } from '$app/components/editor/components/editor/utils'; +import { addMark, BasePoint, Editor, Transforms, removeMark } from 'slate'; +import { EditorStyleFormat } from '$app/application/document/document.types'; +import { useSlate } from 'slate-react'; +import { ReactComponent as RemoveSvg } from '$app/assets/delete.svg'; +import { ReactComponent as LinkSvg } from '$app/assets/link.svg'; +import { ReactComponent as CopySvg } from '$app/assets/copy.svg'; +import { open as openWindow } from '@tauri-apps/api/shell'; +import { OutlinedInput } from '@mui/material'; +import { notify } from '$app/components/editor/components/tools/notify'; + +const pattern = /^(https?:\/\/)?([\da-z.-]+)\.([a-z.]{2,6})([/\w.-]*)*\/?$/; + +export function LinkEditPopover({ + defaultHref, + open, + anchorEl, + onClose, +}: { + defaultHref: string; + open: boolean; + anchorEl: HTMLElement | null; + onClose: (at?: BasePoint) => void; +}) { + const { t } = useTranslation(); + const editor = useSlate(); + const [link, setLink] = useState<string>(defaultHref); + + const setNodeMark = useCallback(() => { + if (!anchorEl) return; + const path = getNodePath(editor, anchorEl); + + // select the node before updating the formula + Transforms.select(editor, path); + if (link === '') { + removeMark(editor, EditorStyleFormat.Href); + } else { + addMark(editor, EditorStyleFormat.Href, link); + } + + onClose(); + }, [editor, anchorEl, link, onClose]); + + const removeNodeMark = useCallback(() => { + if (!anchorEl) return; + const path = getNodePath(editor, anchorEl); + const beforePath = Editor.before(editor, path); + const beforePathEnd = beforePath ? Editor.end(editor, beforePath) : undefined; + + // select the node before updating the formula + Transforms.select(editor, path); + editor.removeMark(EditorStyleFormat.Href); + + onClose(beforePathEnd); + }, [editor, anchorEl, onClose]); + + const linkActions = useMemo( + () => [ + { + icon: <LinkSvg />, + tooltip: t('editor.openLink'), + onClick: () => { + void openWindow(link); + }, + disabled: !pattern.test(link), + }, + { + icon: <CopySvg />, + tooltip: t('editor.copyLink'), + onClick: async () => { + await navigator.clipboard.writeText(link); + notify.success(t('message.copy.success')); + }, + }, + { + icon: <RemoveSvg />, + tooltip: t('editor.removeLink'), + onClick: removeNodeMark, + }, + ], + [link, t, removeNodeMark] + ); + + return ( + <Popover + {...PopoverCommonProps} + open={open} + anchorEl={anchorEl} + onClose={() => { + onClose(); + }} + anchorOrigin={{ + vertical: 'bottom', + horizontal: 'center', + }} + transformOrigin={{ + vertical: 'top', + horizontal: 'center', + }} + > + <div className='flex flex-col p-2'> + <OutlinedInput + size={'small'} + autoFocus={true} + onKeyDown={(e) => { + if (e.key === 'Enter' && link) { + setNodeMark(); + } + }} + className={'my-1 p-0'} + value={link} + placeholder={'https://example.com'} + onChange={(e) => setLink(e.target.value)} + fullWidth={true} + /> + <div className={'mt-1 flex w-full flex-col items-start'}> + {linkActions.map((action, index) => ( + <Button + key={index} + disabled={action.disabled} + className={'w-full justify-start'} + size={'small'} + color={'inherit'} + startIcon={action.icon} + onClick={action.onClick} + > + {action.tooltip} + </Button> + ))} + </div> + </div> + </Popover> + ); +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/marks/link/index.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/marks/link/index.ts new file mode 100644 index 0000000000..295683a3bc --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/marks/link/index.ts @@ -0,0 +1,3 @@ +export * from './Link'; + +export * from './LinkEditPopover'; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/block_actions/AddBlockBelow.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/block_actions/AddBlockBelow.tsx new file mode 100644 index 0000000000..ac55f4440a --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/block_actions/AddBlockBelow.tsx @@ -0,0 +1,121 @@ +import React, { useCallback, useMemo, useState } from 'react'; +import { ReactEditor, useSlate } from 'slate-react'; +import { IconButton, Tooltip } from '@mui/material'; +import Popover from '@mui/material/Popover'; +import { PopoverPreventBlurProps } from '$app/components/editor/components/tools/popover'; +import SlashCommandPanelContent from '$app/components/editor/components/tools/command_panel/slash_command_panel/SlashCommandPanelContent'; +import { ReactComponent as AddSvg } from '$app/assets/add.svg'; +import { useTranslation } from 'react-i18next'; +import { Editor, Element, Transforms } from 'slate'; +import { EditorNodeType } from '$app/application/document/document.types'; +import { CustomEditor } from '$app/components/editor/command'; + +function AddBlockBelow({ node }: { node: Element }) { + const { t } = useTranslation(); + const [nodeEl, setNodeEl] = useState<HTMLElement | null>(null); + const editor = useSlate(); + const openSlashCommandPanel = useMemo(() => !!nodeEl, [nodeEl]); + + const handleSlashCommandPanelClose = useCallback( + (deleteText?: boolean) => { + if (!nodeEl) return; + const node = ReactEditor.toSlateNode(editor, nodeEl); + + if (deleteText) { + const path = ReactEditor.findPath(editor, node); + + Transforms.select(editor, path); + Transforms.insertNodes( + editor, + [ + { + text: '', + }, + ], + { + select: true, + } + ); + } + + setNodeEl(null); + }, + [editor, nodeEl] + ); + + const handleAddBelow = () => { + if (!node) return; + ReactEditor.focus(editor); + + const path = ReactEditor.findPath(editor, node); + + editor.select(path); + editor.collapse({ + edge: 'end', + }); + + const isEmptyNode = editor.isEmpty(node); + + if (isEmptyNode) { + const nodeDom = ReactEditor.toDOMNode(editor, node); + + setNodeEl(nodeDom); + } else { + CustomEditor.splitToParagraph(editor); + + requestAnimationFrame(() => { + const nextNodeEntry = Editor.next(editor, { + at: path, + match: (n) => Element.isElement(n) && Editor.isBlock(editor, n) && n.type === EditorNodeType.Paragraph, + }); + + if (!nextNodeEntry) return; + const nextNode = nextNodeEntry[0] as Element; + + const nodeDom = ReactEditor.toDOMNode(editor, nextNode); + + setNodeEl(nodeDom); + }); + } + }; + + const searchText = useMemo(() => { + if (!nodeEl) return ''; + const node = ReactEditor.toSlateNode(editor, nodeEl); + const path = ReactEditor.findPath(editor, node); + + return Editor.string(editor, path); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [editor, nodeEl, editor.selection]); + + return ( + <> + <Tooltip title={t('blockActions.addBelowTooltip')}> + <IconButton onClick={handleAddBelow} size={'small'}> + <AddSvg /> + </IconButton> + </Tooltip> + {openSlashCommandPanel && ( + <Popover + {...PopoverPreventBlurProps} + anchorOrigin={{ + vertical: 30, + horizontal: 64, + }} + transformOrigin={{ + vertical: 'top', + horizontal: 'left', + }} + onMouseMove={(e) => e.stopPropagation()} + open={openSlashCommandPanel} + anchorEl={nodeEl} + onClose={() => handleSlashCommandPanelClose(false)} + > + <SlashCommandPanelContent searchText={searchText} closePanel={handleSlashCommandPanelClose} /> + </Popover> + )} + </> + ); +} + +export default AddBlockBelow; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/block_actions/BlockActions.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/block_actions/BlockActions.tsx new file mode 100644 index 0000000000..bf4b242fae --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/block_actions/BlockActions.tsx @@ -0,0 +1,16 @@ +import React from 'react'; + +import { Element } from 'slate'; +import AddBlockBelow from '$app/components/editor/components/tools/block_actions/AddBlockBelow'; +import DragBlock from '$app/components/editor/components/tools/block_actions/DragBlock'; + +export function BlockActions({ node, onSelectedBlock }: { node: Element; onSelectedBlock: (blockId: string) => void }) { + return ( + <> + <AddBlockBelow node={node} /> + <DragBlock node={node} onSelectedBlock={onSelectedBlock} /> + </> + ); +} + +export default BlockActions; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/block_actions/BlockActionsToolbar.hooks.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/block_actions/BlockActionsToolbar.hooks.ts new file mode 100644 index 0000000000..9230b0a742 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/block_actions/BlockActionsToolbar.hooks.ts @@ -0,0 +1,62 @@ +import { useEffect, useState } from 'react'; +import { ReactEditor, useSlate } from 'slate-react'; +import { getBlockActionsPosition } from '$app/components/editor/components/tools/block_actions/utils'; +import { Element } from 'slate'; + +export function useBlockActionsToolbar(ref: React.RefObject<HTMLDivElement>) { + const editor = useSlate(); + const [node, setNode] = useState<Element | null>(null); + + useEffect(() => { + const handleMouseMove = (e: MouseEvent) => { + const el = ref.current; + + if (!el) return; + + const target = e.target as HTMLElement; + + if (target.closest('.block-actions')) return; + const blockElement = target ? (target.closest('.block-element') as HTMLElement) : null; + + if (!blockElement) { + el.style.opacity = '0'; + el.style.pointerEvents = 'none'; + setNode(null); + return; + } + + const { top, left } = getBlockActionsPosition(editor, blockElement); + + const slateEditorDom = ReactEditor.toDOMNode(editor, editor); + + el.style.opacity = '1'; + el.style.pointerEvents = 'auto'; + el.style.top = `${top + slateEditorDom.offsetTop}px`; + el.style.left = `${left + slateEditorDom.offsetLeft}px`; + const slateNode = ReactEditor.toSlateNode(editor, blockElement) as Element; + + setNode(slateNode); + }; + + const handleMouseLeave = (_e: MouseEvent) => { + const el = ref.current; + + if (!el) return; + + el.style.opacity = '0'; + el.style.pointerEvents = 'none'; + setNode(null); + }; + + document.addEventListener('mousemove', handleMouseMove); + document.addEventListener('mouseleave', handleMouseLeave); + return () => { + document.removeEventListener('mousemove', handleMouseMove); + document.removeEventListener('mouseleave', handleMouseLeave); + }; + }, [editor, ref]); + + return { + node, + }; +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/block_actions/BlockActionsToolbar.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/block_actions/BlockActionsToolbar.tsx new file mode 100644 index 0000000000..e3dadd8d52 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/block_actions/BlockActionsToolbar.tsx @@ -0,0 +1,32 @@ +import React, { useRef } from 'react'; +import { useBlockActionsToolbar } from './BlockActionsToolbar.hooks'; +import BlockActions from '$app/components/editor/components/tools/block_actions/BlockActions'; + +import { getBlockCssProperty } from '$app/components/editor/components/tools/block_actions/utils'; + +export function BlockActionsToolbar({ onSelectedBlock }: { onSelectedBlock: (blockId: string) => void }) { + const ref = useRef<HTMLDivElement | null>(null); + + const { node } = useBlockActionsToolbar(ref); + + const cssProperty = node && getBlockCssProperty(node); + + return ( + <div + ref={ref} + contentEditable={false} + className={`block-actions ${cssProperty} absolute z-10 flex w-[64px] flex-grow transform items-center justify-end px-1 opacity-0 transition-opacity`} + onMouseDown={(e) => { + // prevent toolbar from taking focus away from editor + e.preventDefault(); + }} + onMouseUp={(e) => { + e.stopPropagation(); + }} + > + {/* Ensure the toolbar in middle */} + <div className={'invisible'}>0</div> + {node && <BlockActions node={node} onSelectedBlock={onSelectedBlock} />} + </div> + ); +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/block_actions/BlockOperationMenu.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/block_actions/BlockOperationMenu.tsx new file mode 100644 index 0000000000..f875a1adfc --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/block_actions/BlockOperationMenu.tsx @@ -0,0 +1,62 @@ +import React, { useMemo } from 'react'; +import Popover, { PopoverProps } from '@mui/material/Popover'; +import { ReactComponent as DeleteSvg } from '$app/assets/delete.svg'; +import { ReactComponent as CopySvg } from '$app/assets/copy.svg'; +import { useTranslation } from 'react-i18next'; +import { Button } from '@mui/material'; +import { PopoverCommonProps } from '$app/components/editor/components/tools/popover'; +import { Element } from 'slate'; +import { useSlateStatic } from 'slate-react'; +import { CustomEditor } from '$app/components/editor/command'; + +export function BlockOperationMenu({ + node, + ...props +}: { + node: Element; +} & PopoverProps) { + const editor = useSlateStatic(); + const { t } = useTranslation(); + const options = useMemo( + () => [ + { + icon: <DeleteSvg />, + text: t('button.delete'), + onClick: () => { + CustomEditor.deleteNode(editor, node); + props.onClose?.({}, 'backdropClick'); + }, + }, + { + icon: <CopySvg />, + text: t('button.duplicate'), + onClick: () => { + CustomEditor.duplicateNode(editor, node); + props.onClose?.({}, 'backdropClick'); + }, + }, + ], + [editor, node, props, t] + ); + + return ( + <Popover {...PopoverCommonProps} {...props}> + <div className={'flex flex-col p-2'}> + {options.map((option, index) => ( + <Button + color={'inherit'} + onClick={option.onClick} + startIcon={option.icon} + size={'small'} + className={'w-full justify-start'} + key={index} + > + {option.text} + </Button> + ))} + </div> + </Popover> + ); +} + +export default BlockOperationMenu; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/block_actions/DragBlock.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/block_actions/DragBlock.tsx new file mode 100644 index 0000000000..b212fc805b --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/block_actions/DragBlock.tsx @@ -0,0 +1,54 @@ +import React, { useCallback, useRef, useState } from 'react'; +import { IconButton, Tooltip } from '@mui/material'; +import { useTranslation } from 'react-i18next'; +import { ReactComponent as DragSvg } from '$app/assets/drag.svg'; +import BlockOperationMenu from '$app/components/editor/components/tools/block_actions/BlockOperationMenu'; +import { Element } from 'slate'; + +function DragBlock({ node, onSelectedBlock }: { node: Element; onSelectedBlock: (blockId: string) => void }) { + const dragBtnRef = useRef<HTMLButtonElement>(null); + const [openMenu, setOpenMenu] = useState(false); + const { t } = useTranslation(); + + const handleClick = useCallback( + (e: React.MouseEvent) => { + e.stopPropagation(); + setOpenMenu(true); + if (!node || !node.blockId) return; + + onSelectedBlock(node.blockId); + }, + [node, onSelectedBlock] + ); + + return ( + <> + <Tooltip title={t('blockActions.openMenuTooltip')}> + <IconButton onClick={handleClick} ref={dragBtnRef} size={'small'}> + <DragSvg /> + </IconButton> + </Tooltip> + {openMenu && node && ( + <BlockOperationMenu + onMouseMove={(e) => { + e.stopPropagation(); + }} + anchorOrigin={{ + vertical: 'bottom', + horizontal: 'right', + }} + transformOrigin={{ + vertical: 'center', + horizontal: 'left', + }} + node={node} + open={openMenu} + anchorEl={dragBtnRef.current} + onClose={() => setOpenMenu(false)} + /> + )} + </> + ); +} + +export default DragBlock; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/block_actions/index.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/block_actions/index.ts new file mode 100644 index 0000000000..e8b87721c2 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/block_actions/index.ts @@ -0,0 +1,2 @@ +export * from './BlockActions'; +export * from './BlockActionsToolbar'; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/block_actions/utils.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/block_actions/utils.ts new file mode 100644 index 0000000000..9991d03f62 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/block_actions/utils.ts @@ -0,0 +1,28 @@ +import { ReactEditor } from 'slate-react'; +import { getEditorDomNode, getHeadingCssProperty } from '$app/components/editor/plugins/utils'; +import { Element } from 'slate'; +import { EditorNodeType, HeadingNode } from '$app/application/document/document.types'; + +export function getBlockActionsPosition(editor: ReactEditor, blockElement: HTMLElement) { + const editorDom = getEditorDomNode(editor); + const editorDomRect = editorDom.getBoundingClientRect(); + const blockDomRect = blockElement.getBoundingClientRect(); + + const relativeTop = blockDomRect.top - editorDomRect.top; + const relativeLeft = blockDomRect.left - editorDomRect.left; + + return { + top: relativeTop, + left: relativeLeft, + }; +} + +export function getBlockCssProperty(node: Element) { + switch (node.type) { + case EditorNodeType.HeadingBlock: + return getHeadingCssProperty((node as HeadingNode).data.level); + case EditorNodeType.CodeBlock: + case EditorNodeType.CalloutBlock: + return 'my-2'; + } +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/command_panel/index.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/command_panel/index.ts new file mode 100644 index 0000000000..cf07c7d996 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/command_panel/index.ts @@ -0,0 +1,2 @@ +export * from './mention_panel'; +export * from './slash_command_panel'; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/command_panel/mention_panel/MentionPanel.hooks.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/command_panel/mention_panel/MentionPanel.hooks.ts new file mode 100644 index 0000000000..82da550588 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/command_panel/mention_panel/MentionPanel.hooks.ts @@ -0,0 +1,81 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useSlate } from 'slate-react'; +import { Mention, MentionPage, MentionType } from '$app/application/document/document.types'; +import { CustomEditor } from '$app/components/editor/command'; +import { useAppSelector } from '$app/stores/store'; + +export function useMentionPanel({ + closePanel, + searchText, +}: { + searchText: string; + closePanel: (deleteText?: boolean) => void; +}) { + const { t } = useTranslation(); + const editor = useSlate(); + const [selectedOptionId, setSelectedOptionId] = useState<string>(''); + + const onClick = useCallback( + (type: MentionType, mention: Mention) => { + closePanel(false); + CustomEditor.insertMention(editor, mention); + }, + [closePanel, editor] + ); + const pagesMap = useAppSelector((state) => state.pages.pageMap); + + const pagesRef = useRef<MentionPage[]>([]); + const [recentPages, setPages] = useState<MentionPage[]>([]); + + const loadPages = useCallback(async () => { + const pages = Object.values(pagesMap); + + pagesRef.current = pages; + setPages(pages); + }, [pagesMap]); + + useEffect(() => { + void loadPages(); + }, [loadPages]); + + useEffect(() => { + if (!searchText) { + setPages(pagesRef.current); + return; + } + + const filteredPages = pagesRef.current.filter((page) => { + return page.name.toLowerCase().includes(searchText.toLowerCase()); + }); + + setPages(filteredPages); + }, [searchText]); + + const options = useMemo(() => { + return [ + { + key: MentionType.PageRef, + label: t('document.mention.page.label'), + options: recentPages.map((page) => { + return { + key: page.id, + label: page.name, + icon: page.icon, + onClick: () => { + onClick(MentionType.PageRef, { + page: page.id, + }); + }, + }; + }), + }, + ].filter((option) => option.options.length > 0); + }, [onClick, recentPages, t]); + + return { + options, + selectedOptionId, + setSelectedOptionId, + }; +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/command_panel/mention_panel/MentionPanel.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/command_panel/mention_panel/MentionPanel.tsx new file mode 100644 index 0000000000..33daced920 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/command_panel/mention_panel/MentionPanel.tsx @@ -0,0 +1,24 @@ +import React, { useRef } from 'react'; +import { PanelPopoverProps, usePanel } from '$app/components/editor/components/tools/command_panel/usePanel.hooks'; +import Popover from '@mui/material/Popover'; + +import MentionPanelContent from '$app/components/editor/components/tools/command_panel/mention_panel/MentionPanelContent'; + +export function MentionPanel() { + const ref = useRef<HTMLDivElement>(null); + const { anchorPosition, closePanel, searchText } = usePanel(ref); + + const open = Boolean(anchorPosition); + + return ( + <div ref={ref} className={'mention-panel'}> + {open && ( + <Popover {...PanelPopoverProps} open={open} anchorPosition={anchorPosition} onClose={() => closePanel(false)}> + <MentionPanelContent closePanel={closePanel} searchText={searchText} /> + </Popover> + )} + </div> + ); +} + +export default MentionPanel; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/command_panel/mention_panel/MentionPanelContent.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/command_panel/mention_panel/MentionPanelContent.tsx new file mode 100644 index 0000000000..53dbdb0dd3 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/command_panel/mention_panel/MentionPanelContent.tsx @@ -0,0 +1,77 @@ +import React, { useCallback, useRef } from 'react'; +import { useMentionPanel } from '$app/components/editor/components/tools/command_panel/mention_panel/MentionPanel.hooks'; +import { useKeyDown } from '$app/components/editor/components/tools/command_panel/usePanel.hooks'; +import { useTranslation } from 'react-i18next'; +import { MenuItem, MenuList, Typography } from '@mui/material'; +import { ReactComponent as DocumentSvg } from '$app/assets/document.svg'; + +function MentionPanelContent({ + closePanel, + searchText, +}: { + closePanel: (deleteText?: boolean) => void; + searchText: string; +}) { + const { t } = useTranslation(); + const scrollRef = useRef<HTMLDivElement>(null); + + const { options, selectedOptionId, setSelectedOptionId } = useMentionPanel({ + closePanel, + searchText, + }); + + const handleSelectKey = useCallback( + (key?: string | number) => { + setSelectedOptionId(String(key)); + }, + [setSelectedOptionId] + ); + + useKeyDown({ + scrollRef, + panelOpen: true, + closePanel, + options, + selectedKey: selectedOptionId, + setSelectedKey: handleSelectKey, + }); + return ( + <div ref={scrollRef} className={'max-h-[360px] w-[300px] overflow-auto overflow-x-hidden'}> + {options.length === 0 ? ( + <Typography variant='body1' className={'p-3 text-text-caption'}> + No results + </Typography> + ) : ( + options.map((option, index) => ( + <div key={option.key} className={`${index !== 0 ? 'border-t border-line-divider' : ''}`}> + <Typography variant='body1' className={'p-3 px-4 text-text-caption'}> + {option.label} + </Typography> + <MenuList className={'px-2 pb-3 pt-0'}> + {option.options.map((subOption) => { + return ( + <MenuItem + onMouseEnter={() => setSelectedOptionId(subOption.key)} + selected={selectedOptionId === subOption.key} + data-type={subOption.key} + className={'ml-0 flex w-full items-center justify-start px-2 py-1'} + key={subOption.key} + onClick={subOption.onClick} + > + <div className={'h-4 w-4'}>{subOption.icon?.value || <DocumentSvg />}</div> + + <Typography variant='body1' className={'ml-2 text-xs'}> + {subOption.label || t('document.title.placeholder')} + </Typography> + </MenuItem> + ); + })} + </MenuList> + </div> + )) + )} + </div> + ); +} + +export default MentionPanelContent; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/command_panel/mention_panel/index.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/command_panel/mention_panel/index.ts new file mode 100644 index 0000000000..bfca34ef9a --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/command_panel/mention_panel/index.ts @@ -0,0 +1,2 @@ +export * from './MentionPanel'; +export * from './MentionPanelContent'; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/command_panel/slash_command_panel/SlashCommandPanel.hooks.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/command_panel/slash_command_panel/SlashCommandPanel.hooks.ts new file mode 100644 index 0000000000..13a4f929b3 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/command_panel/slash_command_panel/SlashCommandPanel.hooks.ts @@ -0,0 +1,249 @@ +import { EditorNodeType } from '$app/application/document/document.types'; +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useSlate } from 'slate-react'; +import { Editor, Transforms } from 'slate'; +import { getBlock } from '$app/components/editor/plugins/utils'; +import { ReactComponent as TextIcon } from '$app/assets/text.svg'; +import { ReactComponent as TodoListIcon } from '$app/assets/todo-list.svg'; +import { ReactComponent as Heading1Icon } from '$app/assets/h1.svg'; +import { ReactComponent as Heading2Icon } from '$app/assets/h2.svg'; +import { ReactComponent as Heading3Icon } from '$app/assets/h3.svg'; +import { ReactComponent as BulletedListIcon } from '$app/assets/list.svg'; +import { ReactComponent as NumberedListIcon } from '$app/assets/numbers.svg'; +import { ReactComponent as QuoteIcon } from '$app/assets/quote.svg'; +import { ReactComponent as ToggleListIcon } from '$app/assets/show-menu.svg'; +import { ReactComponent as GridIcon } from '$app/assets/grid.svg'; +import { DataObjectOutlined, FunctionsOutlined, HorizontalRuleOutlined, MenuBookOutlined } from '@mui/icons-material'; +import { CustomEditor } from '$app/components/editor/command'; + +enum SlashCommandPanelTab { + BASIC = 'basic', + ADVANCED = 'advanced', +} + +enum SlashOptionType { + Paragraph, + TodoList, + Heading1, + Heading2, + Heading3, + BulletedList, + NumberedList, + Quote, + ToggleList, + Divider, + Callout, + Code, + Grid, + MathEquation, +} +const slashOptionGroup = [ + { + key: SlashCommandPanelTab.BASIC, + options: [ + SlashOptionType.Paragraph, + SlashOptionType.TodoList, + SlashOptionType.Heading1, + SlashOptionType.Heading2, + SlashOptionType.Heading3, + SlashOptionType.BulletedList, + SlashOptionType.NumberedList, + SlashOptionType.Quote, + SlashOptionType.ToggleList, + SlashOptionType.Divider, + ], + }, + { + key: SlashCommandPanelTab.ADVANCED, + options: [SlashOptionType.Callout, SlashOptionType.Code, SlashOptionType.Grid, SlashOptionType.MathEquation], + }, +]; + +const slashOptionMapToEditorNodeType = { + [SlashOptionType.Paragraph]: EditorNodeType.Paragraph, + [SlashOptionType.TodoList]: EditorNodeType.TodoListBlock, + [SlashOptionType.Heading1]: EditorNodeType.HeadingBlock, + [SlashOptionType.Heading2]: EditorNodeType.HeadingBlock, + [SlashOptionType.Heading3]: EditorNodeType.HeadingBlock, + [SlashOptionType.BulletedList]: EditorNodeType.BulletedListBlock, + [SlashOptionType.NumberedList]: EditorNodeType.NumberedListBlock, + [SlashOptionType.Quote]: EditorNodeType.QuoteBlock, + [SlashOptionType.ToggleList]: EditorNodeType.ToggleListBlock, + [SlashOptionType.Divider]: EditorNodeType.DividerBlock, + [SlashOptionType.Callout]: EditorNodeType.CalloutBlock, + [SlashOptionType.Code]: EditorNodeType.CodeBlock, + [SlashOptionType.Grid]: EditorNodeType.GridBlock, + [SlashOptionType.MathEquation]: EditorNodeType.EquationBlock, +}; + +const headingTypeToLevelMap: Record<string, number> = { + [SlashOptionType.Heading1]: 1, + [SlashOptionType.Heading2]: 2, + [SlashOptionType.Heading3]: 3, +}; + +const headingTypes = [SlashOptionType.Heading1, SlashOptionType.Heading2, SlashOptionType.Heading3]; + +export function useSlashCommandPanel({ + searchText, + closePanel, + open, +}: { + searchText: string; + closePanel: (deleteText?: boolean) => void; + open: boolean; +}) { + const { t } = useTranslation(); + const editor = useSlate(); + const [selectedType, setSelectedType] = useState(SlashOptionType.Paragraph); + const onClick = useCallback( + (type: SlashOptionType) => { + const node = getBlock(editor); + + if (!node) return; + + const nodeType = slashOptionMapToEditorNodeType[type]; + + if (!nodeType) return; + + const data = headingTypes.includes(type) ? { level: headingTypeToLevelMap[type] } : {}; + + closePanel(true); + + const newNode = getBlock(editor); + + if (!newNode) return; + + const isEmpty = Editor.isEmpty(editor, newNode); + + if (isEmpty) { + CustomEditor.turnToBlock(editor, { + type: nodeType, + data, + }); + return; + } + + Transforms.splitNodes(editor, { always: true }); + Transforms.setNodes(editor, { type: nodeType, data }); + }, + [editor, closePanel] + ); + + const typeToLabelIconMap = useMemo(() => { + return { + [SlashOptionType.Paragraph]: { + label: t('editor.text'), + Icon: TextIcon, + }, + [SlashOptionType.TodoList]: { + label: t('document.plugins.todoList'), + Icon: TodoListIcon, + }, + [SlashOptionType.Heading1]: { + label: t('editor.heading1'), + Icon: Heading1Icon, + }, + [SlashOptionType.Heading2]: { + label: t('editor.heading2'), + Icon: Heading2Icon, + }, + [SlashOptionType.Heading3]: { + label: t('editor.heading3'), + Icon: Heading3Icon, + }, + [SlashOptionType.BulletedList]: { + label: t('editor.bulletedList'), + Icon: BulletedListIcon, + }, + [SlashOptionType.NumberedList]: { + label: t('editor.numberedList'), + Icon: NumberedListIcon, + }, + [SlashOptionType.Quote]: { + label: t('editor.quote'), + Icon: QuoteIcon, + }, + [SlashOptionType.ToggleList]: { + label: t('document.plugins.toggleList'), + Icon: ToggleListIcon, + }, + [SlashOptionType.Divider]: { + label: t('editor.divider'), + Icon: HorizontalRuleOutlined, + }, + [SlashOptionType.Callout]: { + label: t('document.plugins.callout'), + Icon: MenuBookOutlined, + }, + [SlashOptionType.Code]: { + label: t('document.selectionMenu.codeBlock'), + Icon: DataObjectOutlined, + }, + [SlashOptionType.Grid]: { + label: t('grid.menuName'), + Icon: GridIcon, + }, + + [SlashOptionType.MathEquation]: { + label: t('document.plugins.mathEquation.name'), + Icon: FunctionsOutlined, + }, + }; + }, [t]); + + const groupTypeToLabelMap = useMemo(() => { + return { + [SlashCommandPanelTab.BASIC]: 'Basic', + [SlashCommandPanelTab.ADVANCED]: 'Advanced', + }; + }, []); + + const options = useMemo(() => { + return slashOptionGroup + .map((group) => { + return { + key: group.key, + label: groupTypeToLabelMap[group.key], + options: group.options + .map((type) => { + return { + key: type, + label: typeToLabelIconMap[type].label, + Icon: typeToLabelIconMap[type].Icon, + onClick: () => onClick(type), + }; + }) + .filter((option) => { + if (!searchText) return true; + return option.label.toLowerCase().includes(searchText.toLowerCase()); + }), + }; + }) + .filter((group) => group.options.length > 0); + }, [groupTypeToLabelMap, onClick, searchText, typeToLabelIconMap]); + + useEffect(() => { + if (open) { + const node = getBlock(editor); + + if (!node) return; + const nodeType = node.type; + + const optionType = Object.entries(slashOptionMapToEditorNodeType).find(([, type]) => type === nodeType); + + if (optionType) { + setSelectedType(Number(optionType[0])); + } + } else { + setSelectedType(SlashOptionType.Paragraph); + } + }, [editor, open]); + + return { + options, + selectedType, + setSelectedType, + }; +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/command_panel/slash_command_panel/SlashCommandPanel.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/command_panel/slash_command_panel/SlashCommandPanel.tsx new file mode 100644 index 0000000000..967a699bad --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/command_panel/slash_command_panel/SlashCommandPanel.tsx @@ -0,0 +1,38 @@ +import React, { useRef } from 'react'; +import { PanelPopoverProps, usePanel } from '$app/components/editor/components/tools/command_panel/usePanel.hooks'; +import Popover from '@mui/material/Popover'; +import SlashCommandPanelContent from '$app/components/editor/components/tools/command_panel/slash_command_panel/SlashCommandPanelContent'; +import { useSlate } from 'slate-react'; + +export function SlashCommandPanel() { + const ref = useRef<HTMLDivElement>(null); + const editor = useSlate(); + const { anchorPosition, closePanel, searchText } = usePanel(ref); + + const open = Boolean(anchorPosition); + + return ( + <div ref={ref} className={'slash-command-panel'}> + {open && ( + <Popover + {...PanelPopoverProps} + open={open} + anchorPosition={anchorPosition} + onClose={() => { + const selection = editor.selection; + + closePanel(false); + + if (selection) { + editor.select(selection); + } + }} + > + <SlashCommandPanelContent closePanel={closePanel} searchText={searchText} /> + </Popover> + )} + </div> + ); +} + +export default SlashCommandPanel; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/command_panel/slash_command_panel/SlashCommandPanelContent.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/command_panel/slash_command_panel/SlashCommandPanelContent.tsx new file mode 100644 index 0000000000..1fe412549c --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/command_panel/slash_command_panel/SlashCommandPanelContent.tsx @@ -0,0 +1,76 @@ +import React, { useCallback, useRef } from 'react'; +import { MenuItem, MenuList, Typography } from '@mui/material'; +import { useSlashCommandPanel } from '$app/components/editor/components/tools/command_panel/slash_command_panel/SlashCommandPanel.hooks'; +import { useKeyDown } from '$app/components/editor/components/tools/command_panel/usePanel.hooks'; + +function SlashCommandPanelContent({ + closePanel, + searchText, +}: { + closePanel: (deleteText?: boolean) => void; + searchText: string; +}) { + const scrollRef = useRef<HTMLDivElement>(null); + + const { options, selectedType, setSelectedType } = useSlashCommandPanel({ + searchText, + closePanel, + open: true, + }); + + const handleSelectType = useCallback( + (type?: string | number) => { + if (type === undefined) return; + + setSelectedType(Number(type)); + }, + [setSelectedType] + ); + + useKeyDown({ + scrollRef, + panelOpen: true, + closePanel, + options, + selectedKey: selectedType, + setSelectedKey: handleSelectType, + }); + return ( + <div ref={scrollRef} className={'max-h-[360px] w-[220px] overflow-auto overflow-x-hidden py-1 pl-1'}> + {options.length > 0 ? ( + options.map((group) => ( + <div key={group.key}> + <Typography variant='body1' className={'p-2 text-text-caption'}> + {group.label} + </Typography> + <MenuList className={'py-0 pl-1'}> + {group.options.map((subOption) => { + const Icon = subOption.Icon; + + return ( + <MenuItem + onMouseEnter={() => setSelectedType(subOption.key)} + selected={selectedType === subOption.key} + data-type={subOption.key} + className={'ml-0 flex w-full items-center justify-start'} + key={subOption.key} + onClick={subOption.onClick} + > + <Icon className={'mr-2 h-4 w-4'} /> + <div className={'flex-1'}>{subOption.label}</div> + </MenuItem> + ); + })} + </MenuList> + </div> + )) + ) : ( + <Typography variant='body1' className={'p-3 text-text-caption'}> + No results + </Typography> + )} + </div> + ); +} + +export default SlashCommandPanelContent; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/command_panel/slash_command_panel/index.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/command_panel/slash_command_panel/index.ts new file mode 100644 index 0000000000..688a6ffb7d --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/command_panel/slash_command_panel/index.ts @@ -0,0 +1,2 @@ +export * from './SlashCommandPanel'; +export * from './SlashCommandPanelContent'; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/command_panel/usePanel.hooks.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/command_panel/usePanel.hooks.ts new file mode 100644 index 0000000000..a210949430 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/command_panel/usePanel.hooks.ts @@ -0,0 +1,258 @@ +import { useEffect, RefObject, useState, useCallback, useRef } from 'react'; +import { getPanelPosition } from '$app/components/editor/components/tools/command_panel/utils'; +import { ReactEditor, useSlate } from 'slate-react'; +import { PopoverPreventBlurProps } from '$app/components/editor/components/tools/popover'; +import { PopoverProps } from '@mui/material/Popover'; +import { commandPanelShowProperty } from '$app/components/editor/components/editor/shortcuts/withCommandShortcuts'; +import { Editor, Point, Transforms } from 'slate'; +import { getBlockEntry } from '$app/components/editor/plugins/utils'; + +export const PanelPopoverProps: Partial<PopoverProps> = { + ...PopoverPreventBlurProps, + onMouseUp: (e) => e.stopPropagation(), + transformOrigin: { + vertical: -28, + horizontal: 'left', + }, + anchorReference: 'anchorPosition', +}; + +export function usePanel(ref: RefObject<HTMLDivElement | null>) { + const editor = useSlate(); + const [anchorPosition, setAnchorPosition] = useState< + | { + top: number; + left: number; + } + | undefined + >(undefined); + const startPoint = useRef<Point>(); + const endPoint = useRef<Point>(); + const open = Boolean(anchorPosition); + const [searchText, setSearchText] = useState(''); + + const closePanel = useCallback( + (deleteText?: boolean) => { + ref.current?.classList.remove(commandPanelShowProperty); + + if (deleteText && startPoint.current && endPoint.current) { + const anchor = { + path: startPoint.current.path, + offset: startPoint.current.offset - 1, + }; + const focus = { + path: endPoint.current.path, + offset: endPoint.current.offset, + }; + + Transforms.delete(editor, { + at: { + anchor, + focus, + }, + }); + } + + setAnchorPosition(undefined); + setSearchText(''); + }, + [editor, ref] + ); + + const setPosition = useCallback( + (position?: { left: number; top: number }) => { + if (!position) { + closePanel(false); + return; + } + + const nodeEntry = getBlockEntry(editor); + + if (!nodeEntry) return; + + setAnchorPosition({ + top: position.top, + left: position.left, + }); + }, + [closePanel, editor] + ); + + useEffect(() => { + const el = ref.current; + + if (!el) return; + + let prevState = el.classList.contains(commandPanelShowProperty); + const observer = new MutationObserver((mutations) => { + mutations.forEach((mutation) => { + const { target } = mutation; + + if (mutation.attributeName === 'class') { + const currentState = (target as HTMLElement).classList.contains(commandPanelShowProperty); + + if (prevState !== currentState) { + prevState = currentState; + if (currentState) { + const position = getPanelPosition(editor); + + if (position && editor.selection) { + startPoint.current = Editor.start(editor, editor.selection); + endPoint.current = Editor.end(editor, editor.selection); + setPosition(position); + } else { + setPosition(undefined); + } + } else { + setPosition(undefined); + } + } + } + }); + }); + + observer.observe(el, { attributes: true }); + + return () => { + observer.disconnect(); + }; + }, [setPosition, editor, ref]); + + useEffect(() => { + const { onChange } = editor; + + if (open) { + editor.onChange = (...args) => { + if (!editor.selection || !startPoint.current || !endPoint.current) return; + onChange(...args); + const isSelectionChange = editor.operations.every((op) => op.type === 'set_selection'); + const currentPoint = Editor.end(editor, editor.selection); + const isBackward = currentPoint.offset < startPoint.current.offset; + const isAnotherBlock = + currentPoint.path[0] !== startPoint.current.path[0] || currentPoint.path[1] !== startPoint.current.path[1]; + + if (isAnotherBlock || isBackward) { + closePanel(false); + return; + } + + if (!isSelectionChange) { + if (currentPoint.offset > endPoint.current?.offset) { + endPoint.current = currentPoint; + } + + const text = Editor.string(editor, { + anchor: startPoint.current, + focus: endPoint.current, + }); + + setSearchText(text); + } else { + const isForward = currentPoint.offset > endPoint.current.offset; + + if (isForward) { + closePanel(false); + } + } + }; + } else { + editor.onChange = onChange; + } + + return () => { + editor.onChange = onChange; + }; + }, [open, editor, closePanel]); + + return { + anchorPosition, + closePanel, + searchText, + }; +} + +export function useKeyDown({ + scrollRef: ref, + options, + panelOpen: open, + setSelectedKey, + selectedKey, + closePanel, +}: { + panelOpen: boolean; + selectedKey?: string | number; + setSelectedKey: (key?: string | number) => void; + closePanel: (deleteText?: boolean) => void; + scrollRef: RefObject<HTMLDivElement | null>; + options: { + key: string | number; + label: string; + options: { key: string | number; label: string; onClick: () => void }[]; + }[]; +}) { + const editor = useSlate(); + const handleKeyDown = useCallback( + (e: KeyboardEvent) => { + const flattenOptions = options.flatMap((group) => group.options); + + const index = flattenOptions.findIndex((option) => option.key === selectedKey); + const option = flattenOptions[index]; + const nextIndex = (index + 1) % flattenOptions.length; + const prevIndex = (index - 1 + flattenOptions.length) % flattenOptions.length; + + switch (e.key) { + case 'Escape': + e.preventDefault(); + closePanel(false); + break; + case 'ArrowDown': { + e.preventDefault(); + const nextOption = flattenOptions[nextIndex].key; + const dom = ref.current?.querySelector(`[data-type="${nextOption}"]`); + + setSelectedKey(nextOption); + + dom?.scrollIntoView({ + behavior: 'smooth', + block: 'end', + }); + break; + } + + case 'ArrowUp': { + e.preventDefault(); + const prevOption = flattenOptions[prevIndex].key; + const prevDom = ref.current?.querySelector(`[data-type="${prevOption}"]`); + + setSelectedKey(prevOption); + prevDom?.scrollIntoView({ + behavior: 'smooth', + block: 'end', + }); + break; + } + + case 'Enter': + case 'Tab': + e.preventDefault(); + option.onClick(); + break; + } + }, + [selectedKey, options, closePanel, ref, setSelectedKey] + ); + + useEffect(() => { + const slateDom = ReactEditor.toDOMNode(editor, editor); + + if (open) { + slateDom.addEventListener('keydown', handleKeyDown); + } else { + slateDom.removeEventListener('keydown', handleKeyDown); + } + + return () => { + slateDom.removeEventListener('keydown', handleKeyDown); + }; + }, [editor, handleKeyDown, open]); +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/command_panel/utils.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/command_panel/utils.ts new file mode 100644 index 0000000000..b10aa021ff --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/command_panel/utils.ts @@ -0,0 +1,27 @@ +import { ReactEditor } from 'slate-react'; + +export function getPanelPosition(editor: ReactEditor) { + const { selection } = editor; + + const isFocused = ReactEditor.isFocused(editor); + + if (!selection || !isFocused) { + return null; + } + + const domSelection = window.getSelection(); + const rangeCount = domSelection?.rangeCount; + + if (!rangeCount) return null; + + const domRange = rangeCount > 0 ? domSelection.getRangeAt(0) : undefined; + + const rect = domRange?.getBoundingClientRect(); + + if (!rect) return null; + return { + ...rect, + top: rect.top, + left: rect.left, + }; +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/notify/index.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/notify/index.ts new file mode 100644 index 0000000000..0cfd6a5f10 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/notify/index.ts @@ -0,0 +1,21 @@ +import toast from 'react-hot-toast'; + +const commonOptions = { + style: { + background: 'var(--bg-base)', + color: 'var(--text-title)', + shadows: 'var(--shadow)', + }, +}; + +export const notify = { + success: (message: string) => { + toast.success(message, commonOptions); + }, + error: (message: string) => { + toast.error(message, commonOptions); + }, + loading: (message: string) => { + toast.loading(message, commonOptions); + }, +}; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/popover.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/popover.ts new file mode 100644 index 0000000000..2b6a715baa --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/popover.ts @@ -0,0 +1,34 @@ +import { PopoverProps } from '@mui/material/Popover'; + +export const PopoverCommonProps: Partial<PopoverProps> = { + keepMounted: false, + disableAutoFocus: true, + disableEnforceFocus: true, + disableRestoreFocus: true, +}; + +export const PopoverPreventBlurProps: Partial<PopoverProps> = { + ...PopoverCommonProps, + + onMouseDown: (e) => { + // prevent editor blur + e.preventDefault(); + e.stopPropagation(); + }, +}; + +export const PopoverNoBackdropProps: Partial<PopoverProps> = { + ...PopoverCommonProps, + sx: { + pointerEvents: 'none', + }, + PaperProps: { + style: { + pointerEvents: 'auto', + }, + }, + onMouseDown: (e) => { + // prevent editor blur + e.stopPropagation(); + }, +}; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/SelectionActions.hooks.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/SelectionActions.hooks.tsx new file mode 100644 index 0000000000..51f59eb242 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/SelectionActions.hooks.tsx @@ -0,0 +1,485 @@ +import { + EditorInlineAttributes, + EditorInlineNodeType, + EditorMarkFormat, + EditorNodeType, + EditorStyleFormat, + EditorTurnFormat, + HeadingNode, +} from '$app/application/document/document.types'; + +import { ReactComponent as BoldSvg } from '$app/assets/bold.svg'; +import { ReactComponent as UnderlineSvg } from '$app/assets/underline.svg'; +import { ReactComponent as StrikeThroughSvg } from '$app/assets/strikethrough.svg'; +import { ReactComponent as ItalicSvg } from '$app/assets/italic.svg'; +import { ReactComponent as CodeSvg } from '$app/assets/inline-code.svg'; +import { ReactComponent as Heading1Svg } from '$app/assets/h1.svg'; +import { ReactComponent as Heading2Svg } from '$app/assets/h2.svg'; +import { ReactComponent as Heading3Svg } from '$app/assets/h3.svg'; +import { ReactComponent as ParagraphSvg } from '$app/assets/text.svg'; +import { ReactComponent as TodoListSvg } from '$app/assets/todo-list.svg'; +import { ReactComponent as QuoteSvg } from '$app/assets/quote.svg'; +import { ReactComponent as ToggleListSvg } from '$app/assets/show-menu.svg'; +import { ReactComponent as NumberedListSvg } from '$app/assets/numbers.svg'; +import { ReactComponent as BulletedListSvg } from '$app/assets/list.svg'; +import { ReactComponent as LinkSvg } from '$app/assets/link.svg'; + +import FormatColorFillIcon from '@mui/icons-material/FormatColorFill'; +import FormatColorTextIcon from '@mui/icons-material/FormatColorText'; +import Functions from '@mui/icons-material/Functions'; + +import { ReactEditor } from 'slate-react'; +import React, { useCallback, useMemo } from 'react'; +import { getBlock, getBlockEntry } from '$app/components/editor/plugins/utils'; +import { FontColorPicker, BgColorPicker } from '$app/components/editor/components/tools/selection_toolbar/sub_menu'; +import { useTranslation } from 'react-i18next'; +import { addMark, Editor } from 'slate'; +import { CustomEditor } from '$app/components/editor/command'; + +const markFormatActions = [ + EditorMarkFormat.Underline, + EditorMarkFormat.Bold, + EditorMarkFormat.Italic, + EditorMarkFormat.StrikeThrough, + EditorMarkFormat.Code, + EditorMarkFormat.Formula, +]; + +const styleFormatActions = [EditorStyleFormat.Href, EditorStyleFormat.FontColor, EditorStyleFormat.BackgroundColor]; + +const textFormatActions = [ + EditorTurnFormat.Paragraph, + EditorTurnFormat.Heading1, + EditorTurnFormat.Heading2, + EditorTurnFormat.Heading3, +]; + +const blockFormatActions = [ + EditorTurnFormat.TodoList, + EditorTurnFormat.Quote, + EditorTurnFormat.ToggleList, + EditorTurnFormat.NumberedList, + EditorTurnFormat.BulletedList, +]; + +export interface SelectionAction { + format: EditorMarkFormat | EditorTurnFormat | EditorStyleFormat; + Icon: React.FunctionComponent<React.SVGProps<SVGSVGElement>>; + text: string; + isActive: () => boolean; + onClick: ((e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void) | (() => void); + alwaysInSingleLine?: boolean; +} + +export function useSelectionMarkFormatActions(editor: ReactEditor) { + const { t } = useTranslation(); + const formatMark = useCallback( + (format: EditorMarkFormat) => { + CustomEditor.toggleMark(editor, { + key: format, + value: true, + }); + }, + [editor] + ); + const isFormatActive = useCallback( + (format: EditorMarkFormat) => { + return CustomEditor.isMarkActive(editor, format); + }, + [editor] + ); + + return useMemo(() => { + const map = { + [EditorMarkFormat.Bold]: { + format: EditorMarkFormat.Bold, + Icon: BoldSvg, + text: t('editor.bold'), + isActive: () => { + return isFormatActive(EditorMarkFormat.Bold); + }, + onClick: () => { + formatMark(EditorMarkFormat.Bold); + }, + }, + [EditorMarkFormat.Italic]: { + format: EditorMarkFormat.Italic, + Icon: ItalicSvg, + text: t('editor.italic'), + isActive: () => { + return isFormatActive(EditorMarkFormat.Italic); + }, + onClick: () => { + formatMark(EditorMarkFormat.Italic); + }, + }, + [EditorMarkFormat.Underline]: { + format: EditorMarkFormat.Underline, + Icon: UnderlineSvg, + text: t('editor.underline'), + isActive: () => { + return isFormatActive(EditorMarkFormat.Underline); + }, + onClick: () => { + formatMark(EditorMarkFormat.Underline); + }, + }, + [EditorMarkFormat.StrikeThrough]: { + format: EditorMarkFormat.StrikeThrough, + Icon: StrikeThroughSvg, + text: t('editor.strikethrough'), + isActive: () => { + return isFormatActive(EditorMarkFormat.StrikeThrough); + }, + onClick: () => { + formatMark(EditorMarkFormat.StrikeThrough); + }, + }, + [EditorMarkFormat.Code]: { + format: EditorMarkFormat.Code, + Icon: CodeSvg, + text: t('editor.embedCode'), + isActive: () => { + return isFormatActive(EditorMarkFormat.Code); + }, + onClick: () => { + formatMark(EditorMarkFormat.Code); + }, + }, + [EditorMarkFormat.Formula]: { + format: EditorMarkFormat.Formula, + Icon: Functions, + alwaysInSingleLine: true, + text: t('document.plugins.createInlineMathEquation'), + isActive: () => { + return CustomEditor.isFormulaActive(editor); + }, + onClick: () => { + CustomEditor.toggleInlineElement(editor, EditorInlineNodeType.Formula); + }, + }, + }; + + return markFormatActions.map((format) => map[format]) as SelectionAction[]; + }, [editor, formatMark, isFormatActive, t]); +} + +export function useBlockFormatActionMap(editor: ReactEditor) { + const { t } = useTranslation(); + + return useMemo(() => { + const toHeading = (level: number) => { + CustomEditor.turnToBlock(editor, { + type: EditorNodeType.HeadingBlock, + data: { + level, + }, + }); + }; + + return { + [EditorTurnFormat.Paragraph]: { + format: EditorTurnFormat.Paragraph, + text: t('editor.text'), + onClick: () => { + const node = getBlock(editor); + + if (!node) return; + + CustomEditor.turnToBlock(editor, { + type: EditorNodeType.Paragraph, + }); + }, + Icon: ParagraphSvg, + isActive: () => { + return CustomEditor.isBlockActive(editor, EditorTurnFormat.Paragraph); + }, + }, + [EditorTurnFormat.Heading1]: { + format: EditorTurnFormat.Heading1, + text: t('editor.heading1'), + Icon: Heading1Svg, + onClick: () => { + toHeading(1); + }, + isActive: () => { + const node = getBlock(editor) as HeadingNode; + + if (!node) return false; + const isBlock = CustomEditor.isBlockActive(editor, EditorNodeType.HeadingBlock); + + return isBlock && node.data.level === 1; + }, + }, + [EditorTurnFormat.Heading2]: { + format: EditorTurnFormat.Heading2, + Icon: Heading2Svg, + text: t('editor.heading2'), + onClick: () => { + toHeading(2); + }, + isActive: () => { + const node = getBlock(editor) as HeadingNode; + + if (!node) return false; + const isBlock = CustomEditor.isBlockActive(editor, EditorNodeType.HeadingBlock); + + return isBlock && node.data.level === 2; + }, + }, + [EditorTurnFormat.Heading3]: { + format: EditorTurnFormat.Heading3, + Icon: Heading3Svg, + text: t('editor.heading3'), + onClick: () => { + toHeading(3); + }, + isActive: () => { + const node = getBlock(editor) as HeadingNode; + + if (!node) return false; + const isBlock = CustomEditor.isBlockActive(editor, EditorNodeType.HeadingBlock); + + return isBlock && node.data.level === 3; + }, + }, + [EditorTurnFormat.TodoList]: { + format: EditorTurnFormat.TodoList, + text: t('document.plugins.todoList'), + onClick: () => { + const node = getBlock(editor); + + if (!node) return; + + CustomEditor.turnToBlock(editor, { + type: EditorNodeType.TodoListBlock, + }); + }, + Icon: TodoListSvg, + isActive: () => { + const entry = getBlockEntry(editor); + + if (!entry) return false; + + const node = entry[0]; + + return node.type === EditorNodeType.TodoListBlock; + }, + }, + [EditorTurnFormat.Quote]: { + format: EditorTurnFormat.Quote, + text: t('editor.quote'), + onClick: () => { + const node = getBlock(editor); + + if (!node) return; + CustomEditor.turnToBlock(editor, { + type: EditorNodeType.QuoteBlock, + }); + }, + Icon: QuoteSvg, + isActive: () => { + const entry = getBlockEntry(editor); + + if (!entry) return false; + + const node = entry[0]; + + return node.type === EditorNodeType.QuoteBlock; + }, + }, + [EditorTurnFormat.ToggleList]: { + format: EditorTurnFormat.ToggleList, + text: t('document.plugins.toggleList'), + onClick: () => { + const node = getBlock(editor); + + if (!node) return; + + CustomEditor.turnToBlock(editor, { + type: EditorNodeType.ToggleListBlock, + }); + }, + Icon: ToggleListSvg, + isActive: () => { + const entry = getBlockEntry(editor); + + if (!entry) return false; + + const node = entry[0]; + + return node.type === EditorNodeType.ToggleListBlock; + }, + }, + [EditorTurnFormat.NumberedList]: { + format: EditorTurnFormat.NumberedList, + text: t('document.plugins.numberedList'), + onClick: () => { + const node = getBlock(editor); + + if (!node) return; + + CustomEditor.turnToBlock(editor, { + type: EditorNodeType.NumberedListBlock, + }); + }, + Icon: NumberedListSvg, + isActive: () => { + const entry = getBlockEntry(editor); + + if (!entry) return false; + + const node = entry[0]; + + return node.type === EditorNodeType.NumberedListBlock; + }, + }, + [EditorTurnFormat.BulletedList]: { + format: EditorTurnFormat.BulletedList, + text: t('document.plugins.bulletedList'), + onClick: () => { + const node = getBlock(editor); + + if (!node) return; + + CustomEditor.turnToBlock(editor, { + type: EditorNodeType.BulletedListBlock, + }); + }, + Icon: BulletedListSvg, + isActive: () => { + const entry = getBlockEntry(editor); + + if (!entry) return false; + + const node = entry[0]; + + return node.type === EditorNodeType.BulletedListBlock; + }, + }, + }; + }, [editor, t]); +} + +export function useSelectionTextFormatActions(editor: ReactEditor): SelectionAction[] { + const map = useBlockFormatActionMap(editor); + + return useMemo(() => { + return textFormatActions.map((action) => map[action]); + }, [map]); +} + +export function useBlockFormatActions(editor: ReactEditor): SelectionAction[] { + const map = useBlockFormatActionMap(editor); + + return useMemo(() => { + return blockFormatActions.map((action) => map[action]); + }, [map]); +} + +export function useSelectionStyleFormatActions( + editor: ReactEditor, + { + onPopoverOpen, + onFocus, + onBlur, + onPopoverClose, + }: { + onPopoverOpen: (format: EditorStyleFormat, target: HTMLButtonElement) => void; + onPopoverClose: () => void; + onFocus: () => void; + onBlur: () => void; + } +) { + const handleStyleChange = useCallback( + (format: EditorStyleFormat, value: string) => { + onPopoverClose(); + addMark(editor, format, value); + }, + [editor, onPopoverClose] + ); + + const subMenu = useCallback( + (format: EditorStyleFormat) => { + if (!editor.selection) return null; + const entry = editor.node(editor.selection); + const node = entry[0] as EditorInlineAttributes; + + switch (format) { + case EditorStyleFormat.Href: + return null; + case EditorStyleFormat.BackgroundColor: + return ( + <BgColorPicker + onBlur={onBlur} + onFocus={onFocus} + color={node.bg_color} + onChange={(color) => handleStyleChange(format, color)} + /> + ); + case EditorStyleFormat.FontColor: + return ( + <FontColorPicker + onBlur={onBlur} + onFocus={onFocus} + color={node.font_color} + onChange={(color) => handleStyleChange(format, color)} + /> + ); + } + }, + [editor, handleStyleChange, onBlur, onFocus] + ); + const { t } = useTranslation(); + + const options = useMemo(() => { + const handleClick = (e: React.MouseEvent<HTMLButtonElement, MouseEvent>, format: EditorStyleFormat) => { + onPopoverOpen(format, e.currentTarget); + }; + + const map = { + [EditorStyleFormat.Href]: { + format: EditorStyleFormat.Href, + Icon: LinkSvg, + text: t('editor.link'), + alwaysInSingleLine: true, + isActive: () => { + return CustomEditor.isMarkActive(editor, EditorStyleFormat.Href); + }, + onClick: () => { + if (!editor.selection) return; + const text = Editor.string(editor, editor.selection); + + addMark(editor, EditorStyleFormat.Href, text); + }, + }, + [EditorStyleFormat.FontColor]: { + format: EditorStyleFormat.FontColor, + Icon: FormatColorTextIcon, + text: t('editor.textColor'), + isActive: () => { + return CustomEditor.isMarkActive(editor, EditorStyleFormat.FontColor); + }, + onClick: (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => handleClick(e, EditorStyleFormat.FontColor), + }, + [EditorStyleFormat.BackgroundColor]: { + format: EditorStyleFormat.BackgroundColor, + Icon: FormatColorFillIcon, + text: t('editor.backgroundColor'), + isActive: () => { + return CustomEditor.isMarkActive(editor, EditorStyleFormat.BackgroundColor); + }, + onClick: (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => + handleClick(e, EditorStyleFormat.BackgroundColor), + }, + }; + + return styleFormatActions.map((format) => map[format]) as SelectionAction[]; + }, [t, editor, onPopoverOpen]); + + return { + options, + handleStyleChange, + subMenu, + }; +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/SelectionActions.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/SelectionActions.tsx new file mode 100644 index 0000000000..6f780c65b8 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/SelectionActions.tsx @@ -0,0 +1,140 @@ +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; + +import { ReactEditor, useSlate } from 'slate-react'; +import IconButton from '@mui/material/IconButton'; + +import { + SelectionAction, + useBlockFormatActions, + useSelectionMarkFormatActions, + useSelectionStyleFormatActions, + useSelectionTextFormatActions, +} from '$app/components/editor/components/tools/selection_toolbar/SelectionActions.hooks'; +import Popover from '@mui/material/Popover'; +import { EditorStyleFormat } from '$app/application/document/document.types'; +import { PopoverPreventBlurProps } from '$app/components/editor/components/tools/popover'; +import { Tooltip } from '@mui/material'; + +function SelectionActions({ + toolbarVisible, + storeSelection, + restoreSelection, +}: { + toolbarVisible: boolean; + storeSelection: () => void; + restoreSelection: () => void; +}) { + const ref = useRef<HTMLDivElement>(null); + const editor = useSlate() as ReactEditor; + const [anchorEl, setAnchorEl] = useState<HTMLButtonElement | null>(null); + const [menuType, setMenuType] = useState<EditorStyleFormat | null>(null); + const open = Boolean(anchorEl); + const handlePopoverOpen = useCallback((format: EditorStyleFormat, target: HTMLButtonElement) => { + setAnchorEl(target); + setMenuType(format); + }, []); + + const handleFocus = useCallback(() => { + storeSelection(); + }, [storeSelection]); + + const handleBlur = useCallback(() => { + restoreSelection(); + }, [restoreSelection]); + + const handlePopoverClose = useCallback(() => { + setAnchorEl(null); + setMenuType(null); + handleBlur(); + }, [handleBlur]); + + const isMultiple = editor.getFragment().length > 1; + const markOptions = useSelectionMarkFormatActions(editor); + const textOptions = useSelectionTextFormatActions(editor); + const blockOptions = useBlockFormatActions(editor); + const { options: styleOptions, subMenu: styleSubMenu } = useSelectionStyleFormatActions(editor, { + onPopoverOpen: handlePopoverOpen, + onPopoverClose: handlePopoverClose, + onFocus: handleFocus, + onBlur: handleBlur, + }); + + const subMenu = useMemo(() => { + if (!menuType) return null; + + return styleSubMenu(menuType); + }, [menuType, styleSubMenu]); + + const group = useMemo(() => { + const base = [markOptions, styleOptions]; + + if (isMultiple) { + const filter = (option: SelectionAction) => { + return !option.alwaysInSingleLine; + }; + + return [markOptions.filter(filter), styleOptions.filter(filter)]; + } + + return [textOptions, ...base, blockOptions]; + }, [markOptions, styleOptions, isMultiple, textOptions, blockOptions]); + + useEffect(() => { + if (!toolbarVisible) { + handlePopoverClose(); + } + }, [toolbarVisible, handlePopoverClose]); + + return ( + <div ref={ref} className={'flex w-fit flex-grow items-center'}> + {group.map((item, index) => { + return ( + <div key={index} className={index > 0 ? 'border-l border-gray-500' : ''}> + {item.map((action) => { + const { format, Icon, text, onClick, isActive } = action; + + const isActivated = isActive(); + + return ( + <Tooltip placement={'top'} title={text} key={format}> + <IconButton + onClick={onClick} + size={'small'} + className={`bg-transparent px-1.5 py-0 text-bg-body hover:bg-transparent`} + > + <Icon + style={{ + color: isActivated ? 'var(--fill-default)' : undefined, + }} + className={'h-4 w-4 text-lg text-bg-body hover:text-fill-hover'} + /> + </IconButton> + </Tooltip> + ); + })} + </div> + ); + })} + {open && ( + <Popover + {...PopoverPreventBlurProps} + anchorEl={anchorEl} + open={open} + onClose={handlePopoverClose} + anchorOrigin={{ + vertical: 30, + horizontal: 'left', + }} + onMouseUp={(e) => { + // prevent editor blur + e.stopPropagation(); + }} + > + {subMenu} + </Popover> + )} + </div> + ); +} + +export default SelectionActions; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/SelectionToolbar.hooks.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/SelectionToolbar.hooks.ts new file mode 100644 index 0000000000..22f9e9f4e9 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/SelectionToolbar.hooks.ts @@ -0,0 +1,78 @@ +import { ReactEditor, useSlate } from 'slate-react'; +import { MutableRefObject, useCallback, useEffect, useRef, useState } from 'react'; +import { getSelectionPosition } from '$app/components/editor/components/tools/selection_toolbar/utils'; +import debounce from 'lodash-es/debounce'; + +export function useSelectionToolbar(ref: MutableRefObject<HTMLDivElement | null>) { + const editor = useSlate() as ReactEditor; + + const [visible, setVisible] = useState(false); + const rangeRef = useRef<Range | null>(null); + + const recalculatePosition = useCallback(() => { + const el = ref.current; + + if (!el) { + return; + } + + if (rangeRef.current) { + return; + } + + const position = getSelectionPosition(editor); + + if (!position) { + rangeRef.current = null; + setVisible(false); + el.style.opacity = '0'; + el.style.pointerEvents = 'none'; + return; + } + + const slateEditorDom = ReactEditor.toDOMNode(editor, editor); + + setVisible(true); + el.style.opacity = '1'; + el.style.pointerEvents = 'auto'; + el.style.top = `${position.top + slateEditorDom.offsetTop - el.offsetHeight}px`; + el.style.left = `${position.left + slateEditorDom.offsetLeft - el.offsetWidth / 2 + position.width / 2}px`; + }, [editor, ref]); + + useEffect(() => { + const debounceRecalculatePosition = debounce(recalculatePosition, 100); + + document.addEventListener('mouseup', debounceRecalculatePosition); + document.addEventListener('keydown', debounceRecalculatePosition); + return () => { + document.removeEventListener('mouseup', debounceRecalculatePosition); + document.addEventListener('keydown', debounceRecalculatePosition); + }; + }, [editor, recalculatePosition, ref]); + + const restoreSelection = useCallback(() => { + if (!rangeRef.current) return; + const windowSelection = window.getSelection(); + + if (!windowSelection) return; + windowSelection.removeAllRanges(); + windowSelection.addRange(rangeRef.current); + rangeRef.current = null; + }, []); + + const storeSelection = useCallback(() => { + const windowSelection = window.getSelection(); + + if (!windowSelection) return; + + if (windowSelection.rangeCount === 0) return; + + rangeRef.current = windowSelection.getRangeAt(0); + }, []); + + return { + visible, + restoreSelection, + storeSelection, + }; +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/SelectionToolbar.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/SelectionToolbar.tsx new file mode 100644 index 0000000000..0752da4b3c --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/SelectionToolbar.tsx @@ -0,0 +1,27 @@ +import React, { memo, useRef } from 'react'; +import { useSelectionToolbar } from '$app/components/editor/components/tools/selection_toolbar/SelectionToolbar.hooks'; +import SelectionActions from '$app/components/editor/components/tools/selection_toolbar/SelectionActions'; + +export const SelectionToolbar = memo(() => { + const ref = useRef<HTMLDivElement | null>(null); + + const { visible, ...toolbarProps } = useSelectionToolbar(ref); + + return ( + <div + ref={ref} + className={ + 'selection-toolbar pointer-events-none absolute z-10 flex w-fit flex-grow transform items-center rounded-lg bg-[var(--fill-toolbar)] p-2 opacity-0 shadow-lg transition-opacity' + } + onMouseDown={(e) => { + // prevent toolbar from taking focus away from editor + e.preventDefault(); + }} + onMouseUp={(e) => { + e.stopPropagation(); + }} + > + <SelectionActions {...toolbarProps} toolbarVisible={visible} /> + </div> + ); +}); diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/index.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/index.ts new file mode 100644 index 0000000000..a6ced3f248 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/index.ts @@ -0,0 +1 @@ +export * from './SelectionToolbar'; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/sub_menu/BgColorPicker.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/sub_menu/BgColorPicker.tsx new file mode 100644 index 0000000000..1ce5523277 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/sub_menu/BgColorPicker.tsx @@ -0,0 +1,12 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { + ColorPicker, + ColorPickerProps, +} from '$app/components/editor/components/tools/selection_toolbar/sub_menu/ColorPicker'; + +export function BgColorPicker(props: ColorPickerProps) { + const { t } = useTranslation(); + + return <ColorPicker {...props} label={t('editor.backgroundColor')} />; +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/sub_menu/ColorPicker.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/sub_menu/ColorPicker.tsx new file mode 100644 index 0000000000..ed6a056a3b --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/sub_menu/ColorPicker.tsx @@ -0,0 +1,161 @@ +import React, { useEffect, useMemo, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { CustomColorPicker } from '$app/components/editor/components/tools/selection_toolbar/sub_menu/CustomColorPicker'; +import Typography from '@mui/material/Typography'; +import { MenuItem, MenuList } from '@mui/material'; +import { ReactComponent as SelectCheckSvg } from '$app/assets/select-check.svg'; +import { ReactComponent as MoreSvg } from '$app/assets/more.svg'; +import { PopoverNoBackdropProps } from '$app/components/editor/components/tools/popover'; + +export interface ColorPickerProps { + onFocus?: () => void; + onBlur?: () => void; + label?: string; + color?: string; + onChange?: (color: string) => void; +} +export function ColorPicker({ onFocus, onBlur, label, color, onChange }: ColorPickerProps) { + const { t } = useTranslation(); + const colors = useMemo( + () => [ + { + key: 'default', + name: t('colors.default'), + color: '', + }, + { + key: 'gray', + name: t('colors.gray'), + color: '#78909c', + }, + { + key: 'brown', + name: t('colors.brown'), + color: '#8d6e63', + }, + { + key: 'orange', + name: t('colors.orange'), + color: '#ff9100', + }, + { + key: 'yellow', + name: t('colors.yellow'), + color: '#ffd600', + }, + { + key: 'green', + name: t('colors.green'), + color: '#00e676', + }, + { + key: 'blue', + name: t('colors.blue'), + color: '#448aff', + }, + { + key: 'purple', + name: t('colors.purple'), + color: '#e040fb', + }, + { + key: 'pink', + name: t('colors.pink'), + color: '#ff4081', + }, + { + key: 'red', + name: t('colors.red'), + color: '#ff5252', + }, + ], + [t] + ); + + const [openCustomColorPicker, setOpenCustomColorPicker] = useState(false); + + const customItemRef = useRef<HTMLLIElement | null>(null); + + useEffect(() => { + if (openCustomColorPicker) { + onFocus?.(); + } else { + onBlur?.(); + } + }, [openCustomColorPicker, onFocus, onBlur]); + + return ( + <div className={'flex min-w-[150px] flex-col'}> + <Typography className={'px-3 pt-3 text-text-caption'} variant='subtitle2'> + {label} + </Typography> + <MenuList disabledItemsFocusable={true}> + <MenuItem + onMouseEnter={() => { + setOpenCustomColorPicker(true); + }} + onMouseLeave={() => { + setOpenCustomColorPicker(false); + }} + className={'flex px-2 py-1'} + ref={customItemRef} + > + <div className={'flex-1'}>{t('colors.custom')}</div> + <MoreSvg className={'h-4 w-4'} /> + {openCustomColorPicker && ( + <CustomColorPicker + anchorEl={customItemRef.current} + open={openCustomColorPicker} + onColorChange={onChange} + onMouseDown={(e) => e.stopPropagation()} + {...PopoverNoBackdropProps} + onClose={() => { + setOpenCustomColorPicker(false); + }} + onMouseUp={(e) => { + // prevent editor blur + e.stopPropagation(); + }} + anchorOrigin={{ + vertical: 'top', + horizontal: 'right', + }} + transformOrigin={{ + vertical: 'top', + horizontal: 'left', + }} + /> + )} + </MenuItem> + {colors.map((c) => { + return ( + <MenuItem + className={'flex px-2 py-1'} + key={c.key} + onClick={() => { + onChange?.(c.color); + }} + > + <div + className={'mr-2 flex h-4 w-4 items-center justify-center rounded-full border-2 p-0.5'} + style={{ + borderColor: c.color, + }} + > + <div + className={'h-2 w-2 rounded-full'} + style={{ + backgroundColor: c.color, + }} + /> + </div> + <div className={'flex-1'}>{c.name}</div> + + {color && color === c.color && <SelectCheckSvg />} + </MenuItem> + ); + })} + </MenuList> + </div> + ); +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/sub_menu/CustomColorPicker.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/sub_menu/CustomColorPicker.tsx new file mode 100644 index 0000000000..edded7f874 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/sub_menu/CustomColorPicker.tsx @@ -0,0 +1,39 @@ +import React, { useState } from 'react'; +import Popover, { PopoverProps } from '@mui/material/Popover'; +import { RGBColor, SketchPicker } from 'react-color'; +import Button from '@mui/material/Button'; +import { useTranslation } from 'react-i18next'; +import { Divider } from '@mui/material'; + +export function CustomColorPicker({ + onColorChange, + ...props +}: { + onColorChange?: (color: string) => void; +} & PopoverProps) { + const { t } = useTranslation(); + const [color, setColor] = useState<RGBColor | undefined>(); + + return ( + <Popover {...props}> + <SketchPicker + onChange={(color) => { + setColor(color.rgb); + }} + color={color} + /> + <Divider /> + <div className={'z-10 flex justify-end bg-bg-body px-2 py-2'}> + <Button + size={'small'} + onClick={() => { + onColorChange?.(`rgba(${color?.r}, ${color?.g}, ${color?.b}, ${color?.a})`); + }} + variant={'outlined'} + > + {t('button.done')} + </Button> + </div> + </Popover> + ); +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/sub_menu/FontColorPicker.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/sub_menu/FontColorPicker.tsx new file mode 100644 index 0000000000..983d4444d4 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/sub_menu/FontColorPicker.tsx @@ -0,0 +1,12 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { + ColorPicker, + ColorPickerProps, +} from '$app/components/editor/components/tools/selection_toolbar/sub_menu/ColorPicker'; + +export function FontColorPicker(props: ColorPickerProps) { + const { t } = useTranslation(); + + return <ColorPicker {...props} label={t('editor.textColor')} />; +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/sub_menu/index.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/sub_menu/index.ts new file mode 100644 index 0000000000..caf74b0e96 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/sub_menu/index.ts @@ -0,0 +1,4 @@ +export * from './CustomColorPicker'; +export * from './FontColorPicker'; +export * from './ColorPicker'; +export * from './BgColorPicker'; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/utils.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/utils.ts new file mode 100644 index 0000000000..7e137d1619 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/utils.ts @@ -0,0 +1,49 @@ +import { ReactEditor } from 'slate-react'; +import { Editor, Range } from 'slate'; +import { getEditorDomNode } from '$app/components/editor/plugins/utils'; + +export function getSelectionPosition(editor: ReactEditor) { + const { selection } = editor; + + const isFocused = ReactEditor.isFocused(editor); + + if (!selection || !isFocused || Range.isCollapsed(selection) || Editor.string(editor, selection) === '') { + return null; + } + + const domSelection = window.getSelection(); + const rangeCount = domSelection?.rangeCount; + + if (!rangeCount) return null; + + const domRange = rangeCount > 0 ? domSelection.getRangeAt(0) : undefined; + + const rect = domRange?.getBoundingClientRect(); + + let newRect; + + const domNode = getEditorDomNode(editor); + const domNodeRect = domNode.getBoundingClientRect(); + + // the default height of the toolbar is 30px + const gap = 36; + + if (rect) { + let relativeDomTop = rect.top - domNodeRect.top; + const relativeDomLeft = rect.left - domNodeRect.left; + + // if the range is above the window, move the toolbar to the bottom of range + if (rect.top < gap) { + relativeDomTop = rect.bottom - gap - domNodeRect.top; + } + + newRect = { + top: relativeDomTop, + left: relativeDomLeft, + width: rect.width, + height: rect.height, + }; + } + + return newRect; +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/index.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/index.ts new file mode 100644 index 0000000000..8b7c4c267a --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/index.ts @@ -0,0 +1 @@ +export * from './Editor'; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/plugins/constants.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/plugins/constants.ts new file mode 100644 index 0000000000..b79b91041f --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/plugins/constants.ts @@ -0,0 +1,5 @@ +import { EditorNodeType } from '$app/application/document/document.types'; + +export const BREAK_TO_PARAGRAPH_TYPES = [EditorNodeType.HeadingBlock, EditorNodeType.QuoteBlock]; + +export const SOFT_BREAK_TYPES = [EditorNodeType.CalloutBlock, EditorNodeType.CodeBlock]; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/plugins/utils.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/plugins/utils.ts new file mode 100644 index 0000000000..9792f8eda8 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/plugins/utils.ts @@ -0,0 +1,61 @@ +import { Editor, Element, Location, NodeEntry, Point, Range } from 'slate'; +import { EditorNodeType } from '$app/application/document/document.types'; +import { ReactEditor } from 'slate-react'; + +export function getHeadingCssProperty(level: number) { + switch (level) { + case 1: + return 'text-3xl pt-4'; + case 2: + return 'text-2xl pt-3'; + case 3: + return 'text-xl pt-2'; + default: + return ''; + } +} + +export function isDeleteBackwardAtStartOfBlock(editor: ReactEditor, type?: EditorNodeType) { + const { selection } = editor; + + if (selection && Range.isCollapsed(selection)) { + const [match] = Editor.nodes(editor, { + match: (n) => !Editor.isEditor(n) && Element.isElement(n) && Editor.isBlock(editor, n), + }); + + if (match) { + const [node, path] = match as NodeEntry<Element>; + + if (type !== undefined && node.type !== type) return false; + + const start = Editor.start(editor, path); + + if (Point.equals(selection.anchor, start)) { + return true; + } + } + } + + return false; +} + +export function getBlockEntry(editor: ReactEditor, at?: Location) { + if (!editor.selection) return null; + + const entry = Editor.above(editor, { + at, + match: (n) => !Editor.isEditor(n) && Element.isElement(n), + }); + + return entry as NodeEntry<Element>; +} + +export function getBlock(editor: ReactEditor, at?: Location) { + const entry = getBlockEntry(editor, at); + + return entry?.[0]; +} + +export function getEditorDomNode(editor: ReactEditor) { + return ReactEditor.toDOMNode(editor, editor); +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/plugins/withBlockDeleteBackward.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/plugins/withBlockDeleteBackward.ts new file mode 100644 index 0000000000..9dc0391866 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/plugins/withBlockDeleteBackward.ts @@ -0,0 +1,52 @@ +import { ReactEditor } from 'slate-react'; + +import { isDeleteBackwardAtStartOfBlock } from '$app/components/editor/plugins/utils'; +import { EditorNodeType } from '$app/application/document/document.types'; +import { Editor, Element, NodeEntry } from 'slate'; +import { CustomEditor } from '$app/components/editor/command'; + +export function withBlockDeleteBackward(editor: ReactEditor) { + const { deleteBackward } = editor; + + editor.deleteBackward = (...args) => { + if (!isDeleteBackwardAtStartOfBlock(editor)) { + deleteBackward(...args); + return; + } + + const [match] = Editor.nodes(editor, { + match: (n) => !Editor.isEditor(n) && Element.isElement(n) && Editor.isBlock(editor, n), + }); + + const [node] = match as NodeEntry<Element>; + + // if the current node is not a paragraph, convert it to a paragraph + if (node.type !== EditorNodeType.Paragraph) { + CustomEditor.turnToBlock(editor, { type: EditorNodeType.Paragraph }); + return; + } + + const level = node.level; + + if (!level) { + deleteBackward(...args); + return; + } + + const nextNode = CustomEditor.findNextNode(editor, node, level); + + if (nextNode) { + deleteBackward(...args); + return; + } + + if (level > 1) { + CustomEditor.tabBackward(editor); + return; + } + + deleteBackward(...args); + }; + + return editor; +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/plugins/withBlockInsertBreak.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/plugins/withBlockInsertBreak.ts new file mode 100644 index 0000000000..a0f9720b8a --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/plugins/withBlockInsertBreak.ts @@ -0,0 +1,38 @@ +import { ReactEditor } from 'slate-react'; +import { Editor, Element, NodeEntry } from 'slate'; +import { SOFT_BREAK_TYPES } from '$app/components/editor/plugins/constants'; +import { EditorNodeType } from '$app/application/document/document.types'; +import { CustomEditor } from '$app/components/editor/command'; + +export function withBlockInsertBreak(editor: ReactEditor) { + const { insertBreak } = editor; + + editor.insertBreak = (...args) => { + const nodeEntry = Editor.above(editor, { + match: (n) => !Editor.isEditor(n) && Element.isElement(n) && Editor.isBlock(editor, n), + }); + + if (!nodeEntry) return insertBreak(...args); + + const [node] = nodeEntry as NodeEntry<Element>; + const type = node.type as EditorNodeType; + + // should insert a soft break, eg: code block and callout + if (SOFT_BREAK_TYPES.includes(type)) { + editor.insertText('\n'); + return; + } + + const isEmpty = Editor.isEmpty(editor, node); + + // if the node is empty, convert it to a paragraph + if (isEmpty && type !== EditorNodeType.Paragraph) { + CustomEditor.turnToBlock(editor, { type: EditorNodeType.Paragraph }); + return; + } + + insertBreak(...args); + }; + + return editor; +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/plugins/withBlockPlugins.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/plugins/withBlockPlugins.ts new file mode 100644 index 0000000000..aa22e1ce2a --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/plugins/withBlockPlugins.ts @@ -0,0 +1,17 @@ +import { ReactEditor } from 'slate-react'; + +import { withBlockDeleteBackward } from '$app/components/editor/plugins/withBlockDeleteBackward'; +import { withBlockInsertBreak } from '$app/components/editor/plugins/withBlockInsertBreak'; +import { withMergeNodes } from '$app/components/editor/plugins/withMergeNodes'; +import { withSplitNodes } from '$app/components/editor/plugins/withSplitNodes'; +import { withDatabaseBlockPlugin } from '$app/components/editor/components/blocks/database'; +import { withMathEquationPlugin } from '$app/components/editor/components/blocks/math_equation'; +import { withPasted } from '$app/components/editor/plugins/withPasted'; + +export function withBlockPlugins(editor: ReactEditor) { + return withMathEquationPlugin( + withDatabaseBlockPlugin( + withPasted(withSplitNodes(withMergeNodes(withBlockInsertBreak(withBlockDeleteBackward(editor))))) + ) + ); +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/plugins/withMergeNodes.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/plugins/withMergeNodes.ts new file mode 100644 index 0000000000..0d7ad81489 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/plugins/withMergeNodes.ts @@ -0,0 +1,43 @@ +import { ReactEditor } from 'slate-react'; +import { Editor, Element, NodeEntry, Node, Transforms, Point } from 'slate'; +import { CustomEditor } from '$app/components/editor/command'; + +export function withMergeNodes(editor: ReactEditor) { + const { mergeNodes } = editor; + + // before merging nodes, check whether the node is a block and whether the selection is at the start of the block + // if so, move the children of the node to the previous node + editor.mergeNodes = (...args) => { + const { selection } = editor; + + const isBlock = (n: Node) => + !Editor.isEditor(n) && Element.isElement(n) && n.type !== undefined && n.level !== undefined; + + const [match] = Editor.nodes(editor, { + match: isBlock, + }); + + if (match && selection) { + const [node, path] = match as NodeEntry<Element>; + const start = Editor.start(editor, path); + + if (Point.equals(selection.anchor, start)) { + const previous = Editor.previous(editor, { at: path }); + const [previousNode] = previous as NodeEntry<Element>; + const previousLevel = previousNode.level ?? 1; + + const children = CustomEditor.findNodeChildren(editor, node); + + children.forEach((child) => { + const childPath = ReactEditor.findPath(editor, child); + + Transforms.setNodes(editor, { level: previousLevel + 1, parentId: previousNode.blockId }, { at: childPath }); + }); + } + } + + mergeNodes(...args); + }; + + return editor; +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/plugins/withPasted.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/plugins/withPasted.ts new file mode 100644 index 0000000000..9dbfdafdd0 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/plugins/withPasted.ts @@ -0,0 +1,86 @@ +import { ReactEditor } from 'slate-react'; +import { Editor, Element, Node, NodeEntry, Transforms } from 'slate'; +import { Log } from '$app/utils/log'; +import { generateId } from '$app/components/editor/provider/utils/convert'; + +export function withPasted(editor: ReactEditor) { + const { insertData, mergeNodes } = editor; + + editor.mergeNodes = (...args) => { + const isBlock = (n: Node) => + !Editor.isEditor(n) && Element.isElement(n) && n.type !== undefined && n.level !== undefined; + + const [match] = Editor.nodes(editor, { + match: isBlock, + }); + + const node = match ? (match[0] as Element) : null; + + if (!node) { + mergeNodes(...args); + return; + } + + // This is a hack to fix the bug that the children of the node will be moved to the previous node + const previous = Editor.previous(editor, { + match: (n) => { + return !Editor.isEditor(n) && Element.isElement(n) && n.type !== undefined && n.level === (node.level ?? 1) - 1; + }, + }); + + if (previous) { + const [previousNode] = previous as NodeEntry<Element>; + + if (previousNode && previousNode.blockId !== node.parentId) { + const children = editor.children.filter((child) => (child as Element).parentId === node.parentId); + + children.forEach((child) => { + const childIndex = editor.children.findIndex((c) => c === child); + const childPath = [childIndex]; + + Transforms.setNodes(editor, { parentId: previousNode.blockId }, { at: childPath }); + }); + } + } + + mergeNodes(...args); + }; + + editor.insertData = (data) => { + const fragment = data.getData('application/x-slate-fragment'); + + try { + if (fragment) { + const decoded = decodeURIComponent(window.atob(fragment)); + const parsed = JSON.parse(decoded); + + if (parsed instanceof Array) { + const idMap = new Map<string, string>(); + + for (const parsedElement of parsed as Element[]) { + if (!parsedElement.blockId) continue; + const newBlockId = generateId(); + + if (parsedElement.parentId) { + parsedElement.parentId = idMap.get(parsedElement.parentId) ?? parsedElement.parentId; + } + + idMap.set(parsedElement.blockId, newBlockId); + parsedElement.blockId = newBlockId; + + parsedElement.textId = generateId(); + } + + editor.insertFragment(parsed); + return; + } + } + } catch (err) { + Log.error('insertData', err); + } + + insertData(data); + }; + + return editor; +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/plugins/withSplitNodes.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/plugins/withSplitNodes.ts new file mode 100644 index 0000000000..ee72421f16 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/plugins/withSplitNodes.ts @@ -0,0 +1,113 @@ +import { ReactEditor } from 'slate-react'; +import { Editor, Element, NodeEntry, Transforms } from 'slate'; +import { EditorMarkFormat, EditorNodeType, markTypes, ToggleListNode } from '$app/application/document/document.types'; +import { CustomEditor } from '$app/components/editor/command'; +import { BREAK_TO_PARAGRAPH_TYPES } from '$app/components/editor/plugins/constants'; +import { generateId } from '$app/components/editor/provider/utils/convert'; + +export function withSplitNodes(editor: ReactEditor) { + const { splitNodes } = editor; + + editor.splitNodes = (...args) => { + const isInsertBreak = args.length === 1 && JSON.stringify(args[0]) === JSON.stringify({ always: true }); + + if (!isInsertBreak) { + splitNodes(...args); + return; + } + + // This is a workaround for the bug that the new paragraph will inherit the marks of the previous paragraph + // remove all marks in current selection, otherwise the new paragraph will inherit the marks + markTypes.forEach((markType) => { + const isActive = CustomEditor.isMarkActive(editor, markType as EditorMarkFormat); + + if (isActive) { + editor.removeMark(markType as EditorMarkFormat); + } + }); + + const [match] = Editor.nodes(editor, { + match: (n) => !Editor.isEditor(n) && Element.isElement(n) && n.blockId !== undefined && n.type !== undefined, + }); + + if (!match) { + splitNodes(...args); + return; + } + + const [node, path] = match as NodeEntry<Element>; + + const newBlockId = generateId(); + const newTextId = generateId(); + + const nodeType = node.type as EditorNodeType; + + // should be split to a new paragraph for the first child of the toggle list + if (nodeType === EditorNodeType.ToggleListBlock) { + const collapsed = (node as ToggleListNode).data.collapsed; + const level = node.level ?? 1; + const blockId = node.blockId as string; + const parentId = node.parentId as string; + + // if the toggle list is collapsed, split to a new paragraph append to the children of the toggle list + if (!collapsed) { + splitNodes(...args); + Transforms.setNodes(editor, { + type: EditorNodeType.Paragraph, + data: {}, + level: level + 1, + blockId: newBlockId, + parentId: blockId, + textId: newTextId, + }); + } else { + // if the toggle list is not collapsed, split to a toggle list after the toggle list + const nextNode = CustomEditor.findNextNode(editor, node, level); + const nextIndex = nextNode ? ReactEditor.findPath(editor, nextNode)[0] : null; + const index = path[0]; + + splitNodes(...args); + Transforms.setNodes(editor, { level, data: {}, blockId: newBlockId, parentId, textId: newTextId }); + if (nextIndex) { + Transforms.moveNodes(editor, { at: [index + 1], to: [nextIndex] }); + } + } + + return; + } + + // should be split to another paragraph, eg: heading and quote + if (BREAK_TO_PARAGRAPH_TYPES.includes(nodeType)) { + splitNodes(...args); + Transforms.setNodes(editor, { + type: EditorNodeType.Paragraph, + data: {}, + blockId: newBlockId, + textId: newTextId, + }); + return; + } + + splitNodes(...args); + + Transforms.setNodes(editor, { blockId: newBlockId, data: {}, textId: newTextId }); + + const children = CustomEditor.findNodeChildren(editor, node); + + children.forEach((child) => { + const childPath = ReactEditor.findPath(editor, child); + + Transforms.setNodes( + editor, + { + parentId: newBlockId, + }, + { + at: [childPath[0] + 1], + } + ); + }); + }; + + return editor; +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/provider/__tests__/action.test.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/provider/__tests__/action.test.ts new file mode 100644 index 0000000000..5f502a8b9b --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/provider/__tests__/action.test.ts @@ -0,0 +1,141 @@ +import { applyActions } from './utils/mockBackendService'; +import { generateId } from '$app/components/editor/provider/utils/convert'; +import { Provider } from '$app/components/editor/provider'; +import * as Y from 'yjs'; +import { BlockActionTypePB } from '@/services/backend'; +import { + generateFormulaInsertTextOp, + generateInsertTextOp, + genersteMentionInsertTextOp, +} from '$app/components/editor/provider/__tests__/utils/convert'; + +describe('Transform events to actions', () => { + let provider: Provider; + beforeEach(() => { + provider = new Provider(generateId()); + provider.connect(); + applyActions.mockClear(); + }); + + afterEach(() => { + provider.disconnect(); + }); + + test('should transform insert event to insert action', () => { + const sharedType = provider.sharedType; + + const parentId = sharedType?.getAttribute('blockId') as string; + const insertTextOp = generateInsertTextOp('insert text', parentId, 1); + + sharedType?.applyDelta([{ retain: 1 }, insertTextOp]); + + const actions = applyActions.mock.calls[0][1]; + expect(actions).toHaveLength(2); + const textId = actions[0].payload.text_id; + expect(actions[0].action).toBe(BlockActionTypePB.InsertText); + expect(actions[0].payload.delta).toBe('[{"insert":"insert text"}]'); + expect(actions[1].action).toBe(BlockActionTypePB.Insert); + expect(actions[1].payload.block.ty).toBe('paragraph'); + expect(actions[1].payload.block.parent_id).toBe('3EzeCrtxlh'); + expect(actions[1].payload.block.children_id).not.toBeNull(); + expect(actions[1].payload.block.external_id).toBe(textId); + expect(actions[1].payload.parent_id).toBe('3EzeCrtxlh'); + expect(actions[1].payload.prev_id).toBe('2qonPRrNTO'); + }); + + test('should transform move event to move action', () => { + const sharedType = provider.sharedType; + + const parentId = 'CxPil0324P'; + const yText = sharedType?.toDelta()[3].insert as Y.XmlText; + sharedType?.doc?.transact(() => { + yText.setAttribute('level', 2); + yText.setAttribute('parentId', parentId); + }); + + const actions = applyActions.mock.calls[0][1]; + expect(actions).toHaveLength(1); + expect(actions[0].action).toBe(BlockActionTypePB.Move); + expect(actions[0].payload.block.id).toBe('Fn4KACkt1i'); + expect(actions[0].payload.parent_id).toBe('CxPil0324P'); + expect(actions[0].payload.prev_id).toBe(''); + }); + + test('should transform delete event to delete action', () => { + const sharedType = provider.sharedType; + + sharedType?.doc?.transact(() => { + sharedType?.applyDelta([{ retain: 3 }, { delete: 1 }]); + }); + + const actions = applyActions.mock.calls[0][1]; + expect(actions).toHaveLength(1); + expect(actions[0].action).toBe(BlockActionTypePB.Delete); + expect(actions[0].payload.block.id).toBe('Fn4KACkt1i'); + expect(actions[0].payload.parent_id).toBe('3EzeCrtxlh'); + }); + + test('should transform update event to update action', () => { + const sharedType = provider.sharedType; + + const yText = sharedType?.toDelta()[3].insert as Y.XmlText; + sharedType?.doc?.transact(() => { + yText.setAttribute('data', { + checked: true, + }); + }); + + const actions = applyActions.mock.calls[0][1]; + expect(actions).toHaveLength(1); + expect(actions[0].action).toBe(BlockActionTypePB.Update); + expect(actions[0].payload.block.id).toBe('Fn4KACkt1i'); + expect(actions[0].payload.block.data).toBe('{"checked":true}'); + expect(actions[0].payload.parent_id).toBe('3EzeCrtxlh'); + }); + + test('should transform apply delta event to apply delta action (insert text)', () => { + const sharedType = provider.sharedType; + + const yText = sharedType?.toDelta()[3].insert as Y.XmlText; + sharedType?.doc?.transact(() => { + yText.applyDelta([{ retain: 1 }, { insert: 'apply delta' }]); + }); + const textId = yText.getAttribute('textId'); + + const actions = applyActions.mock.calls[0][1]; + expect(actions).toHaveLength(1); + expect(actions[0].action).toBe(BlockActionTypePB.ApplyTextDelta); + expect(actions[0].payload.text_id).toBe(textId); + expect(actions[0].payload.delta).toBe('[{"retain":1},{"insert":"apply delta"}]'); + }); + + test('should transform apply delta event to apply delta action: insert mention', () => { + const sharedType = provider.sharedType; + + const yText = sharedType?.toDelta()[3].insert as Y.XmlText; + sharedType?.doc?.transact(() => { + yText.applyDelta([{ retain: 1 }, genersteMentionInsertTextOp()]); + }); + + const actions = applyActions.mock.calls[0][1]; + expect(actions).toHaveLength(1); + expect(actions[0].action).toBe(BlockActionTypePB.ApplyTextDelta); + expect(actions[0].payload.delta).toBe('[{"retain":1},{"insert":"@","attributes":{"mention":{"page":"page_id"}}}]'); + }); + + test('should transform apply delta event to apply delta action: insert formula', () => { + const sharedType = provider.sharedType; + + const yText = sharedType?.toDelta()[3].insert as Y.XmlText; + sharedType?.doc?.transact(() => { + yText.applyDelta([{ retain: 1 }, generateFormulaInsertTextOp()]); + }); + + const actions = applyActions.mock.calls[0][1]; + expect(actions).toHaveLength(1); + expect(actions[0].action).toBe(BlockActionTypePB.ApplyTextDelta); + expect(actions[0].payload.delta).toBe('[{"retain":1},{"insert":"= 1 + 1","attributes":{"formula":true}}]'); + }); +}); + +export {}; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/provider/__tests__/observe.test.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/provider/__tests__/observe.test.ts new file mode 100644 index 0000000000..835bcaeb57 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/provider/__tests__/observe.test.ts @@ -0,0 +1,42 @@ +import { applyActions } from './utils/mockBackendService'; + +import { Provider } from '$app/components/editor/provider'; +import { generateId } from '$app/components/editor/provider/utils/convert'; +import { generateInsertTextOp } from '$app/components/editor/provider/__tests__/utils/convert'; + +export {}; + +describe('Provider connected', () => { + let provider: Provider; + + beforeEach(() => { + provider = new Provider(generateId()); + provider.connect(); + applyActions.mockClear(); + }); + + afterEach(() => { + provider.disconnect(); + }); + + test('should initial document', () => { + const sharedType = provider.sharedType; + expect(sharedType).not.toBeNull(); + expect(sharedType?.length).toBe(24); + expect(sharedType?.getAttribute('blockId')).toBe('3EzeCrtxlh'); + }); + + test('should send actions when the local changed', () => { + const sharedType = provider.sharedType; + + const parentId = sharedType?.getAttribute('blockId') as string; + const insertTextOp = generateInsertTextOp('', parentId, 1); + + sharedType?.applyDelta([{ retain: 1 }, insertTextOp]); + + expect(sharedType?.length).toBe(25); + expect(applyActions).toBeCalledTimes(1); + }); +}); + +export {}; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/provider/__tests__/read_me.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/provider/__tests__/read_me.ts new file mode 100644 index 0000000000..adb85f2bfd --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/provider/__tests__/read_me.ts @@ -0,0 +1,437 @@ +export default { + viewId: '04acbc17-2265-4e4d-ac80-392bdc81379f', + rootId: '3EzeCrtxlh', + nodeMap: { + '692ooXzoV-': { + id: '692ooXzoV-', + type: 'todo_list', + parent: '3EzeCrtxlh', + children: '4yIcpjxFTQ', + data: { checked: false }, + externalId: '9L10h3UZ7J', + externalType: 'text', + }, + gCjs671FiD: { + id: 'gCjs671FiD', + type: 'paragraph', + parent: '3EzeCrtxlh', + children: 'Bj4M6midh3', + data: {}, + externalId: 'uGR_eATq2B', + externalType: 'text', + }, + whGVOpFJzA: { + id: 'whGVOpFJzA', + type: 'code', + parent: '3EzeCrtxlh', + children: 'eYqZSaUSrF', + data: { language: 'rust' }, + externalId: '2H8dnFhOsJ', + externalType: 'text', + }, + PeUTr8lpaW: { + id: 'PeUTr8lpaW', + type: 'divider', + parent: '3EzeCrtxlh', + children: '7gXZ4anHxc', + data: {}, + externalId: '', + externalType: '', + }, + aRUJ8rTJR9: { + id: 'aRUJ8rTJR9', + type: 'quote', + parent: '3EzeCrtxlh', + children: '877leNxAdX', + data: {}, + externalId: 'Qdn9CIuCJb', + externalType: 'text', + }, + '5OZNiernqA': { + id: '5OZNiernqA', + type: 'paragraph', + parent: '3EzeCrtxlh', + children: 'rnWU0OMG2D', + data: {}, + externalId: '7szeShePbX', + externalType: 'text', + }, + '6okS7LcJz6': { + id: '6okS7LcJz6', + type: 'paragraph', + parent: '3EzeCrtxlh', + children: 'o2VwjuyMqD', + data: {}, + externalId: 'T7KYfpQzkC', + externalType: 'text', + }, + '2qonPRrNTO': { + id: '2qonPRrNTO', + type: 'heading', + parent: '3EzeCrtxlh', + children: 'EwyNm_pVG3', + data: { level: 1 }, + externalId: 'zatA8Lta9U', + externalType: 'text', + }, + G6SPYNOXyd: { + id: 'G6SPYNOXyd', + type: 'heading', + parent: '3EzeCrtxlh', + children: 'dMAT_mdB3t', + data: { level: 2 }, + externalId: 'EZJU9Ks-XL', + externalType: 'text', + }, + 'i-7TRjZWn4': { + id: 'i-7TRjZWn4', + type: 'todo_list', + parent: '3EzeCrtxlh', + children: '3NsAmPWBLT', + data: { checked: true }, + externalId: 'rG9KOmfyQc', + externalType: 'text', + }, + mAce5pJ5iN: { + id: 'mAce5pJ5iN', + type: 'paragraph', + parent: '3EzeCrtxlh', + children: 'PavtRGeb_I', + data: {}, + externalId: 'UUBk3lnHDj', + externalType: 'text', + }, + ViWXVLgaux: { + id: 'ViWXVLgaux', + type: 'numbered_list', + parent: '3EzeCrtxlh', + children: 'osPbZZroOQ', + data: {}, + externalId: 'OkO9CIoWYX', + externalType: 'text', + }, + VrW0GWtvmq: { + id: 'VrW0GWtvmq', + type: 'todo_list', + parent: '3EzeCrtxlh', + children: 'hFX8bE3MQ6', + data: { checked: false }, + externalId: 'wBzhBx7bcM', + externalType: 'text', + }, + YzM4q9vJgy: { + id: 'YzM4q9vJgy', + type: 'paragraph', + parent: '3EzeCrtxlh', + children: 'mlDk334eJ5', + data: {}, + externalId: 'wIXo3cMKpn', + externalType: 'text', + }, + okBQecghDx: { + id: 'okBQecghDx', + type: 'todo_list', + parent: '3EzeCrtxlh', + children: 'r7U-ocUQEj', + data: { checked: false }, + externalId: '8T-1vemF9G', + externalType: 'text', + }, + qJcR2SnePa: { + id: 'qJcR2SnePa', + type: 'callout', + parent: '3EzeCrtxlh', + children: 'X8-C5rdFkI', + data: { icon: '🥰' }, + externalId: 'o3mqcqjEvX', + externalType: 'text', + }, + q8trFTc21J: { + id: 'q8trFTc21J', + type: 'numbered_list', + parent: '3EzeCrtxlh', + children: 'Ye_KrA1Zqb', + data: {}, + externalId: 'E-XQYK1KGP', + externalType: 'text', + }, + '-7trMtJMEt': { + id: '-7trMtJMEt', + type: 'numbered_list', + parent: '3EzeCrtxlh', + children: '5-RQuN9654', + data: {}, + externalId: 'meKXZh3E_1', + externalType: 'text', + }, + Fn4KACkt1i: { + id: 'Fn4KACkt1i', + type: 'todo_list', + parent: '3EzeCrtxlh', + children: 'MM6vCgc7RC', + data: { checked: false }, + externalId: 'v0XWYu0w3F', + externalType: 'text', + }, + TTU0eUzM4G: { + id: 'TTU0eUzM4G', + type: 'paragraph', + parent: '3EzeCrtxlh', + children: 'Jo1ix-lCgZ', + data: {}, + externalId: 'IRpzcwVaU4', + externalType: 'text', + }, + '6QZZccBfnT': { + id: '6QZZccBfnT', + type: 'heading', + parent: '3EzeCrtxlh', + children: 'bdI2gAQB-G', + data: { level: 2 }, + externalId: 'J-oMw2g2_D', + externalType: 'text', + }, + sZT5qbJvLX: { + id: 'sZT5qbJvLX', + type: 'paragraph', + parent: '3EzeCrtxlh', + children: 'yR1_d6lPtR', + data: {}, + externalId: 'anA255zYMV', + externalType: 'text', + }, + '3EzeCrtxlh': { + id: '3EzeCrtxlh', + type: 'page', + parent: '', + children: 'ChrjyUcqp5', + data: {}, + externalId: 'bGaty5Tv88', + externalType: 'text', + }, + 'b3G76VM-nh': { + id: 'b3G76VM-nh', + type: 'heading', + parent: '3EzeCrtxlh', + children: '7PDV2Ev8pz', + data: { level: 2 }, + externalId: 'baM4S6ohnQ', + externalType: 'text', + }, + CxPil0324P: { + id: 'CxPil0324P', + type: 'todo_list', + parent: '3EzeCrtxlh', + children: 'qJkq_FYLux', + data: { checked: false }, + externalId: 'LGUrob79hg', + externalType: 'text', + }, + }, + childrenMap: { + '-7trMtJMEt': [], + okBQecghDx: [], + PeUTr8lpaW: [], + '6QZZccBfnT': [], + aRUJ8rTJR9: [], + G6SPYNOXyd: [], + VrW0GWtvmq: [], + 'i-7TRjZWn4': [], + '6okS7LcJz6': [], + Fn4KACkt1i: [], + qJcR2SnePa: [], + sZT5qbJvLX: [], + '2qonPRrNTO': [], + CxPil0324P: [], + YzM4q9vJgy: [], + mAce5pJ5iN: [], + gCjs671FiD: [], + q8trFTc21J: [], + whGVOpFJzA: [], + '5OZNiernqA': [], + 'b3G76VM-nh': [], + TTU0eUzM4G: [], + '3EzeCrtxlh': [ + '2qonPRrNTO', + 'b3G76VM-nh', + 'CxPil0324P', + 'Fn4KACkt1i', + 'okBQecghDx', + 'VrW0GWtvmq', + 'i-7TRjZWn4', + '692ooXzoV-', + 'sZT5qbJvLX', + 'PeUTr8lpaW', + 'YzM4q9vJgy', + 'G6SPYNOXyd', + '-7trMtJMEt', + 'q8trFTc21J', + 'ViWXVLgaux', + 'whGVOpFJzA', + 'mAce5pJ5iN', + '6QZZccBfnT', + 'aRUJ8rTJR9', + 'gCjs671FiD', + 'qJcR2SnePa', + '5OZNiernqA', + 'TTU0eUzM4G', + '6okS7LcJz6', + ], + ViWXVLgaux: [], + '692ooXzoV-': [], + }, + relativeMap: { + '4yIcpjxFTQ': '692ooXzoV-', + Bj4M6midh3: 'gCjs671FiD', + eYqZSaUSrF: 'whGVOpFJzA', + '7gXZ4anHxc': 'PeUTr8lpaW', + '877leNxAdX': 'aRUJ8rTJR9', + rnWU0OMG2D: '5OZNiernqA', + o2VwjuyMqD: '6okS7LcJz6', + EwyNm_pVG3: '2qonPRrNTO', + dMAT_mdB3t: 'G6SPYNOXyd', + '3NsAmPWBLT': 'i-7TRjZWn4', + PavtRGeb_I: 'mAce5pJ5iN', + osPbZZroOQ: 'ViWXVLgaux', + hFX8bE3MQ6: 'VrW0GWtvmq', + mlDk334eJ5: 'YzM4q9vJgy', + 'r7U-ocUQEj': 'okBQecghDx', + 'X8-C5rdFkI': 'qJcR2SnePa', + Ye_KrA1Zqb: 'q8trFTc21J', + '5-RQuN9654': '-7trMtJMEt', + MM6vCgc7RC: 'Fn4KACkt1i', + 'Jo1ix-lCgZ': 'TTU0eUzM4G', + 'bdI2gAQB-G': '6QZZccBfnT', + yR1_d6lPtR: 'sZT5qbJvLX', + ChrjyUcqp5: '3EzeCrtxlh', + '7PDV2Ev8pz': 'b3G76VM-nh', + qJkq_FYLux: 'CxPil0324P', + }, + deltaMap: { + gCjs671FiD: [], + G6SPYNOXyd: [{ insert: 'Keyboard shortcuts, markdown, and code block' }], + VrW0GWtvmq: [ + { insert: 'Type ' }, + { insert: '/', attributes: { code: true } }, + { insert: ' followed by ' }, + { insert: '/bullet', attributes: { code: true } }, + { insert: ' or ' }, + { insert: '/num', attributes: { code: true } }, + { insert: ' to create a list.', attributes: { code: false } }, + ], + '-7trMtJMEt': [ + { insert: 'Keyboard shortcuts ' }, + { + insert: 'guide', + attributes: { href: 'https://appflowy.gitbook.io/docs/essential-documentation/shortcuts' }, + }, + ], + okBQecghDx: [ + { insert: 'As soon as you type ' }, + { insert: '/', attributes: { font_color: '0xff00b5ff', code: true } }, + { insert: ' a menu will pop up. Select ' }, + { insert: 'different types', attributes: { bg_color: '0x4d9c27b0' } }, + { insert: ' of content blocks you can add.' }, + ], + qJcR2SnePa: [ + { insert: '\nLike AppFlowy? Follow us:\n' }, + { insert: 'GitHub', attributes: { href: 'https://github.com/AppFlowy-IO/AppFlowy' } }, + { insert: '\n' }, + { insert: 'Twitter', attributes: { href: 'https://twitter.com/appflowy' } }, + { insert: ': @appflowy\n' }, + { insert: 'Newsletter', attributes: { href: 'https://blog-appflowy.ghost.io/' } }, + { insert: '\n' }, + ], + whGVOpFJzA: [ + { + insert: + '// This is the main function.\nfn main() {\n // Print text to the console.\n println!("Hello World!");\n}', + }, + ], + YzM4q9vJgy: [], + '692ooXzoV-': [ + { insert: 'Click ' }, + { insert: '+', attributes: { code: true } }, + { insert: ' next to any page title in the sidebar to ' }, + { insert: 'quickly', attributes: { font_color: '0xff8427e0' } }, + { insert: ' add a new subpage, ' }, + { insert: 'Document', attributes: { code: true } }, + { insert: ', ', attributes: { code: false } }, + { insert: 'Grid', attributes: { code: true } }, + { insert: ', or ', attributes: { code: false } }, + { insert: 'Kanban Board', attributes: { code: true } }, + { insert: '.', attributes: { code: false } }, + ], + q8trFTc21J: [ + { insert: 'Markdown ' }, + { + insert: 'reference', + attributes: { href: 'https://appflowy.gitbook.io/docs/essential-documentation/markdown' }, + }, + ], + ViWXVLgaux: [ + { insert: 'Type ' }, + { insert: '/code', attributes: { code: true } }, + { insert: ' to insert a code block', attributes: { code: false } }, + ], + sZT5qbJvLX: [], + '6QZZccBfnT': [{ insert: 'Have a question❓' }], + '5OZNiernqA': [], + '6okS7LcJz6': [], + mAce5pJ5iN: [], + aRUJ8rTJR9: [ + { insert: 'Click ' }, + { insert: '?', attributes: { code: true } }, + { insert: ' at the bottom right for help and support.' }, + ], + Fn4KACkt1i: [ + { insert: 'Highlight ', attributes: { bg_color: '0x4dffeb3b' } }, + { insert: 'any text, and use the editing menu to ' }, + { insert: 'style', attributes: { italic: true } }, + { insert: ' ' }, + { insert: 'your', attributes: { bold: true } }, + { insert: ' ' }, + { insert: 'writing', attributes: { underline: true } }, + { insert: ' ' }, + { insert: 'however', attributes: { code: true } }, + { insert: ' you ' }, + { insert: 'like.', attributes: { strikethrough: true } }, + ], + '3EzeCrtxlh': [], + '2qonPRrNTO': [{ insert: 'Welcome to AppFlowy!' }], + 'b3G76VM-nh': [{ insert: 'Here are the basics' }], + TTU0eUzM4G: [], + CxPil0324P: [{ insert: 'Click anywhere and just start typing.' }], + 'i-7TRjZWn4': [ + { insert: 'Click ' }, + { insert: '+ New Page ', attributes: { code: true } }, + { insert: 'button at the bottom of your sidebar to add a new page.' }, + ], + }, + externalIdMap: { + '9L10h3UZ7J': '692ooXzoV-', + uGR_eATq2B: 'gCjs671FiD', + '2H8dnFhOsJ': 'whGVOpFJzA', + Qdn9CIuCJb: 'aRUJ8rTJR9', + '7szeShePbX': '5OZNiernqA', + T7KYfpQzkC: '6okS7LcJz6', + zatA8Lta9U: '2qonPRrNTO', + 'EZJU9Ks-XL': 'G6SPYNOXyd', + rG9KOmfyQc: 'i-7TRjZWn4', + UUBk3lnHDj: 'mAce5pJ5iN', + OkO9CIoWYX: 'ViWXVLgaux', + wBzhBx7bcM: 'VrW0GWtvmq', + wIXo3cMKpn: 'YzM4q9vJgy', + '8T-1vemF9G': 'okBQecghDx', + o3mqcqjEvX: 'qJcR2SnePa', + 'E-XQYK1KGP': 'q8trFTc21J', + meKXZh3E_1: '-7trMtJMEt', + v0XWYu0w3F: 'Fn4KACkt1i', + IRpzcwVaU4: 'TTU0eUzM4G', + 'J-oMw2g2_D': '6QZZccBfnT', + anA255zYMV: 'sZT5qbJvLX', + bGaty5Tv88: '3EzeCrtxlh', + baM4S6ohnQ: 'b3G76VM-nh', + LGUrob79hg: 'CxPil0324P', + }, +}; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/provider/__tests__/utils/convert.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/provider/__tests__/utils/convert.ts new file mode 100644 index 0000000000..69d04880ff --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/provider/__tests__/utils/convert.ts @@ -0,0 +1,70 @@ +/** + * @jest-environment jsdom + */ +import { slateNodesToInsertDelta } from '@slate-yjs/core'; +import * as Y from 'yjs'; +import { generateId } from '$app/components/editor/provider/utils/convert'; + +export function slateElementToYText({ + children, + ...attributes +}: { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + children: any; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + [key: string]: any; +}) { + const yElement = new Y.XmlText(); + + Object.entries(attributes).forEach(([key, value]) => { + yElement.setAttribute(key, value); + }); + yElement.applyDelta(slateNodesToInsertDelta(children), { + sanitize: false, + }); + return yElement; +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function generateInsertTextOp(text: string, parentId: string, level: number, attributes?: Record<string, any>) { + const insertYText = slateElementToYText({ + children: [{ text: text }], + type: 'paragraph', + data: {}, + blockId: generateId(), + parentId, + textId: generateId(), + level, + }); + + return { + insert: insertYText, + attributes, + }; +} + +export function genersteMentionInsertTextOp() { + const mentionYText = slateElementToYText({ + children: [{ text: '@' }], + type: 'mention', + data: { + page: 'page_id', + }, + }); + + return { + insert: mentionYText, + }; +} + +export function generateFormulaInsertTextOp() { + const formulaYText = slateElementToYText({ + children: [{ text: '= 1 + 1' }], + type: 'formula', + data: true, + }); + + return { + insert: formulaYText, + }; +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/provider/__tests__/utils/mockBackendService.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/provider/__tests__/utils/mockBackendService.ts new file mode 100644 index 0000000000..52347239b6 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/provider/__tests__/utils/mockBackendService.ts @@ -0,0 +1,20 @@ +import read_me from '$app/components/editor/provider/__tests__/read_me'; + +const applyActions = jest.fn().mockReturnValue(Promise.resolve()); + +jest.mock('$app/application/notification', () => { + return { + subscribeNotification: jest.fn().mockReturnValue(Promise.resolve(() => ({}))), + }; +}); + +jest.mock('nanoid', () => ({ nanoid: jest.fn().mockReturnValue(String(Math.random())) })); + +jest.mock('$app/application/document/document_service', () => { + return { + openDocument: jest.fn().mockReturnValue(Promise.resolve(read_me)), + applyActions, + }; +}); + +export { applyActions }; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/provider/data_client.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/provider/data_client.ts new file mode 100644 index 0000000000..544bd21ff4 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/provider/data_client.ts @@ -0,0 +1,72 @@ +import { applyActions, openDocument } from '$app/application/document/document_service'; +import { slateNodesToInsertDelta } from '@slate-yjs/core'; +import { convertToSlateValue } from '$app/components/editor/provider/utils/convert'; +import { EventEmitter } from 'events'; +import { BlockActionPB, DocEventPB, DocumentNotification } from '@/services/backend'; +import { AsyncQueue } from '$app/utils/async_queue'; +import { subscribeNotification } from '$app/application/notification'; +import { YDelta } from '$app/components/editor/provider/types/y_event'; +import { DocEvent2YDelta } from '$app/components/editor/provider/utils/delta'; + +export class DataClient extends EventEmitter { + private queue: AsyncQueue<ReturnType<typeof BlockActionPB.prototype.toObject>[]>; + private unsubscribe: Promise<() => void>; + public rootId?: string; + + constructor(private id: string) { + super(); + this.queue = new AsyncQueue(this.sendActions); + this.unsubscribe = subscribeNotification(DocumentNotification.DidReceiveUpdate, this.sendMessage); + + this.on('update', this.handleReceiveMessage); + } + + public disconnect() { + this.off('update', this.handleReceiveMessage); + + void this.unsubscribe.then((unsubscribe) => unsubscribe()); + } + + public async getInsertDelta() { + const data = await openDocument(this.id); + + this.rootId = data.rootId; + + return slateNodesToInsertDelta(convertToSlateValue(data)); + } + + public on(event: 'change', listener: (events: YDelta) => void): this; + public on(event: 'update', listener: (actions: ReturnType<typeof BlockActionPB.prototype.toObject>[]) => void): this; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + public on(event: string, listener: (...args: any[]) => void): this { + return super.on(event, listener); + } + + public off(event: 'change', listener: (events: YDelta) => void): this; + public off(event: 'update', listener: (actions: ReturnType<typeof BlockActionPB.prototype.toObject>[]) => void): this; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + public off(event: string, listener: (...args: any[]) => void): this { + return super.off(event, listener); + } + + public emit(event: 'change', events: YDelta): boolean; + public emit(event: 'update', actions: ReturnType<typeof BlockActionPB.prototype.toObject>[]): boolean; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + public emit(event: string, ...args: any[]): boolean { + return super.emit(event, ...args); + } + + private sendMessage = (docEvent: DocEventPB) => { + // transform events to ops + this.emit('change', DocEvent2YDelta(docEvent)); + }; + + private handleReceiveMessage = (actions: ReturnType<typeof BlockActionPB.prototype.toObject>[]) => { + this.queue.enqueue(actions); + }; + + private sendActions = async (actions: ReturnType<typeof BlockActionPB.prototype.toObject>[]) => { + if (!actions.length) return; + await applyActions(this.id, actions); + }; +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/provider/index.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/provider/index.ts new file mode 100644 index 0000000000..03be03e588 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/provider/index.ts @@ -0,0 +1 @@ +export * from './provider'; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/provider/provider.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/provider/provider.ts new file mode 100644 index 0000000000..52af850b02 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/provider/provider.ts @@ -0,0 +1,76 @@ +import * as Y from 'yjs'; + +import { DataClient } from '$app/components/editor/provider/data_client'; +import { convertToIdList, fillIdRelationMap } from '$app/components/editor/provider/utils/relation'; +import { YDelta } from '$app/components/editor/provider/types/y_event'; +import { YEvents2BlockActions } from '$app/components/editor/provider/utils/action'; +import { EventEmitter } from 'events'; + +const REMOTE_ORIGIN = 'remote'; + +export class Provider extends EventEmitter { + document: Y.Doc = new Y.Doc(); + // id order + idList: Y.XmlText = this.document.get('idList', Y.XmlText) as Y.XmlText; + // id -> parentId + idRelationMap: Y.Map<string> = this.document.getMap('idRelationMap'); + sharedType: Y.XmlText | null = null; + dataClient: DataClient; + constructor(public id: string) { + super(); + this.dataClient = new DataClient(id); + void this.initialDocument(); + } + + initialDocument = async () => { + const sharedType = this.document.get('local', Y.XmlText) as Y.XmlText; + + // Load the initial value into the yjs document + const delta = await this.dataClient.getInsertDelta(); + + sharedType.applyDelta(delta); + + this.idList.applyDelta(convertToIdList(delta)); + delta.forEach((op) => { + if (op.insert instanceof Y.XmlText) { + fillIdRelationMap(op.insert, this.idRelationMap); + } + }); + + sharedType.setAttribute('blockId', this.dataClient.rootId); + + this.sharedType = sharedType; + this.sharedType?.observeDeep(this.onChange); + this.emit('ready'); + }; + + connect() { + this.dataClient.on('change', this.onRemoteChange); + return; + } + + disconnect() { + this.dataClient.off('change', this.onRemoteChange); + this.dataClient.disconnect(); + this.sharedType?.unobserveDeep(this.onChange); + this.sharedType = null; + } + + onChange = (events: Y.YEvent<Y.XmlText>[], transaction: Y.Transaction) => { + if (transaction.origin === REMOTE_ORIGIN) { + return; + } + + if (!this.sharedType || !events.length) return; + // transform events to actions + this.dataClient.emit('update', YEvents2BlockActions(this.sharedType, events)); + }; + + onRemoteChange = (delta: YDelta) => { + if (!delta.length) return; + + this.document.transact(() => { + this.sharedType?.applyDelta(delta); + }, REMOTE_ORIGIN); + }; +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/provider/types/y_event.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/provider/types/y_event.ts new file mode 100644 index 0000000000..36ec97aa39 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/provider/types/y_event.ts @@ -0,0 +1,12 @@ +import { YXmlText } from 'yjs/dist/src/types/YXmlText'; + +export interface YOp { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + insert?: string | object | any[] | YXmlText | undefined; + retain?: number | undefined; + delete?: number | undefined; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + attributes?: { [p: string]: any } | undefined; +} + +export type YDelta = YOp[]; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/provider/utils/action.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/provider/utils/action.ts new file mode 100644 index 0000000000..ab400adbfe --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/provider/utils/action.ts @@ -0,0 +1,257 @@ +import * as Y from 'yjs'; +import { BlockActionPB, BlockActionTypePB } from '@/services/backend'; +import { generateId } from '$app/components/editor/provider/utils/convert'; +import { YDelta2Delta } from '$app/components/editor/provider/utils/delta'; +import { YDelta } from '$app/components/editor/provider/types/y_event'; +import { convertToIdList, fillIdRelationMap, findPreviousSibling } from '$app/components/editor/provider/utils/relation'; + +export function generateUpdateDataActions(yXmlText: Y.XmlText, data: Record<string, string | boolean>) { + const id = yXmlText.getAttribute('blockId'); + const parentId = yXmlText.getAttribute('parentId'); + + return [ + { + action: BlockActionTypePB.Update, + payload: { + block: { + id, + data: JSON.stringify(data), + parent: parentId, + children: '', + }, + parent_id: parentId, + }, + }, + ]; +} + +export function generateApplyTextActions(yXmlText: Y.XmlText, delta: YDelta) { + const externalId = yXmlText.getAttribute('textId'); + + if (!externalId) return []; + + const deltaString = JSON.stringify(YDelta2Delta(delta)); + + return [ + { + action: BlockActionTypePB.ApplyTextDelta, + payload: { + text_id: externalId, + delta: deltaString, + }, + }, + ]; +} + +export function generateDeleteBlockActions({ id, parentId }: { id: string; parentId: string }) { + return [ + { + action: BlockActionTypePB.Delete, + payload: { + block: { + id, + }, + parent_id: parentId, + }, + }, + ]; +} + +export function generateInsertBlockActions( + insertYXmlText: Y.XmlText +): ReturnType<typeof BlockActionPB.prototype.toObject>[] { + const childrenId = generateId(); + const prev = findPreviousSibling(insertYXmlText); + + const prevId = prev ? prev.getAttribute('blockId') : null; + const parentId = insertYXmlText.getAttribute('parentId'); + const delta = YDelta2Delta(insertYXmlText.toDelta()); + const data = insertYXmlText.getAttribute('data'); + const type = insertYXmlText.getAttribute('type'); + const id = insertYXmlText.getAttribute('blockId'); + const externalId = insertYXmlText.getAttribute('textId'); + + return [ + { + action: BlockActionTypePB.InsertText, + payload: { + text_id: externalId, + delta: JSON.stringify(delta), + }, + }, + { + action: BlockActionTypePB.Insert, + payload: { + block: { + id, + data: JSON.stringify(data), + ty: type, + parent_id: parentId, + children_id: childrenId, + external_id: externalId, + external_type: 'text', + }, + prev_id: prevId, + parent_id: parentId, + }, + }, + ]; +} + +export function generateMoveBlockActions(yXmlText: Y.XmlText, parentId: string, prevId: string | null) { + const id = yXmlText.getAttribute('blockId'); + const blockParentId = yXmlText.getAttribute('parentId'); + + return [ + { + action: BlockActionTypePB.Move, + payload: { + block: { + id, + parent_id: blockParentId, + }, + parent_id: parentId, + prev_id: prevId || '', + }, + }, + ]; +} + +export function YEvents2BlockActions( + sharedType: Y.XmlText, + events: Y.YEvent<Y.XmlText>[] +): ReturnType<typeof BlockActionPB.prototype.toObject>[] { + const actions: ReturnType<typeof BlockActionPB.prototype.toObject>[] = []; + + events.forEach((event) => { + const eventActions = YEvent2BlockActions(sharedType, event); + + if (eventActions.length === 0) return; + + actions.push(...eventActions); + }); + + const deleteActions = actions.filter((action) => action.action === BlockActionTypePB.Delete); + const otherActions = actions.filter((action) => action.action !== BlockActionTypePB.Delete); + + const filteredDeleteActions = filterDeleteActions(deleteActions); + + return [...otherActions, ...filteredDeleteActions]; +} + +function filterDeleteActions(actions: ReturnType<typeof BlockActionPB.prototype.toObject>[]) { + return actions.filter((deleteAction) => { + const { payload } = deleteAction; + + if (payload === undefined) return true; + + const { parent_id } = payload; + + return !actions.some((action) => action.payload?.block?.id === parent_id); + }); +} + +export function YEvent2BlockActions( + sharedType: Y.XmlText, + event: Y.YEvent<Y.XmlText> +): ReturnType<typeof BlockActionPB.prototype.toObject>[] { + const { target: yXmlText, keys, delta } = event; + // when the target is equal to the sharedType, it means that the change type is insert/delete block + const isBlockEvent = yXmlText === sharedType; + + if (isBlockEvent) { + return blockOps2BlockActions(sharedType, delta); + } + + const actions = textOps2BlockActions(yXmlText, delta); + + if (keys.size > 0) { + actions.push(...parentUpdatedOps2BlockActions(yXmlText, keys)); + + actions.push(...dataOps2BlockActions(yXmlText, keys)); + } + + return actions; +} + +function textOps2BlockActions(yXmlText: Y.XmlText, ops: YDelta): ReturnType<typeof BlockActionPB.prototype.toObject>[] { + if (ops.length === 0) return []; + return generateApplyTextActions(yXmlText, ops); +} + +function parentUpdatedOps2BlockActions( + yXmlText: Y.XmlText, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + keys: Map<string, { action: 'update' | 'add' | 'delete'; oldValue: any; newValue: any }> +) { + const parentUpdated = keys.has('parentId'); + + if (!parentUpdated) return []; + const parentId = yXmlText.getAttribute('parentId'); + const prev = findPreviousSibling(yXmlText) as Y.XmlText; + + const prevId = prev?.getAttribute('blockId'); + + fillIdRelationMap(yXmlText, yXmlText.doc?.getMap('idRelationMap') as Y.Map<string>); + + return generateMoveBlockActions(yXmlText, parentId, prevId); +} + +function dataOps2BlockActions( + yXmlText: Y.XmlText, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + keys: Map<string, { action: 'update' | 'add' | 'delete'; oldValue: any; newValue: any }> +) { + const dataUpdated = keys.has('data'); + + if (!dataUpdated) return []; + const data = yXmlText.getAttribute('data'); + + return generateUpdateDataActions(yXmlText, data); +} + +function blockOps2BlockActions( + sharedType: Y.XmlText, + ops: YDelta +): ReturnType<typeof BlockActionPB.prototype.toObject>[] { + const actions: ReturnType<typeof BlockActionPB.prototype.toObject>[] = []; + + const idList = sharedType.doc?.get('idList') as Y.XmlText; + const idRelationMap = sharedType.doc?.getMap('idRelationMap') as Y.Map<string>; + let index = 0; + + ops.forEach((op) => { + if (op.insert) { + if (op.insert instanceof Y.XmlText) { + const insertYXmlText = op.insert; + + actions.push(...generateInsertBlockActions(insertYXmlText)); + } + + index++; + } else if (op.retain) { + index += op.retain; + } else if (op.delete) { + const deletedDelta = idList.toDelta().slice(index, index + op.delete) as { + insert: { + id: string; + }; + }[]; + + deletedDelta.forEach((delta) => { + const parentId = idRelationMap.get(delta.insert.id); + + actions.push( + ...generateDeleteBlockActions({ + id: delta.insert.id, + parentId: parentId || '', + }) + ); + }); + } + }); + + idList.applyDelta(convertToIdList(ops)); + + return actions; +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/provider/utils/convert.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/provider/utils/convert.ts new file mode 100644 index 0000000000..a33c13aa91 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/provider/utils/convert.ts @@ -0,0 +1,111 @@ +import { nanoid } from 'nanoid'; +import { EditorData, EditorInlineNodeType, EditorNodeType, Mention } from '$app/application/document/document.types'; +import { Element, Text } from 'slate'; +import { Op } from 'quill-delta'; + +export function generateId() { + return nanoid(10); +} + +export function transformToInlineElement(op: Op): Element | null { + const attributes = op.attributes; + + if (!attributes) return null; + const isFormula = attributes.formula; + + if (isFormula) { + return { + type: EditorInlineNodeType.Formula, + data: true, + children: [ + { + text: op.insert as string, + ...attributes, + }, + ], + }; + } + + const matchMention = attributes.mention as Mention; + + if (matchMention) { + return { + type: EditorInlineNodeType.Mention, + children: [ + { + text: op.insert as string, + }, + ], + data: { + ...matchMention, + }, + }; + } + + return null; +} + +export function convertToSlateValue(data: EditorData): Element[] { + const nodes: Element[] = []; + const traverse = (id: string, level: number, isHidden?: boolean) => { + const node = data.nodeMap[id]; + const delta = data.deltaMap[id]; + + const slateNode: Element = { + type: node.type, + data: node.data, + level, + children: [], + isHidden, + blockId: id, + parentId: node.parent || '', + textId: node.externalId || '', + }; + + const inlineNodes: (Text | Element)[] = delta + ? data.deltaMap[id].map((op) => { + const matchInline = transformToInlineElement(op); + + if (matchInline) { + return matchInline; + } + + return { + text: op.insert as string, + ...op.attributes, + }; + }) + : []; + + slateNode.children.push(...inlineNodes); + + nodes.push(slateNode); + const children = data.childrenMap[id]; + + if (children) { + for (const childId of children) { + let isHidden = false; + + if (node.type === EditorNodeType.ToggleListBlock) { + const collapsed = (node.data as { collapsed: boolean })?.collapsed; + + if (collapsed) { + isHidden = true; + } + } + + traverse(childId, level + 1, isHidden); + } + } + + return slateNode; + }; + + const rootId = data.rootId; + + traverse(rootId, 0); + + nodes.shift(); + + return nodes; +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/provider/utils/delta.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/provider/utils/delta.ts new file mode 100644 index 0000000000..e9aff8a1c1 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/provider/utils/delta.ts @@ -0,0 +1,39 @@ +import { YDelta, YOp } from '$app/components/editor/provider/types/y_event'; +import { Op } from 'quill-delta'; +import * as Y from 'yjs'; +import { inlineNodeTypes } from '$app/application/document/document.types'; +import { DocEventPB } from '@/services/backend'; + +export function YDelta2Delta(yDelta: YDelta): Op[] { + return yDelta.map((op) => { + if (op.insert instanceof Y.XmlText) { + const type = op.insert.getAttribute('type'); + + if (inlineNodeTypes.includes(type)) { + return YInlineOp2Op(op); + } + } + + return op as Op; + }); +} + +export function YInlineOp2Op(yOp: YOp): Op { + if (!(yOp.insert instanceof Y.XmlText)) return yOp as Op; + + const type = yOp.insert.getAttribute('type'); + const data = yOp.insert.getAttribute('data'); + + return { + insert: yOp.insert.toJSON(), + attributes: { + [type]: data, + }, + }; +} + +export function DocEvent2YDelta(events: DocEventPB): YDelta { + if (!events.is_remote) return []; + + return []; +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/provider/utils/relation.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/provider/utils/relation.ts new file mode 100644 index 0000000000..8772b70f6d --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/provider/utils/relation.ts @@ -0,0 +1,46 @@ +import * as Y from 'yjs'; +import { YDelta } from '$app/components/editor/provider/types/y_event'; + +export function findPreviousSibling(yXmlText: Y.XmlText) { + let prev = yXmlText.prevSibling; + + if (!prev) return null; + + const level = yXmlText.getAttribute('level'); + + while (prev) { + const prevLevel = prev.getAttribute('level'); + + if (prevLevel === level) return prev; + if (prevLevel < level) return null; + + prev = prev.prevSibling; + } + + return prev; +} + +export function fillIdRelationMap(yXmlText: Y.XmlText, idRelationMap: Y.Map<string>) { + const id = yXmlText.getAttribute('blockId'); + const parentId = yXmlText.getAttribute('parentId'); + + if (id && parentId) { + idRelationMap.set(id, parentId); + } +} + +export function convertToIdList(ops: YDelta) { + return ops.map((op) => { + if (op.insert instanceof Y.XmlText) { + const id = op.insert.getAttribute('blockId'); + + return { + insert: { + id, + }, + }; + } + + return op; + }); +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/grid/Grid/Grid.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/grid/Grid/Grid.tsx deleted file mode 100644 index d52cee5c04..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/grid/Grid/Grid.tsx +++ /dev/null @@ -1,81 +0,0 @@ -import { useDatabase } from '$app/components/_shared/database-hooks/useDatabase'; -import { GridTableCount } from '../GridTableCount/GridTableCount'; -import { GridTableHeader } from '../GridTableHeader/GridTableHeader'; -import { GridTableRows } from '../GridTableRows/GridTableRows'; -import { GridTitle } from '../GridTitle/GridTitle'; -import { GridToolbar } from '../GridToolbar/GridToolbar'; -import { EditRow } from '$app/components/_shared/EditRow/EditRow'; -import { useState } from 'react'; -import { RowInfo } from '$app/stores/effects/database/row/row_cache'; -import { ViewLayoutPB } from '@/services/backend'; -import { DatabaseFilterPopup } from '$app/components/_shared/DatabaseFilter/DatabaseFilterPopup'; -import { DatabaseSortPopup } from '$app/components/_shared/DatabaseSort/DatabaseSortPopup'; - -export const Grid = ({ viewId }: { viewId: string }) => { - const { controller, rows, groups } = useDatabase(viewId, ViewLayoutPB.Grid); - const [showGridRow, setShowGridRow] = useState(false); - const [boardRowInfo, setBoardRowInfo] = useState<RowInfo>(); - const [showFilterPopup, setShowFilterPopup] = useState(false); - const [showSortPopup, setShowSortPopup] = useState(false); - - const onOpenRow = (rowInfo: RowInfo) => { - setBoardRowInfo(rowInfo); - setShowGridRow(true); - }; - - const onShowFilterClick = () => { - setShowFilterPopup(true); - }; - - const onShowSortClick = () => { - setShowSortPopup(true); - }; - - return ( - <> - {controller && groups && ( - <> - <div className='flex flex-1 flex-col gap-4'> - <div className='flex w-full items-center justify-between'> - <GridTitle onShowFilterClick={onShowFilterClick} onShowSortClick={onShowSortClick} viewId={viewId} /> - <GridToolbar /> - </div> - - {/* table component page with text area for td */} - <div className='flex flex-1 flex-col gap-4'> - <div className='flex flex-1 flex-col overflow-x-auto'> - <GridTableHeader - controller={controller} - onShowFilterClick={onShowFilterClick} - onShowSortClick={onShowSortClick} - /> - <div className={'relative flex-1'}> - <GridTableRows onOpenRow={onOpenRow} allRows={rows} viewId={viewId} controller={controller} /> - </div> - </div> - </div> - - <GridTableCount rows={rows} /> - </div> - {showGridRow && boardRowInfo && ( - <EditRow - onClose={() => setShowGridRow(false)} - viewId={viewId} - controller={controller} - rowInfo={boardRowInfo} - ></EditRow> - )} - </> - )} - {showFilterPopup && controller && controller.filterController && ( - <DatabaseFilterPopup - filterController={controller.filterController} - onOutsideClick={() => setShowFilterPopup(false)} - /> - )} - {showSortPopup && controller && controller.sortController && ( - <DatabaseSortPopup sortController={controller.sortController} onOutsideClick={() => setShowSortPopup(false)} /> - )} - </> - ); -}; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/grid/GridAddView/GridAddView.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/grid/GridAddView/GridAddView.tsx deleted file mode 100644 index 6a28c3f4a8..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/grid/GridAddView/GridAddView.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import AddSvg from '../../_shared/svg/AddSvg'; - -export const GridAddView = () => { - return ( - <button className='flex cursor-pointer items-center rounded-lg p-2 text-sm hover:bg-fill-list-hover'> - <i className='mr-2 h-5 w-5'> - <AddSvg /> - </i> - <span>Add View</span> - </button> - ); -}; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/grid/GridCell/GridCell.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/grid/GridCell/GridCell.tsx deleted file mode 100644 index 85ede19f03..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/grid/GridCell/GridCell.tsx +++ /dev/null @@ -1,46 +0,0 @@ -import { CellIdentifier } from '@/appflowy_app/stores/effects/database/cell/cell_bd_svc'; -import { CellCache } from '@/appflowy_app/stores/effects/database/cell/cell_cache'; -import { FieldController } from '@/appflowy_app/stores/effects/database/field/field_controller'; -import { FieldType } from '@/services/backend'; -import GridSingleSelectOptions from './GridSingleSelectOptions'; -import GridTextCell from './GridTextCell'; -import { GridCheckBox } from './GridCheckBox'; -import { GridDate } from './GridDate'; -import { GridUrl } from './GridUrl'; -import { GridNumberCell } from './GridNumberCell'; - -export const GridCell = ({ - cellIdentifier, - cellCache, - fieldController, - width, -}: { - cellIdentifier: CellIdentifier; - cellCache: CellCache; - fieldController: FieldController; - width?: number; -}) => { - return ( - <div style={{ width }}> - {cellIdentifier.fieldType === FieldType.MultiSelect || - cellIdentifier.fieldType === FieldType.Checklist || - cellIdentifier.fieldType === FieldType.SingleSelect ? ( - <GridSingleSelectOptions - cellIdentifier={cellIdentifier} - cellCache={cellCache} - fieldController={fieldController} - /> - ) : cellIdentifier.fieldType === FieldType.Checkbox ? ( - <GridCheckBox cellIdentifier={cellIdentifier} cellCache={cellCache} fieldController={fieldController} /> - ) : cellIdentifier.fieldType === FieldType.DateTime ? ( - <GridDate cellIdentifier={cellIdentifier} cellCache={cellCache} fieldController={fieldController}></GridDate> - ) : cellIdentifier.fieldType === FieldType.URL ? ( - <GridUrl cellIdentifier={cellIdentifier} cellCache={cellCache} fieldController={fieldController}></GridUrl> - ) : cellIdentifier.fieldType === FieldType.Number ? ( - <GridNumberCell cellIdentifier={cellIdentifier} cellCache={cellCache} fieldController={fieldController} /> - ) : ( - <GridTextCell cellIdentifier={cellIdentifier} cellCache={cellCache} fieldController={fieldController} /> - )} - </div> - ); -}; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/grid/GridCell/GridCheckBox.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/grid/GridCell/GridCheckBox.tsx deleted file mode 100644 index f73bcdc974..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/grid/GridCell/GridCheckBox.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import { CellIdentifier } from '@/appflowy_app/stores/effects/database/cell/cell_bd_svc'; -import { CellCache } from '@/appflowy_app/stores/effects/database/cell/cell_cache'; -import { FieldController } from '@/appflowy_app/stores/effects/database/field/field_controller'; -import { EditCheckboxCell } from '../../_shared/EditRow/InlineEditFields/EditCheckboxCell'; -import { useCell } from '../../_shared/database-hooks/useCell'; - -export const GridCheckBox = ({ - cellIdentifier, - cellCache, - fieldController, -}: { - cellIdentifier: CellIdentifier; - cellCache: CellCache; - fieldController: FieldController; -}) => { - const { data, cellController } = useCell(cellIdentifier, cellCache, fieldController); - - return ( - <EditCheckboxCell - onToggle={async () => { - if (data === 'Yes') { - await cellController?.saveCellData('No'); - } else { - await cellController?.saveCellData('Yes'); - } - }} - data={data as 'Yes' | 'No' | undefined} - /> - ); -}; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/grid/GridCell/GridDate.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/grid/GridCell/GridDate.tsx deleted file mode 100644 index 8bcee8268e..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/grid/GridCell/GridDate.tsx +++ /dev/null @@ -1,47 +0,0 @@ -import { CellIdentifier } from '@/appflowy_app/stores/effects/database/cell/cell_bd_svc'; -import { CellCache } from '@/appflowy_app/stores/effects/database/cell/cell_cache'; -import { FieldController } from '@/appflowy_app/stores/effects/database/field/field_controller'; -import { useCell } from '../../_shared/database-hooks/useCell'; -import { DateCellDataPB } from '@/services/backend'; -import { EditCellDate } from '../../_shared/EditRow/Date/EditCellDate'; -import { useState } from 'react'; -import { DatePickerPopup } from '../../_shared/EditRow/Date/DatePickerPopup'; - -export const GridDate = ({ - cellIdentifier, - cellCache, - fieldController, -}: { - cellIdentifier: CellIdentifier; - cellCache: CellCache; - fieldController: FieldController; -}) => { - const { data, cellController } = useCell(cellIdentifier, cellCache, fieldController); - - const [showDatePopup, setShowDatePopup] = useState(false); - const [datePickerTop, setdatePickerTop] = useState(0); - const [datePickerLeft, setdatePickerLeft] = useState(0); - - const onEditDateClick = async (left: number, top: number) => { - setdatePickerLeft(left); - setdatePickerTop(top); - setShowDatePopup(true); - }; - - return ( - <> - {cellController && <EditCellDate data={data as DateCellDataPB} onEditClick={onEditDateClick}></EditCellDate>} - - {showDatePopup && ( - <DatePickerPopup - top={datePickerTop} - left={datePickerLeft} - cellIdentifier={cellIdentifier} - cellCache={cellCache} - fieldController={fieldController} - onOutsideClick={() => setShowDatePopup(false)} - ></DatePickerPopup> - )} - </> - ); -}; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/grid/GridCell/GridNumberCell.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/grid/GridCell/GridNumberCell.tsx deleted file mode 100644 index b2510d9174..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/grid/GridCell/GridNumberCell.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import { CellIdentifier } from '@/appflowy_app/stores/effects/database/cell/cell_bd_svc'; -import { CellCache } from '@/appflowy_app/stores/effects/database/cell/cell_cache'; -import { FieldController } from '@/appflowy_app/stores/effects/database/field/field_controller'; -import { useCell } from '../../_shared/database-hooks/useCell'; -import { EditCellNumber } from '../../_shared/EditRow/InlineEditFields/EditCellNumber'; - -export const GridNumberCell = ({ - cellIdentifier, - cellCache, - fieldController, -}: { - cellIdentifier: CellIdentifier; - cellCache: CellCache; - fieldController: FieldController; -}) => { - const { data, cellController } = useCell(cellIdentifier, cellCache, fieldController); - - return ( - <EditCellNumber - data={data as string | undefined} - onSave={async (value) => { - await cellController?.saveCellData(value); - }} - ></EditCellNumber> - ); -}; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/grid/GridCell/GridSingleSelectOptions.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/grid/GridCell/GridSingleSelectOptions.tsx deleted file mode 100644 index 2b4fdb1c70..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/grid/GridCell/GridSingleSelectOptions.tsx +++ /dev/null @@ -1,74 +0,0 @@ -import { useState } from 'react'; -import { CellOptions } from '$app/components/_shared/EditRow/Options/CellOptions'; -import { CellIdentifier } from '@/appflowy_app/stores/effects/database/cell/cell_bd_svc'; -import { CellCache } from '@/appflowy_app/stores/effects/database/cell/cell_cache'; -import { FieldController } from '@/appflowy_app/stores/effects/database/field/field_controller'; -import { useCell } from '$app/components/_shared/database-hooks/useCell'; -import { CellOptionsPopup } from '$app/components/_shared/EditRow/Options/CellOptionsPopup'; -import { EditCellOptionPopup } from '$app/components/_shared/EditRow/Options/EditCellOptionPopup'; -import { SelectOptionCellDataPB, SelectOptionPB } from '@/services/backend'; - -export default function GridSingleSelectOptions({ - cellIdentifier, - cellCache, - fieldController, -}: { - cellIdentifier: CellIdentifier; - cellCache: CellCache; - fieldController: FieldController; -}) { - const { data } = useCell(cellIdentifier, cellCache, fieldController); - - const [showOptionsPopup, setShowOptionsPopup] = useState(false); - const [changeOptionsTop, setChangeOptionsTop] = useState(0); - const [changeOptionsLeft, setChangeOptionsLeft] = useState(0); - - const [showEditCellOption, setShowEditCellOption] = useState(false); - const [editCellOptionTop, setEditCellOptionTop] = useState(0); - const [editCellOptionLeft, setEditCellOptionLeft] = useState(0); - - const [editingSelectOption, setEditingSelectOption] = useState<SelectOptionPB | undefined>(); - - const onEditOptionsClick = async (left: number, top: number) => { - setChangeOptionsLeft(left); - setChangeOptionsTop(top); - setShowOptionsPopup(true); - }; - - const onOpenOptionDetailClick = (_left: number, _top: number, _select_option: SelectOptionPB) => { - setEditingSelectOption(_select_option); - setShowEditCellOption(true); - setEditCellOptionLeft(_left); - setEditCellOptionTop(_top); - }; - - return ( - <> - <CellOptions data={data as SelectOptionCellDataPB} onEditClick={onEditOptionsClick} /> - - {showOptionsPopup && ( - <CellOptionsPopup - top={changeOptionsTop} - left={changeOptionsLeft} - cellIdentifier={cellIdentifier} - cellCache={cellCache} - fieldController={fieldController} - onOutsideClick={() => !showEditCellOption && setShowOptionsPopup(false)} - openOptionDetail={onOpenOptionDetailClick} - /> - )} - {showEditCellOption && editingSelectOption && ( - <EditCellOptionPopup - top={editCellOptionTop} - left={editCellOptionLeft} - cellIdentifier={cellIdentifier} - editingSelectOption={editingSelectOption} - setEditingSelectOption={setEditingSelectOption} - onOutsideClick={() => { - setShowEditCellOption(false); - }} - ></EditCellOptionPopup> - )} - </> - ); -} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/grid/GridCell/GridTextCell.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/grid/GridCell/GridTextCell.tsx deleted file mode 100644 index 77e7c4a1e4..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/grid/GridCell/GridTextCell.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import { CellIdentifier } from '@/appflowy_app/stores/effects/database/cell/cell_bd_svc'; -import { CellCache } from '@/appflowy_app/stores/effects/database/cell/cell_cache'; -import { FieldController } from '@/appflowy_app/stores/effects/database/field/field_controller'; -import { useCell } from '../../_shared/database-hooks/useCell'; -import { EditCellText } from '../../_shared/EditRow/InlineEditFields/EditCellText'; - -export default function GridTextCell({ - cellIdentifier, - cellCache, - fieldController, -}: { - cellIdentifier: CellIdentifier; - cellCache: CellCache; - fieldController: FieldController; -}) { - const { data, cellController } = useCell(cellIdentifier, cellCache, fieldController); - - return ( - <EditCellText - data={data as string | undefined} - onSave={async (value) => { - await cellController?.saveCellData(value); - }} - ></EditCellText> - ); -} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/grid/GridCell/GridUrl.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/grid/GridCell/GridUrl.tsx deleted file mode 100644 index 67783eed15..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/grid/GridCell/GridUrl.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import { CellIdentifier } from '@/appflowy_app/stores/effects/database/cell/cell_bd_svc'; -import { CellCache } from '@/appflowy_app/stores/effects/database/cell/cell_cache'; -import { FieldController } from '@/appflowy_app/stores/effects/database/field/field_controller'; -import { useCell } from '../../_shared/database-hooks/useCell'; -import { EditCellUrl } from '../../_shared/EditRow/InlineEditFields/EditCellUrl'; -import { URLCellDataPB } from '@/services/backend'; - -export const GridUrl = ({ - cellIdentifier, - cellCache, - fieldController, -}: { - cellIdentifier: CellIdentifier; - cellCache: CellCache; - fieldController: FieldController; -}) => { - const { data, cellController } = useCell(cellIdentifier, cellCache, fieldController); - - return ( - <EditCellUrl - data={data as URLCellDataPB} - onSave={async (value) => { - await cellController?.saveCellData(value); - }} - ></EditCellUrl> - ); -}; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/grid/GridTableCount/GridTableCount.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/grid/GridTableCount/GridTableCount.tsx deleted file mode 100644 index 51ffd11186..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/grid/GridTableCount/GridTableCount.tsx +++ /dev/null @@ -1,11 +0,0 @@ -import { RowInfo } from '@/appflowy_app/stores/effects/database/row/row_cache'; - -export const GridTableCount = ({ rows }: { rows: readonly RowInfo[] }) => { - const count = rows.length; - - return ( - <span> - Count : <span className='font-semibold'>{count}</span> - </span> - ); -}; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/grid/GridTableHeader/GridTableHeader.hooks.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/grid/GridTableHeader/GridTableHeader.hooks.tsx deleted file mode 100644 index f932e11222..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/grid/GridTableHeader/GridTableHeader.hooks.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import { useAppSelector } from '$app/stores/store'; -import { DatabaseController } from '@/appflowy_app/stores/effects/database/database_controller'; -import { TypeOptionController } from '@/appflowy_app/stores/effects/database/field/type_option/type_option_controller'; -import { None } from 'ts-results'; - -export const useGridTableHeaderHooks = function (controller: DatabaseController) { - const database = useAppSelector((state) => state.database); - - const onAddField = async () => { - // TODO: move this to database controller hook - const fieldController = new TypeOptionController(controller.viewId, None); - - await fieldController.initialize(); - }; - - return { - fields: Object.values(database.fields).map((field) => { - return { - fieldId: field.fieldId, - name: field.title, - fieldType: field.fieldType, - }; - }), - onAddField, - }; -}; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/grid/GridTableHeader/GridTableHeader.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/grid/GridTableHeader/GridTableHeader.tsx deleted file mode 100644 index afbe789c17..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/grid/GridTableHeader/GridTableHeader.tsx +++ /dev/null @@ -1,50 +0,0 @@ -import { DatabaseController } from '@/appflowy_app/stores/effects/database/database_controller'; -import AddSvg from '../../_shared/svg/AddSvg'; -import { useGridTableHeaderHooks } from './GridTableHeader.hooks'; -import { GridTableHeaderItem } from './GridTableHeaderItem'; -import { useTranslation } from 'react-i18next'; -import { useAppSelector } from '$app/stores/store'; - -export const GridTableHeader = ({ - controller, - onShowFilterClick, - onShowSortClick, -}: { - controller: DatabaseController; - onShowFilterClick: () => void; - onShowSortClick: () => void; -}) => { - const columns = useAppSelector((state) => state.database.columns); - const fields = useAppSelector((state) => state.database.fields); - const { onAddField } = useGridTableHeaderHooks(controller); - const { t } = useTranslation(); - - return ( - <div className={'flex select-none text-xs'} style={{ userSelect: 'none' }}> - <div className={'w-7 flex-shrink-0'}></div> - {columns - .filter((column) => column.visible) - .map((column, i) => { - return ( - <GridTableHeaderItem - onShowFilterClick={onShowFilterClick} - onShowSortClick={onShowSortClick} - field={fields[column.fieldId]} - controller={controller} - key={i} - index={i} - /> - ); - })} - <div - onClick={onAddField} - className='-ml-1.5 flex w-40 flex-shrink-0 cursor-pointer items-center border-b border-t border-line-divider px-4 py-2 text-text-caption hover:bg-fill-list-hover hover:text-text-title' - > - <i className='mr-2 h-5 w-5'> - <AddSvg /> - </i> - <span className={'whitespace-nowrap'}>{t('grid.field.newProperty')}</span> - </div> - </div> - ); -}; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/grid/GridTableHeader/GridTableHeaderItem.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/grid/GridTableHeader/GridTableHeaderItem.tsx deleted file mode 100644 index 194709d40c..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/grid/GridTableHeader/GridTableHeaderItem.tsx +++ /dev/null @@ -1,189 +0,0 @@ -import { CellIdentifier } from '@/appflowy_app/stores/effects/database/cell/cell_bd_svc'; -import { DatabaseController } from '@/appflowy_app/stores/effects/database/database_controller'; -import { TypeOptionController } from '@/appflowy_app/stores/effects/database/field/type_option/type_option_controller'; -import { FieldType } from '@/services/backend'; -import { useState, useRef, useEffect } from 'react'; -import { Some } from 'ts-results'; -import { ChangeFieldTypePopup } from '../../_shared/EditRow/ChangeFieldTypePopup'; -import { EditFieldPopup } from '../../_shared/EditRow/EditFieldPopup'; -import { databaseActions, IDatabaseField } from '$app_reducers/database/slice'; -import { FieldTypeIcon } from '$app/components/_shared/EditRow/FieldTypeIcon'; -import { useResizer } from '$app/components/_shared/useResizer'; -import { useAppDispatch, useAppSelector } from '$app/stores/store'; -import { Details2Svg } from '$app/components/_shared/svg/Details2Svg'; -import { FilterSvg } from '$app/components/_shared/svg/FilterSvg'; -import { SortAscSvg } from '$app/components/_shared/svg/SortAscSvg'; -import { PromptWindow } from '$app/components/_shared/PromptWindow'; - -const MIN_COLUMN_WIDTH = 100; - -export const GridTableHeaderItem = ({ - controller, - field, - index, - onShowFilterClick, - onShowSortClick, -}: { - controller: DatabaseController; - field: IDatabaseField; - index: number; - onShowFilterClick: () => void; - onShowSortClick: () => void; -}) => { - const { onMouseDown, newSizeX } = useResizer((final) => { - if (final < MIN_COLUMN_WIDTH) return; - void controller.changeWidth({ fieldId: field.fieldId, width: final }); - }); - - const filtersStore = useAppSelector((state) => state.database.filters); - const sortStore = useAppSelector((state) => state.database.sort); - - const dispatch = useAppDispatch(); - const [showFieldEditor, setShowFieldEditor] = useState(false); - const [showChangeFieldTypePopup, setShowChangeFieldTypePopup] = useState(false); - const [changeFieldTypeAnchorEl, setChangeFieldTypeAnchorEl] = useState<HTMLDivElement | null>(null); - const [editingField, setEditingField] = useState<IDatabaseField | null>(null); - const [deletingPropertyId, setDeletingPropertyId] = useState<string | null>(null); - const [showDeletePropertyPrompt, setShowDeletePropertyPrompt] = useState(false); - - const ref = useRef<HTMLDivElement>(null); - - useEffect(() => { - if (!newSizeX) return; - if (newSizeX >= MIN_COLUMN_WIDTH) { - dispatch(databaseActions.changeWidth({ fieldId: field.fieldId, width: newSizeX })); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [newSizeX, dispatch]); - - const changeFieldType = async (newType: FieldType) => { - if (!editingField) return; - - const currentField = controller.fieldController.getField(editingField.fieldId); - - if (!currentField) return; - - const typeOptionController = new TypeOptionController(controller.viewId, Some(currentField)); - - await typeOptionController.switchToField(newType); - - setEditingField({ - ...editingField, - fieldType: newType, - }); - - setShowChangeFieldTypePopup(false); - }; - - const onFieldOptionsClick = () => { - setEditingField(field); - setShowFieldEditor(true); - }; - - const onDeletePropertyClick = (fieldId: string) => { - setDeletingPropertyId(fieldId); - setShowDeletePropertyPrompt(true); - }; - - const onDelete = async () => { - if (!deletingPropertyId) return; - const fieldInfo = controller.fieldController.getField(deletingPropertyId); - - if (!fieldInfo) return; - const typeController = new TypeOptionController(controller.viewId, Some(fieldInfo)); - - setEditingField(null); - - await typeController.initialize(); - await typeController.deleteField(); - setShowDeletePropertyPrompt(false); - }; - - return ( - <> - <div - // field width minus divider width with padding - style={{ width: `${field.width - (index === 0 ? 7 : 14)}px` }} - className='flex-shrink-0 border-b border-t border-line-divider' - > - <div className={'flex w-full items-center justify-between py-2 pl-2'} ref={ref}> - <div className={'flex min-w-0 items-center gap-2'}> - <div className={'flex h-5 w-5 flex-shrink-0 items-center justify-center text-text-caption'}> - <FieldTypeIcon fieldType={field.fieldType}></FieldTypeIcon> - </div> - <span className={'overflow-hidden text-ellipsis whitespace-nowrap text-text-caption'}>{field.title}</span> - </div> - <div className={'flex items-center gap-1'}> - {sortStore.findIndex((sort) => sort.fieldId === field.fieldId) !== -1 && ( - <button onClick={onShowSortClick} className={'rounded p-1 hover:bg-fill-list-hover'}> - <i className={'block h-[16px] w-[16px]'}> - <SortAscSvg></SortAscSvg> - </i> - </button> - )} - - {filtersStore.findIndex((filter) => filter.fieldId === field.fieldId) !== -1 && ( - <button onClick={onShowFilterClick} className={'rounded p-1 hover:bg-fill-list-hover'}> - <i className={'block h-[16px] w-[16px]'}> - <FilterSvg></FilterSvg> - </i> - </button> - )} - - <button className={'rounded p-1 hover:bg-fill-list-hover'} onClick={() => onFieldOptionsClick()}> - <i className={'block h-[16px] w-[16px]'}> - <Details2Svg></Details2Svg> - </i> - </button> - </div> - </div> - </div> - <div - className={'group h-full cursor-col-resize border-b border-t border-line-divider px-[6px]'} - onMouseDown={(e) => onMouseDown(e, field.width)} - > - <div className={'flex h-full w-[3px] justify-center group-hover:bg-fill-hover'}> - <div className={'h-full w-[1px] bg-line-divider group-hover:bg-fill-hover'}></div> - </div> - </div> - {editingField && ( - <EditFieldPopup - open={showFieldEditor} - anchorEl={ref.current} - cellIdentifier={ - { - fieldId: editingField.fieldId, - fieldType: editingField.fieldType, - viewId: controller.viewId, - } as CellIdentifier - } - viewId={controller.viewId} - onOutsideClick={() => { - setShowFieldEditor(false); - }} - controller={controller} - changeFieldTypeClick={(el) => { - setChangeFieldTypeAnchorEl(el); - setShowChangeFieldTypePopup(true); - }} - onDeletePropertyClick={onDeletePropertyClick} - ></EditFieldPopup> - )} - - <ChangeFieldTypePopup - open={showChangeFieldTypePopup} - anchorEl={changeFieldTypeAnchorEl} - onClick={(newType) => changeFieldType(newType)} - onOutsideClick={() => setShowChangeFieldTypePopup(false)} - ></ChangeFieldTypePopup> - - {showDeletePropertyPrompt && ( - <PromptWindow - msg={'Are you sure you want to delete this property?'} - onYes={() => onDelete()} - onCancel={() => setShowDeletePropertyPrompt(false)} - ></PromptWindow> - )} - </> - ); -}; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/grid/GridTableRows/GridRowActions.hooks.ts b/frontend/appflowy_tauri/src/appflowy_app/components/grid/GridTableRows/GridRowActions.hooks.ts deleted file mode 100644 index c9f96ed1b3..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/grid/GridTableRows/GridRowActions.hooks.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { DatabaseController } from '@/appflowy_app/stores/effects/database/database_controller'; - -export const useGridRowActions = (controller: DatabaseController) => { - const deleteRow = async (rowId: string) => { - await controller.deleteRow(rowId); - }; - - const insertRowAfter = async (rowId: string) => { - await controller.createRowAfter(rowId); - }; - - const duplicateRow = async (rowId: string) => { - await controller.duplicateRow(rowId); - }; - - return { - deleteRow, - insertRowAfter, - duplicateRow, - }; -}; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/grid/GridTableRows/GridRowActions.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/grid/GridTableRows/GridRowActions.tsx deleted file mode 100644 index b7076d974f..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/grid/GridTableRows/GridRowActions.tsx +++ /dev/null @@ -1,106 +0,0 @@ -import AddSvg from '../../_shared/svg/AddSvg'; -import { CopySvg } from '../../_shared/svg/CopySvg'; -import { TrashSvg } from '../../_shared/svg/TrashSvg'; -import { ShareSvg } from '../../_shared/svg/ShareSvg'; -import { DatabaseController } from '@/appflowy_app/stores/effects/database/database_controller'; -import { useGridRowActions } from './GridRowActions.hooks'; -import { List, Popover } from '@mui/material'; -import MenuItem from '@mui/material/MenuItem'; -import React, { useRef, useState } from 'react'; -import { useTranslation } from 'react-i18next'; - -export const GridRowActions = ({ - controller, - rowId, - isDragging, - children, -}: { - controller: DatabaseController; - rowId: string; - isDragging: boolean; - children: React.ReactNode; -}) => { - const { deleteRow, duplicateRow, insertRowAfter } = useGridRowActions(controller); - const optionsButtonEl = useRef<HTMLButtonElement>(null); - const [showMenu, setShowMenu] = useState(false); - const { t } = useTranslation(); - - return ( - <> - <div className={'flex flex-shrink-0 items-center justify-center'}> - <button - ref={optionsButtonEl} - onClick={() => setShowMenu(true)} - className={`cursor-pointer items-center justify-center rounded p-1 opacity-0 hover:bg-fill-list-hover group-hover/row:opacity-100 ${ - isDragging || showMenu ? '!opacity-100' : '' - }`} - > - {children} - </button> - </div> - <Popover - open={showMenu} - anchorEl={optionsButtonEl.current} - anchorOrigin={{ - vertical: 'bottom', - horizontal: 'left', - }} - transformOrigin={{ - vertical: 'top', - horizontal: 'left', - }} - onClose={() => setShowMenu(false)} - > - <List> - <MenuItem - onClick={() => { - void insertRowAfter(rowId); - setShowMenu(false); - }} - > - <span className={'mr-2'}> - <i className={'block h-[16px] w-[16px]'}> - <AddSvg /> - </i> - </span> - <span>{t('button.insertBelow')}</span> - </MenuItem> - <MenuItem onClick={() => console.log('copy link')}> - <span className={'mr-2'}> - <i className={'block h-[16px] w-[16px]'}> - <ShareSvg /> - </i> - </span> - <span>{t('shareAction.copyLink')}</span> - </MenuItem> - <MenuItem - onClick={() => { - void duplicateRow(rowId); - setShowMenu(false); - }} - > - <span className={'mr-2'}> - <i className={'block h-[16px] w-[16px]'}> - <CopySvg /> - </i> - </span> - <span>{t('grid.row.duplicate')}</span> - </MenuItem> - <MenuItem - onClick={() => { - void deleteRow(rowId); - setShowMenu(false); - }} - > - <span className={'mr-2'}> - <i className={'block h-[16px] w-[16px]'}> - <TrashSvg /> - </i> - </span> - <span>{t('grid.row.delete')}</span> - </MenuItem> - </List> - </Popover> - </> - ); -}; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/grid/GridTableRows/GridTableCell.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/grid/GridTableRows/GridTableCell.tsx deleted file mode 100644 index ae011b8d7f..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/grid/GridTableRows/GridTableCell.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import { CellIdentifier } from '@/appflowy_app/stores/effects/database/cell/cell_bd_svc'; -import { CellCache } from '@/appflowy_app/stores/effects/database/cell/cell_cache'; -import { FieldController } from '@/appflowy_app/stores/effects/database/field/field_controller'; -import { GridCell } from '../GridCell/GridCell'; - -export const GridTableCell = ({ - cellIdentifier, - cellCache, - fieldController, -}: { - cellIdentifier: CellIdentifier; - cellCache: CellCache; - fieldController: FieldController; -}) => { - return <GridCell cellIdentifier={cellIdentifier} cellCache={cellCache} fieldController={fieldController} />; -}; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/grid/GridTableRows/GridTableRow.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/grid/GridTableRows/GridTableRow.tsx deleted file mode 100644 index 50cb9af8ea..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/grid/GridTableRows/GridTableRow.tsx +++ /dev/null @@ -1,89 +0,0 @@ -import { DatabaseController } from '@/appflowy_app/stores/effects/database/database_controller'; -import { RowInfo } from '@/appflowy_app/stores/effects/database/row/row_cache'; -import { useRow } from '../../_shared/database-hooks/useRow'; -import { FullView } from '../../_shared/svg/FullView'; -import { GridCell } from '../GridCell/GridCell'; -import { DragSvg } from '../../_shared/svg/DragSvg'; -import { Draggable, DraggableProvided, DraggableStateSnapshot } from 'react-beautiful-dnd'; -import { GridRowActions } from './GridRowActions'; -import { useAppSelector } from '$app/stores/store'; - -export const GridTableRow = ({ - viewId, - controller, - row, - onOpenRow, - index, -}: { - viewId: string; - controller: DatabaseController; - row: RowInfo; - onOpenRow: (rowId: RowInfo) => void; - index: number; -}) => { - const { cells } = useRow(viewId, controller, row); - const fields = useAppSelector((state) => state.database.fields); - - return ( - // this is needed to prevent DnD from causing exceptions - cells.length ? ( - <Draggable draggableId={row.row.id} key={row.row.id} index={index}> - {(provided: DraggableProvided, snapshot: DraggableStateSnapshot) => ( - <div - ref={provided.innerRef} - {...provided.draggableProps} - className={`group/row flex cursor-pointer items-stretch ${snapshot.isDragging ? 'shadow-md' : ''}`} - > - <GridRowActions controller={controller} rowId={row.row.id} isDragging={snapshot.isDragging}> - <i className={`block h-5 w-5`} {...provided.dragHandleProps}> - <DragSvg /> - </i> - </GridRowActions> - {cells - // filter out hidden fields - // ?? true is to prevent DnD from causing exceptions - .filter((cell) => fields[cell.fieldId]?.visible ?? true) - .map((cell, cellIndex) => { - return ( - <div - className={`group/cell relative flex flex-shrink-0 border-b border-line-divider bg-bg-body ${ - snapshot.isDragging ? 'border-t' : '' - }`} - key={cellIndex} - draggable={false} - > - <GridCell - width={fields[cell.fieldId]?.width} - cellIdentifier={cell.cellIdentifier} - cellCache={controller.databaseViewCache.getRowCache().getCellCache()} - fieldController={controller.fieldController} - /> - - {cellIndex === 0 && ( - <div - onClick={() => onOpenRow(row)} - className='absolute inset-y-0 right-0 my-auto mr-1 hidden flex-shrink-0 cursor-pointer items-center justify-center rounded p-1 hover:bg-fill-list-hover group-hover/cell:flex' - > - <i className={' block h-5 w-5'}> - <FullView /> - </i> - </div> - )} - - <div className={'flex h-full justify-center'}> - <div className={'h-full w-[1px] bg-line-divider'}></div> - </div> - </div> - ); - })} - <div - className={`-ml-1.5 w-40 border-b border-line-divider bg-bg-body ${snapshot.isDragging ? 'border-t' : ''}`} - ></div> - </div> - )} - </Draggable> - ) : ( - <></> - ) - ); -}; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/grid/GridTableRows/GridTableRows.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/grid/GridTableRows/GridTableRows.tsx deleted file mode 100644 index bf4a3c9dcd..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/grid/GridTableRows/GridTableRows.tsx +++ /dev/null @@ -1,50 +0,0 @@ -import { DatabaseController } from '@/appflowy_app/stores/effects/database/database_controller'; -import { RowInfo } from '@/appflowy_app/stores/effects/database/row/row_cache'; -import { GridTableRow } from './GridTableRow'; -import { DragDropContext, Droppable, DroppableProvided, OnDragEndResponder } from 'react-beautiful-dnd'; - -export const GridTableRows = ({ - viewId, - controller, - allRows, - onOpenRow, -}: { - viewId: string; - controller: DatabaseController; - allRows: readonly RowInfo[]; - onOpenRow: (rowId: RowInfo) => void; -}) => { - const onRowsDragEnd: OnDragEndResponder = async (result) => { - if (!result.destination) return; - if (result.destination.index === result.source.index) return; - await controller.moveRow(result.draggableId, allRows[result.destination.index].row.id); - }; - - return ( - <DragDropContext onDragEnd={onRowsDragEnd}> - <Droppable droppableId='table'> - {(droppableProvided: DroppableProvided) => ( - <div - className={'absolute h-full overflow-y-auto overflow-x-hidden'} - ref={droppableProvided.innerRef} - {...droppableProvided.droppableProps} - > - {allRows.map((row, i) => { - return ( - <GridTableRow - onOpenRow={onOpenRow} - row={row} - key={i} - index={i} - viewId={viewId} - controller={controller} - /> - ); - })} - {droppableProvided.placeholder} - </div> - )} - </Droppable> - </DragDropContext> - ); -}; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/grid/GridTitle/GridTitle.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/grid/GridTitle/GridTitle.tsx deleted file mode 100644 index ba48f0ed75..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/grid/GridTitle/GridTitle.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import { SettingsSvg } from '../../_shared/svg/SettingsSvg'; -import { GridTitleOptionsPopup } from './GridTitleOptionsPopup'; -import { useState } from 'react'; -import { useAppSelector } from '$app/stores/store'; - -export const GridTitle = ({ - onShowFilterClick, - onShowSortClick, - viewId, -}: { - onShowFilterClick: () => void; - onShowSortClick: () => void; - viewId: string; -}) => { - const [showOptions, setShowOptions] = useState(false); - const pagesStore = useAppSelector((state) => state.pages.pageMap[viewId]); - - return ( - <div className={'relative flex items-center '}> - <div className='flex '> - <div>{pagesStore?.name}</div> - <button className={'ml-2 h-5 w-5 '} onClick={() => setShowOptions(!showOptions)}> - <SettingsSvg></SettingsSvg> - </button> - - {showOptions && ( - <GridTitleOptionsPopup - onClose={() => setShowOptions(!showOptions)} - onFilterClick={() => onShowFilterClick()} - onSortClick={() => onShowSortClick()} - /> - )} - </div> - </div> - ); -}; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/grid/GridTitle/GridTitleOptionsPopup.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/grid/GridTitle/GridTitleOptionsPopup.tsx deleted file mode 100644 index 87e27f5d3d..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/grid/GridTitle/GridTitleOptionsPopup.tsx +++ /dev/null @@ -1,66 +0,0 @@ -import { IPopupItem, PopupSelect } from '../../_shared/PopupSelect'; -import { FilterSvg } from '../../_shared/svg/FilterSvg'; -import { GroupBySvg } from '../../_shared/svg/GroupBySvg'; -import { PropertiesSvg } from '../../_shared/svg/PropertiesSvg'; -import { SortSvg } from '../../_shared/svg/SortSvg'; - -export const GridTitleOptionsPopup = ({ - onClose, - onFilterClick, - onSortClick, -}: { - onClose?: () => void; - onFilterClick: () => void; - onSortClick: () => void; -}) => { - const items: IPopupItem[] = [ - { - icon: ( - <i className={'h-[16px] w-[16px] flex-shrink-0 text-text-title'}> - <FilterSvg /> - </i> - ), - onClick: () => { - onFilterClick && onFilterClick(); - onClose && onClose(); - }, - title: 'Filter', - }, - { - icon: ( - <i className={'h-[16px] w-[16px] flex-shrink-0 text-text-title'}> - <SortSvg /> - </i> - ), - onClick: () => { - onSortClick && onSortClick(); - onClose && onClose(); - }, - title: 'Sort By', - }, - { - icon: ( - <i className={'h-[16px] w-[16px] flex-shrink-0 text-text-title'}> - <PropertiesSvg /> - </i> - ), - onClick: () => { - console.log('fields'); - }, - title: 'Fields', - }, - { - icon: ( - <i className={'h-[16px] w-[16px] flex-shrink-0 text-text-title'}> - <GroupBySvg /> - </i> - ), - onClick: () => { - console.log('group by'); - }, - title: 'Group by', - }, - ]; - - return <PopupSelect items={items} className={'absolute top-full z-10 w-[140px]'} onOutsideClick={onClose} />; -}; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/grid/GridToolbar/GridFieldsButton.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/grid/GridToolbar/GridFieldsButton.tsx deleted file mode 100644 index ec97c51a0c..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/grid/GridToolbar/GridFieldsButton.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import { PropertiesSvg } from '../../_shared/svg/PropertiesSvg'; - -export const GridFieldsButton = () => { - return ( - <button className={'flex items-center rounded-lg p-2 text-sm hover:bg-main-selector'}> - <i className={'mr-2 h-5 w-5'}> - <PropertiesSvg></PropertiesSvg> - </i> - <span>Fields</span> - </button> - ); -}; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/grid/GridToolbar/GridFilterButton.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/grid/GridToolbar/GridFilterButton.tsx deleted file mode 100644 index e373a702a8..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/grid/GridToolbar/GridFilterButton.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import { FilterSvg } from '../../_shared/svg/FilterSvg'; - -export const GridFilterButton = () => { - return ( - <button className={'flex items-center rounded-lg p-2 text-sm hover:bg-main-selector'}> - <i className={'mr-2 h-5 w-5'}> - <FilterSvg></FilterSvg> - </i> - <span>Filter</span> - </button> - ); -}; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/grid/GridToolbar/GridSortButton.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/grid/GridToolbar/GridSortButton.tsx deleted file mode 100644 index fc1b9d4b69..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/grid/GridToolbar/GridSortButton.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import { SortSvg } from '../../_shared/svg/SortSvg'; - -export const GridSortButton = () => { - return ( - <button className={'flex items-center rounded-lg p-2 text-sm hover:bg-main-selector'}> - <i className={'mr-2 h-5 w-5'}> - <SortSvg></SortSvg> - </i> - <span>Sort</span> - </button> - ); -}; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/grid/GridToolbar/GridToolbar.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/grid/GridToolbar/GridToolbar.tsx deleted file mode 100644 index 6039a6147a..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/grid/GridToolbar/GridToolbar.tsx +++ /dev/null @@ -1,11 +0,0 @@ -import { GridAddView } from '../GridAddView/GridAddView'; -import { SearchInput } from '../../_shared/SearchInput'; - -export const GridToolbar = () => { - return ( - <div className='flex shrink-0 items-center gap-4'> - <SearchInput /> - <GridAddView /> - </div> - ); -}; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/layout/Breadcrumb/Breadcrumb.hooks.ts b/frontend/appflowy_tauri/src/appflowy_app/components/layout/Breadcrumb/Breadcrumb.hooks.ts index 7063cea877..1250d1c113 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/layout/Breadcrumb/Breadcrumb.hooks.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/components/layout/Breadcrumb/Breadcrumb.hooks.ts @@ -56,6 +56,16 @@ export function useLoadExpandedPages() { // eslint-disable-next-line react-hooks/exhaustive-deps }, [currentPageId]); + useEffect(() => { + setPagePath((prev) => { + return prev.map((page, index) => { + if (!page) return page; + if (index === 0) return page; + return 'id' in page && page.id ? pageMap[page.id] : page; + }); + }); + }, [pageMap]); + useEffect(() => { if (isTrash) { setPagePath([ diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/layout/Breadcrumb/index.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/layout/Breadcrumb/index.tsx index 91fc080463..40439a3624 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/layout/Breadcrumb/index.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/layout/Breadcrumb/index.tsx @@ -3,9 +3,8 @@ import { useLoadExpandedPages } from '$app/components/layout/Breadcrumb/Breadcru import Breadcrumbs from '@mui/material/Breadcrumbs'; import Link from '@mui/material/Link'; import Typography from '@mui/material/Typography'; -import { Page } from '$app_reducers/pages/slice'; +import { Page, pageTypeMap } from '$app_reducers/pages/slice'; import { useNavigate } from 'react-router-dom'; -import { pageTypeMap } from '$app/constants'; import { useTranslation } from 'react-i18next'; function Breadcrumb() { @@ -13,7 +12,7 @@ function Breadcrumb() { const { pagePath } = useLoadExpandedPages(); const navigate = useNavigate(); const activePage = useMemo(() => pagePath[pagePath.length - 1], [pagePath]); - const parentPages = useMemo(() => pagePath.slice(1, pagePath.length - 1) as Page[], [pagePath]); + const parentPages = useMemo(() => pagePath.slice(1, pagePath.length - 1).filter(Boolean) as Page[], [pagePath]); const navigateToPage = useCallback( (page: Page) => { const pageType = pageTypeMap[page.layout]; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/layout/CollapseMenuButton/index.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/layout/CollapseMenuButton/index.tsx index 459547714f..850ac0b703 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/layout/CollapseMenuButton/index.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/layout/CollapseMenuButton/index.tsx @@ -1,9 +1,10 @@ import React from 'react'; import { IconButton } from '@mui/material'; -import { ShowMenuSvg } from '$app/components/_shared/svg/ShowMenuSvg'; -import { HideMenuSvg } from '$app/components/_shared/svg/HideMenuSvg'; + import { useAppDispatch, useAppSelector } from '$app/stores/store'; import { sidebarActions } from '$app_reducers/sidebar/slice'; +import { ReactComponent as LeftSvg } from '$app/assets/left.svg'; +import { ReactComponent as RightSvg } from '$app/assets/right.svg'; function CollapseMenuButton() { const isCollapsed = useAppSelector((state) => state.sidebar.isCollapsed); @@ -13,8 +14,8 @@ function CollapseMenuButton() { }; return ( - <IconButton className={'h-6 w-6 p-2'} size={'small'} onClick={handleClick}> - {isCollapsed ? <ShowMenuSvg /> : <HideMenuSvg />} + <IconButton size={'small'} className={'font-bold'} onClick={handleClick}> + {isCollapsed ? <RightSvg /> : <LeftSvg />} </IconButton> ); } diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/layout/Layout.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/layout/Layout.tsx index cbec94ba81..b41096f6c1 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/layout/Layout.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/layout/Layout.tsx @@ -3,7 +3,6 @@ import SideBar from '$app/components/layout/SideBar'; import TopBar from '$app/components/layout/TopBar'; import { useAppSelector } from '$app/stores/store'; import { FooterPanel } from '$app/components/layout/FooterPanel'; -import BlockDragDropContext from '$app/components/_shared/BlockDraggable/BlockDragDropContext'; function Layout({ children }: { children: ReactNode }) { const { isCollapsed, width } = useAppSelector((state) => state.sidebar); @@ -21,7 +20,7 @@ function Layout({ children }: { children: ReactNode }) { }; }, []); return ( - <BlockDragDropContext> + <> <div className='flex h-screen w-[100%] text-sm text-text-title'> <SideBar /> <div @@ -43,7 +42,7 @@ function Layout({ children }: { children: ReactNode }) { <FooterPanel /> </div> </div> - </BlockDragDropContext> + </> ); } diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/layout/NestedPage/NestedPage.hooks.ts b/frontend/appflowy_tauri/src/appflowy_app/components/layout/NestedPage/NestedPage.hooks.ts index 428c77193b..f754fa8416 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/layout/NestedPage/NestedPage.hooks.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/components/layout/NestedPage/NestedPage.hooks.ts @@ -1,10 +1,9 @@ import { useCallback, useEffect, useMemo } from 'react'; import { PageController } from '$app/stores/effects/workspace/page/page_controller'; -import { Page, pagesActions } from '$app_reducers/pages/slice'; +import { Page, pagesActions, pageTypeMap } from '$app_reducers/pages/slice'; import { useAppDispatch, useAppSelector } from '$app/stores/store'; import { ViewLayoutPB } from '@/services/backend'; import { useNavigate, useParams } from 'react-router-dom'; -import { pageTypeMap } from '$app/constants'; import { updatePageName } from '$app_reducers/pages/async_actions'; export function useLoadChildPages(pageId: string) { diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/layout/NestedPage/NestedPageTitle.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/layout/NestedPage/NestedPageTitle.tsx index a0eafd964e..98de2e167d 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/layout/NestedPage/NestedPageTitle.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/layout/NestedPage/NestedPageTitle.tsx @@ -1,6 +1,5 @@ import React, { useState } from 'react'; import { ArrowRightSvg } from '$app/components/_shared/svg/ArrowRightSvg'; -import MenuItem from '@mui/material/MenuItem'; import { useAppSelector } from '$app/stores/store'; import AddButton from './AddButton'; import MoreButton from './MoreButton'; @@ -35,8 +34,10 @@ function NestedPageTitle({ const isSelected = useSelectedPage(pageId); return ( - <MenuItem - selected={isSelected} + <div + className={`m-1 cursor-pointer rounded-lg px-2 py-1 ${isHovering ? 'bg-fill-list-hover' : ''} ${ + isSelected ? 'bg-fill-list-active' : '' + }`} onClick={onClick} onMouseEnter={() => setIsHovering(true)} onMouseLeave={() => setIsHovering(false)} @@ -74,7 +75,7 @@ function NestedPageTitle({ /> </div> </div> - </MenuItem> + </div> ); } diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/layout/NestedPage/index.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/layout/NestedPage/index.tsx index 1142bb999a..961861843f 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/layout/NestedPage/index.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/layout/NestedPage/index.tsx @@ -1,21 +1,73 @@ -import React, { useMemo } from 'react'; +import React, { useCallback, useMemo } from 'react'; import Collapse from '@mui/material/Collapse'; import { TransitionGroup } from 'react-transition-group'; import NestedPageTitle from '$app/components/layout/NestedPage/NestedPageTitle'; import { useLoadChildPages, usePageActions } from '$app/components/layout/NestedPage/NestedPage.hooks'; -import BlockDraggable from '$app/components/_shared/BlockDraggable'; -import { BlockDraggableType } from '$app_reducers/block-draggable/slice'; +import { useDrag } from '$app/components/_shared/drag-block'; +import { useAppDispatch } from '$app/stores/store'; +import { movePageThunk } from '$app_reducers/pages/async_actions'; function NestedPage({ pageId }: { pageId: string }) { const { toggleCollapsed, collapsed, childPages } = useLoadChildPages(pageId); const { onAddPage, onPageClick, onDeletePage, onDuplicatePage, onRenamePage } = usePageActions(pageId); - + const dispatch = useAppDispatch(); const children = useMemo(() => { return collapsed ? [] : childPages; }, [collapsed, childPages]); + const onDragFinished = useCallback( + (result: { dragId: string; position: 'before' | 'after' | 'inside' }) => { + void dispatch( + movePageThunk({ + sourceId: result.dragId, + targetId: pageId, + insertType: result.position, + }) + ); + }, + [dispatch, pageId] + ); + + const { onDrop, dropPosition, onDragOver, onDragLeave, onDragStart, onDragEnd, isDraggingOver, isDragging } = useDrag({ + onEnd: onDragFinished, + dragId: pageId, + }); + + const className = useMemo(() => { + const defaultClassName = 'relative flex flex-col w-full'; + + if (isDragging) { + return `${defaultClassName} opacity-40`; + } + + if (isDraggingOver && dropPosition === 'inside') { + if (dropPosition === 'inside') { + return `${defaultClassName} bg-content-blue-100`; + } + } else { + return defaultClassName; + } + }, [dropPosition, isDragging, isDraggingOver]); + return ( - <BlockDraggable id={pageId} type={BlockDraggableType.PAGE} data-page-id={pageId}> + <div + className={className} + onDragLeave={onDragLeave} + onDragStart={onDragStart} + onDragOver={onDragOver} + onDragEnd={onDragEnd} + onDrop={onDrop} + draggable={true} + data-page-id={pageId} + > + <div + style={{ + height: dropPosition === 'before' || dropPosition === 'after' ? '4px' : '0px', + top: dropPosition === 'before' ? '-4px' : 'auto', + bottom: dropPosition === 'after' ? '-4px' : 'auto', + }} + className={'pointer-events-none absolute left-0 z-10 w-full bg-content-blue-100'} + /> <NestedPageTitle onClick={() => { onPageClick(); @@ -28,7 +80,6 @@ function NestedPage({ pageId }: { pageId: string }) { toggleCollapsed={toggleCollapsed} pageId={pageId} /> - <div className={'pl-4 pt-[2px]'}> <TransitionGroup> {children?.map((pageId) => ( @@ -38,7 +89,7 @@ function NestedPage({ pageId }: { pageId: string }) { ))} </TransitionGroup> </div> - </BlockDraggable> + </div> ); } diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/layout/SideBar/index.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/layout/SideBar/index.tsx index 56f88906fb..54e163956c 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/layout/SideBar/index.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/layout/SideBar/index.tsx @@ -1,6 +1,6 @@ import React from 'react'; import { useAppSelector } from '$app/stores/store'; -import { ThemeMode } from '$app/interfaces'; +import { ThemeMode } from '$app/stores/reducers/current-user/slice'; import { AppflowyLogoDark } from '$app/components/_shared/svg/AppflowyLogoDark'; import { AppflowyLogoLight } from '$app/components/_shared/svg/AppflowyLogoLight'; import CollapseMenuButton from '$app/components/layout/CollapseMenuButton'; @@ -19,7 +19,7 @@ function SideBar() { width: isCollapsed ? 0 : width, transition: isResizing ? 'none' : 'width 150ms cubic-bezier(0.4, 0, 0.2, 1)', }} - className={'relative h-screen select-none overflow-hidden'} + className={'relative h-screen overflow-hidden'} > <div className={'flex h-[100vh] flex-col overflow-hidden border-r border-line-divider bg-bg-base'}> <div className={'flex h-[64px] justify-between px-6 py-5'}> @@ -29,12 +29,7 @@ function SideBar() { <div className={'flex h-[36px] items-center'}> <UserInfo /> </div> - - <div - style={{ - height: 'calc(100% - 64px - 36px)', - }} - > + <div className={'flex-1'}> <WorkspaceManager /> </div> </div> diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/layout/UserSetting/AppearanceSetting.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/layout/UserSetting/AppearanceSetting.tsx index 8e3d933eb9..cf69f1c426 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/layout/UserSetting/AppearanceSetting.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/layout/UserSetting/AppearanceSetting.tsx @@ -1,6 +1,6 @@ import React, { useCallback, useEffect, useMemo } from 'react'; import Select from '@mui/material/Select'; -import { Theme, ThemeMode, UserSetting } from '$app/interfaces'; +import { Theme, ThemeMode, UserSetting } from '$app/stores/reducers/current-user/slice'; import MenuItem from '@mui/material/MenuItem'; import { useTranslation } from 'react-i18next'; @@ -16,7 +16,6 @@ function AppearanceSetting({ const { t } = useTranslation(); useEffect(() => { - const html = document.documentElement; html?.setAttribute('data-dark-mode', String(themeMode === ThemeMode.Dark)); diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/layout/UserSetting/LanguageSetting.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/layout/UserSetting/LanguageSetting.tsx index a4fc8866e7..7ad9997567 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/layout/UserSetting/LanguageSetting.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/layout/UserSetting/LanguageSetting.tsx @@ -1,7 +1,7 @@ import React from 'react'; import { useTranslation } from 'react-i18next'; import Select from '@mui/material/Select'; -import { UserSetting } from '$app/interfaces'; +import { UserSetting } from '$app/stores/reducers/current-user/slice'; import MenuItem from '@mui/material/MenuItem'; const languages = [ diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/layout/UserSetting/SettingPanel.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/layout/UserSetting/SettingPanel.tsx index 5d6f2da727..c88fe1f2e3 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/layout/UserSetting/SettingPanel.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/layout/UserSetting/SettingPanel.tsx @@ -3,7 +3,7 @@ import { MenuItem } from './Menu'; import AppearanceSetting from './AppearanceSetting'; import LanguageSetting from './LanguageSetting'; -import { UserSetting } from '$app/interfaces'; +import { UserSetting } from '$app/stores/reducers/current-user/slice'; function UserSettingPanel({ selected, diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/layout/UserSetting/index.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/layout/UserSetting/index.tsx index 3b7409bc7b..256f4e96ac 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/layout/UserSetting/index.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/layout/UserSetting/index.tsx @@ -5,7 +5,7 @@ import DialogTitle from '@mui/material/DialogTitle'; import Slide, { SlideProps } from '@mui/material/Slide'; import UserSettingMenu, { MenuItem } from './Menu'; import UserSettingPanel from './SettingPanel'; -import { Theme, UserSetting } from '$app/interfaces'; +import { Theme, UserSetting } from '$app/stores/reducers/current-user/slice'; import { useAppDispatch, useAppSelector } from '$app/stores/store'; import { currentUserActions } from '$app_reducers/current-user/slice'; import { useUserSettingControllerContext } from '$app/components/_shared/app-hooks/useUserSettingControllerContext'; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/layout/WorkspaceManager/NestedPages.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/layout/WorkspaceManager/NestedPages.tsx index 3930a51267..aa1f7bc188 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/layout/WorkspaceManager/NestedPages.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/layout/WorkspaceManager/NestedPages.tsx @@ -1,21 +1,18 @@ -import React, { useRef } from 'react'; +import React from 'react'; import { useAppSelector } from '$app/stores/store'; import NestedPage from '$app/components/layout/NestedPage'; -import { List } from '@mui/material'; function WorkspaceNestedPages({ workspaceId }: { workspaceId: string }) { const pageIds = useAppSelector((state) => { return state.pages.relationMap[workspaceId]; }); - const ref = useRef(null); - return ( - <List id={`appflowy-scroller_${workspaceId}`} ref={ref} className={'h-[100%] overflow-y-auto overflow-x-hidden'}> + <div className={'h-[100%] overflow-y-auto overflow-x-hidden'}> {pageIds?.map((pageId) => ( <NestedPage key={pageId} pageId={pageId} /> ))} - </List> + </div> ); } diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/layout/WorkspaceManager/TrashButton.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/layout/WorkspaceManager/TrashButton.tsx index a29abdce0d..98e31b9e58 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/layout/WorkspaceManager/TrashButton.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/layout/WorkspaceManager/TrashButton.tsx @@ -1,8 +1,9 @@ -import React from 'react'; +import React, { useCallback } from 'react'; import { TrashSvg } from '$app/components/_shared/svg/TrashSvg'; -import MenuItem from '@mui/material/MenuItem'; import { useTranslation } from 'react-i18next'; import { useLocation, useNavigate } from 'react-router-dom'; +import { useDrag } from '$app/components/_shared/drag-block'; +import { PageController } from '$app/stores/effects/workspace/page/page_controller'; function TrashButton() { const { t } = useTranslation(); @@ -12,21 +13,34 @@ function TrashButton() { navigate('/trash'); }; + const selected = currentPathType === 'trash'; + + const onEnd = useCallback((result: { dragId: string; position: 'before' | 'after' | 'inside' }) => { + const controller = new PageController(result.dragId); + + void controller.deletePage(); + }, []); + + const { onDrop, onDragOver, onDragLeave, isDraggingOver } = useDrag({ + onEnd, + }); + return ( - <MenuItem + <div + onDrop={onDrop} + onDragOver={onDragOver} + onDragLeave={onDragLeave} data-page-id={'trash'} - selected={currentPathType === 'trash'} onClick={navigateToTrash} - style={{ - borderRadius: '8px', - }} - className={'flex w-[100%] items-center'} + className={`mx-1 flex w-[100%] items-center rounded-lg p-2 hover:bg-fill-list-hover ${ + selected ? 'bg-fill-list-active' : '' + } ${isDraggingOver ? 'bg-fill-list-hover' : ''}`} > <div className='h-6 w-6'> <TrashSvg /> </div> <span className={'ml-2'}>{t('trash.text')}</span> - </MenuItem> + </div> ); } diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/layout/WorkspaceManager/Workspace.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/layout/WorkspaceManager/Workspace.tsx index b80b8c28e5..b8a557ff42 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/layout/WorkspaceManager/Workspace.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/layout/WorkspaceManager/Workspace.tsx @@ -14,7 +14,6 @@ function Workspace({ workspace, opened }: { workspace: WorkspaceItem; opened: bo transition: 'height 0.2s ease-in-out', }} > - {/*<WorkspaceTitle workspace={workspace} openWorkspace={openWorkspace} onDelete={onDelete} />*/} <NestedViews workspaceId={workspace.id} /> </div> </div> diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/layout/WorkspaceManager/index.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/layout/WorkspaceManager/index.tsx index 9f26fd8469..db0ead7f01 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/layout/WorkspaceManager/index.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/layout/WorkspaceManager/index.tsx @@ -2,22 +2,24 @@ import React from 'react'; import NewPageButton from '$app/components/layout/WorkspaceManager/NewPageButton'; import { useLoadWorkspaces } from '$app/components/layout/WorkspaceManager/Workspace.hooks'; import Workspace from './Workspace'; -import { List } from '@mui/material'; import TrashButton from '$app/components/layout/WorkspaceManager/TrashButton'; function WorkspaceManager() { const { workspaces, currentWorkspace } = useLoadWorkspaces(); return ( - <div className={'flex h-[100%] flex-col justify-between'}> - <List className={'flex-1 overflow-hidden'}> - {workspaces.map((workspace) => ( - <Workspace opened={currentWorkspace?.id === workspace.id} key={workspace.id} workspace={workspace} /> - ))} - </List> - <div className={'flex h-[48px] w-[100%] items-center px-2'}> - <TrashButton /> + <div className={'flex h-full flex-col justify-between'}> + <div className={'flex w-full flex-1 flex-col'}> + <div className={'flex-1 overflow-hidden'}> + {workspaces.map((workspace) => ( + <Workspace opened={currentWorkspace?.id === workspace.id} key={workspace.id} workspace={workspace} /> + ))} + </div> + <div className={'sticky bottom-0 flex h-[48px] w-[100%] items-center px-2'}> + <TrashButton /> + </div> </div> + {currentWorkspace && <NewPageButton workspaceId={currentWorkspace.id} />} </div> ); diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/tests/TestAPI.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/tests/TestAPI.tsx index 57e6d91b73..46c41a65e9 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/tests/TestAPI.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/tests/TestAPI.tsx @@ -28,7 +28,6 @@ import { TestMoveKanbanBoardColumn, TestMoveKanbanBoardRow, } from './TestGroup'; -import { TestCreateDocument } from './TestDocument'; import { TestCreateViews } from '$app/components/tests/TestFolder'; export const TestAPI = () => { @@ -62,7 +61,6 @@ export const TestAPI = () => { <TestMoveKanbanBoardRow></TestMoveKanbanBoardRow> <TestMoveKanbanBoardColumn></TestMoveKanbanBoardColumn> <TestCreateKanbanBoardColumn></TestCreateKanbanBoardColumn> - <TestCreateDocument></TestCreateDocument> {/*Folders*/} <TestCreateViews></TestCreateViews> </ul> diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/tests/TestDocument.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/tests/TestDocument.tsx deleted file mode 100644 index b6f70acb02..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/tests/TestDocument.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import React from 'react'; -import { createTestDocument } from './DocumentTestHelper'; -import { DocumentBackendService } from '../../stores/effects/document/document_bd_svc'; - -async function testCreateDocument() { - const view = await createTestDocument(); - const svc = new DocumentBackendService(view.id); - - await svc.open().then((result) => result.unwrap()); - - // eslint-disable-next-line @typescript-eslint/no-unused-vars - // const content = JSON.parse(document.content); - // The initial document content: - // { - // "document": { - // "type": "editor", - // "children": [ - // { - // "type": "text" - // } - // ] - // } - // } - await svc.close(); -} - -export const TestCreateDocument = () => { - return TestButton('Test create document', testCreateDocument); -}; - -const TestButton = (title: string, onClick: () => void) => { - return ( - <React.Fragment> - <div> - <button className='rounded-md bg-purple-400 p-4' type='button' onClick={() => onClick()}> - {title} - </button> - </div> - </React.Fragment> - ); -}; diff --git a/frontend/appflowy_tauri/src/appflowy_app/constants/document/config.ts b/frontend/appflowy_tauri/src/appflowy_app/constants/document/config.ts deleted file mode 100644 index fe6c432a55..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/constants/document/config.ts +++ /dev/null @@ -1,112 +0,0 @@ -import { Align, BlockConfig, BlockType, SplitRelationship } from '$app/interfaces/document'; -import { randomEmoji } from '$app/utils/document/emoji'; - -/** - * If the block type is not in the config, it will be thrown an error in development env - */ -export const blockConfig: Record<string, BlockConfig> = { - [BlockType.TextBlock]: { - canAddChild: true, - defaultData: {}, - splitProps: { - nextLineRelationShip: SplitRelationship.NextSibling, - nextLineBlockType: BlockType.TextBlock, - }, - }, - [BlockType.HeadingBlock]: { - canAddChild: false, - splitProps: { - nextLineRelationShip: SplitRelationship.NextSibling, - nextLineBlockType: BlockType.TextBlock, - }, - }, - [BlockType.TodoListBlock]: { - canAddChild: true, - defaultData: { - checked: false, - }, - splitProps: { - nextLineRelationShip: SplitRelationship.NextSibling, - nextLineBlockType: BlockType.TodoListBlock, - }, - }, - [BlockType.BulletedListBlock]: { - canAddChild: true, - defaultData: { - format: 'default', - }, - splitProps: { - nextLineRelationShip: SplitRelationship.NextSibling, - nextLineBlockType: BlockType.BulletedListBlock, - }, - }, - [BlockType.NumberedListBlock]: { - canAddChild: true, - defaultData: { - format: 'default', - }, - splitProps: { - nextLineRelationShip: SplitRelationship.NextSibling, - nextLineBlockType: BlockType.NumberedListBlock, - }, - }, - [BlockType.QuoteBlock]: { - canAddChild: true, - defaultData: { - size: 'default', - }, - splitProps: { - nextLineRelationShip: SplitRelationship.NextSibling, - nextLineBlockType: BlockType.TextBlock, - }, - }, - [BlockType.CalloutBlock]: { - canAddChild: true, - defaultData: { - icon: randomEmoji(), - }, - splitProps: { - nextLineRelationShip: SplitRelationship.NextSibling, - nextLineBlockType: BlockType.TextBlock, - }, - }, - [BlockType.ToggleListBlock]: { - canAddChild: true, - defaultData: { - collapsed: false, - }, - splitProps: { - nextLineRelationShip: SplitRelationship.FirstChild, - nextLineBlockType: BlockType.TextBlock, - }, - }, - - [BlockType.CodeBlock]: { - canAddChild: false, - defaultData: { - language: 'javascript', - }, - }, - [BlockType.DividerBlock]: { - canAddChild: false, - }, - [BlockType.GridBlock]: { - canAddChild: false, - }, - [BlockType.EquationBlock]: { - canAddChild: false, - defaultData: { - formula: '', - }, - }, - [BlockType.ImageBlock]: { - canAddChild: false, - defaultData: { - url: '', - align: Align.Center, - width: 0, - height: 0, - caption: [], - }, - }, -}; diff --git a/frontend/appflowy_tauri/src/appflowy_app/constants/document/copy_paste.ts b/frontend/appflowy_tauri/src/appflowy_app/constants/document/copy_paste.ts deleted file mode 100644 index 9ff02141d9..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/constants/document/copy_paste.ts +++ /dev/null @@ -1,5 +0,0 @@ -export const clipboardTypes = { - JSON: 'application/json', - TEXT: 'text/plain', - HTML: 'text/html', -}; diff --git a/frontend/appflowy_tauri/src/appflowy_app/constants/document/keyboard.ts b/frontend/appflowy_tauri/src/appflowy_app/constants/document/keyboard.ts deleted file mode 100644 index 75faab8c12..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/constants/document/keyboard.ts +++ /dev/null @@ -1,47 +0,0 @@ -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: '/', - REDUCE: '-', - BACK_QUOTE: '`', - UNDER_SCORE: '_', - ASTERISK: '*', - TILDE: '~', - DOLLAR: '$', - FORMAT: { - BOLD: 'Mod+b', - ITALIC: 'Mod+i', - UNDERLINE: 'Mod+u', - STRIKE: 'Mod+Shift+s', - CODE: 'Mod+Shift+c', - }, - COPY: 'Mod+c', - CUT: 'Mod+x', - PASTE: 'Mod+v', - REDO: 'Mod+Shift+z', - UNDO: 'Mod+z', - }, -}; diff --git a/frontend/appflowy_tauri/src/appflowy_app/constants/document/name.ts b/frontend/appflowy_tauri/src/appflowy_app/constants/document/name.ts deleted file mode 100644 index 122dcef246..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/constants/document/name.ts +++ /dev/null @@ -1,16 +0,0 @@ -export const DOCUMENT_NAME = 'document'; -export const TEMPORARY_NAME = 'document/temporary'; -export const BLOCK_EDIT_NAME = 'document/block_edit'; -export const RANGE_NAME = 'document/range'; - -export const MENTION_NAME = 'document/mention'; - -export const RECT_RANGE_NAME = 'document/rect_range'; -export const SLASH_COMMAND_NAME = 'document/slash_command'; -export const TEXT_LINK_NAME = 'document/text_link'; -export const BLOCK_MAP_NAME = 'blocks'; -export const META_NAME = 'meta'; -export const CHILDREN_MAP_NAME = 'children_map'; - -export const TEXT_MAP_NAME = 'text_map'; -export const EQUATION_PLACEHOLDER = '$'; diff --git a/frontend/appflowy_tauri/src/appflowy_app/constants/index.ts b/frontend/appflowy_tauri/src/appflowy_app/constants/index.ts deleted file mode 100644 index 960bac474b..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/constants/index.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { ViewLayoutPB } from '@/services/backend'; - -export const pageTypeMap = { - [ViewLayoutPB.Document]: 'document', - [ViewLayoutPB.Board]: 'board', - [ViewLayoutPB.Grid]: 'grid', - [ViewLayoutPB.Calendar]: 'calendar', -}; diff --git a/frontend/appflowy_tauri/src/appflowy_app/hooks/document.hooks.ts b/frontend/appflowy_tauri/src/appflowy_app/hooks/document.hooks.ts deleted file mode 100644 index a9e6ae986e..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/hooks/document.hooks.ts +++ /dev/null @@ -1,102 +0,0 @@ -import { createContext, useCallback, useContext, useEffect, useState } from 'react'; -import { DocumentData } from '../interfaces/document'; -import { DocumentController } from '$app/stores/effects/document/document_controller'; -import { useAppDispatch } from '../stores/store'; -import { Log } from '../utils/log'; -import { - documentActions, - rangeActions, - rectSelectionActions, - slashCommandActions, -} from '$app/stores/reducers/document/slice'; -import { BlockEventPayloadPB } from '@/services/backend/models/flowy-document2'; - -export const useDocument = (documentId?: string) => { - const [documentData, setDocumentData] = useState<DocumentData>(); - const [controller, setController] = useState<DocumentController | null>(null); - const dispatch = useAppDispatch(); - - const onDocumentChange = useCallback( - (props: { docId: string; isRemote: boolean; data: BlockEventPayloadPB }) => { - dispatch(documentActions.onDataChange(props)); - }, - [dispatch] - ); - - const initializeDocument = useCallback( - (docId: string) => { - Log.debug('initialize document', docId); - dispatch(documentActions.initialState(docId)); - dispatch(rangeActions.initialState(docId)); - dispatch(rectSelectionActions.initialState(docId)); - dispatch(slashCommandActions.initialState(docId)); - }, - [dispatch] - ); - - const clearDocument = useCallback( - (docId: string) => { - Log.debug('clear document', docId); - dispatch(documentActions.clear(docId)); - dispatch(rangeActions.clear(docId)); - dispatch(rectSelectionActions.clear(docId)); - dispatch(slashCommandActions.clear(docId)); - }, - [dispatch] - ); - - useEffect(() => { - let documentController: DocumentController | null = null; - - void (async () => { - if (!documentId) return; - documentController = new DocumentController(documentId, onDocumentChange); - const docId = documentController.documentId; - - Log.debug('open document', documentId); - - initializeDocument(documentController.documentId); - - setController(documentController); - try { - const res = await documentController.open(); - - if (!res) return; - dispatch( - documentActions.create({ - ...res, - docId, - }) - ); - setDocumentData(res); - } catch (e) { - Log.error(e); - } - })(); - - return () => { - if (documentController) { - void (async () => { - await documentController.dispose(); - clearDocument(documentController.documentId); - })(); - } - - Log.debug('close document', documentId); - }; - }, [clearDocument, dispatch, initializeDocument, onDocumentChange, documentId]); - - return { documentId, documentData, controller }; -}; - -export enum ContainerType { - DocumentPage, - EditRecord, -} -export const ContainerTypeContext = createContext(ContainerType.DocumentPage); - -export const ContainerTypeProvider = ContainerTypeContext.Provider; - -export const useContainerType = () => { - return useContext(ContainerTypeContext); -}; diff --git a/frontend/appflowy_tauri/src/appflowy_app/hooks/notification.hooks.ts b/frontend/appflowy_tauri/src/appflowy_app/hooks/notification.hooks.ts index 87b947da98..f8669852d3 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/hooks/notification.hooks.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/hooks/notification.hooks.ts @@ -1,132 +1,7 @@ /* eslint-disable no-redeclare */ /* eslint-disable @typescript-eslint/no-explicit-any */ import { useEffect } from 'react'; -import { listen } from '@tauri-apps/api/event'; -import { SubscribeObject } from '@/services/backend/models/flowy-notification'; -import { - DatabaseFieldChangesetPB, - DatabaseNotification, - FieldPB, - GroupChangesPB, - GroupRowsNotificationPB, - ReorderAllRowsPB, - ReorderSingleRowPB, - RowsChangePB, - RowsVisibilityChangePB, - SortChangesetNotificationPB, - FieldSettingsPB, - FilterChangesetNotificationPB, -} from '@/services/backend'; - -const NotificationPBMap = { - [DatabaseNotification.DidUpdateViewRowsVisibility]: RowsVisibilityChangePB, - [DatabaseNotification.DidUpdateViewRows]: RowsChangePB, - [DatabaseNotification.DidReorderRows]: ReorderAllRowsPB, - [DatabaseNotification.DidReorderSingleRow]: ReorderSingleRowPB, - [DatabaseNotification.DidUpdateFields]: DatabaseFieldChangesetPB, - [DatabaseNotification.DidGroupByField]: GroupChangesPB, - [DatabaseNotification.DidUpdateNumOfGroups]: GroupChangesPB, - [DatabaseNotification.DidUpdateGroupRow]: GroupRowsNotificationPB, - [DatabaseNotification.DidUpdateField]: FieldPB, - [DatabaseNotification.DidUpdateCell]: null, - [DatabaseNotification.DidUpdateSort]: SortChangesetNotificationPB, - [DatabaseNotification.DidUpdateFieldSettings]: FieldSettingsPB, - [DatabaseNotification.DidUpdateFilter]: FilterChangesetNotificationPB, -}; - -type NotificationMap = typeof NotificationPBMap; - -type NotificationEnum = keyof NotificationMap; - -type NullableInstanceType<K extends (abstract new (...args: any) => any) | null> = K extends abstract new ( - ...args: any -) => any - ? InstanceType<K> - : void; - -type NotificationHandler<K extends NotificationEnum> = (result: NullableInstanceType<NotificationMap[K]>) => void; - -/** - * Subscribes to a set of notifications. - * - * This function subscribes to notifications defined by the `NotificationEnum` and - * calls the appropriate `NotificationHandler` when each type of notification is received. - * - * @param {Object} callbacks - An object containing handlers for various notification types. - * Each key is a `NotificationEnum` value, and the corresponding value is a `NotificationHandler` function. - * - * @param {Object} [options] - Optional settings for the subscription. - * @param {string} [options.id] - An optional ID. If provided, only notifications with a matching ID will be processed. - * - * @returns {Promise<() => void>} A Promise that resolves to an unsubscribe function. - * - * @example - * subscribeNotifications({ - * [DatabaseNotification.DidUpdateField]: (result) => { - * if (result.err) { - * // process error - * return; - * } - * - * console.log(result.val); // result.val is FieldPB - * }, - * [DatabaseNotification.DidReorderRows]: (result) => { - * if (result.err) { - * // process error - * return; - * } - * - * console.log(result.val); // result.val is ReorderAllRowsPB - * }, - * }, { id: '123' }) - * .then(unsubscribe => { - * // Do something - * // ... - * // To unsubscribe, call `unsubscribe()` - * }); - * - * @throws {Error} Throws an error if unable to subscribe. - */ -export function subscribeNotifications( - callbacks: { - [K in NotificationEnum]?: NotificationHandler<K>; - }, - options?: { id?: string } -): Promise<() => void> { - return listen<ReturnType<typeof SubscribeObject.prototype.toObject>>('af-notification', (event) => { - const subject = SubscribeObject.fromObject(event.payload); - const { id, ty } = subject; - - if (options?.id !== undefined && id !== options.id) { - return; - } - - const notification = ty as NotificationEnum; - const pb = NotificationPBMap[notification]; - const callback = callbacks[notification] as NotificationHandler<NotificationEnum>; - - if (pb === undefined || !callback) { - return; - } - - if (subject.has_error) { - // const error = FlowyError.deserialize(subject.error); - return; - } else { - const { payload } = subject; - - pb ? callback(pb.deserialize(payload)) : callback(); - } - }); -} - -export function subscribeNotification<K extends NotificationEnum>( - notification: K, - callback: NotificationHandler<K>, - options?: { id?: string } -): Promise<() => void> { - return subscribeNotifications({ [notification]: callback }, options); -} +import { NotificationEnum, NotificationHandler, subscribeNotification } from '$app/application/notification'; export function useNotification<K extends NotificationEnum>( notification: K, diff --git a/frontend/appflowy_tauri/src/appflowy_app/interfaces/document.ts b/frontend/appflowy_tauri/src/appflowy_app/interfaces/document.ts deleted file mode 100644 index 8e8e2f97bf..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/interfaces/document.ts +++ /dev/null @@ -1,349 +0,0 @@ -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', - HeadingBlock = 'heading', - TextBlock = 'paragraph', - TodoListBlock = 'todo_list', - BulletedListBlock = 'bulleted_list', - NumberedListBlock = 'numbered_list', - ToggleListBlock = 'toggle_list', - CodeBlock = 'code', - EquationBlock = 'math_equation', - QuoteBlock = 'quote', - CalloutBlock = 'callout', - DividerBlock = 'divider', - ImageBlock = 'image', - GridBlock = 'grid', -} - -export interface EauqtionBlockData { - formula: string; -} -export interface HeadingBlockData extends TextBlockData { - level: number; -} - -export interface TodoListBlockData extends TextBlockData { - checked: boolean; -} - -export interface BulletListBlockData extends TextBlockData { - format: 'default' | 'circle' | 'square' | 'disc'; -} - -export interface NumberedListBlockData extends TextBlockData { - format: 'default' | 'numbers' | 'letters' | 'roman_numerals'; -} - -export interface ToggleListBlockData extends TextBlockData { - collapsed: boolean; -} - -export interface QuoteBlockData extends TextBlockData { - size: 'default' | 'large'; -} - -export interface CalloutBlockData extends TextBlockData { - icon: string; -} -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export type TextBlockData = Record<string, any>; - -export enum Align { - Left = 'left', - Center = 'center', - Right = 'right', -} - -export interface ImageBlockData { - width: number; - height: number; - caption: Op[]; - url: string; - align: Align; -} - -export enum CoverType { - Image = 'image', - Color = 'color', -} -export interface PageBlockData extends TextBlockData { - cover?: string; - coverType?: CoverType; -} -// eslint-disable-next-line @typescript-eslint/no-explicit-any -type Data = any; - -export interface ReferenceBlockData { - viewId: string; -} - -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export type BlockData<Type = any> = Type extends BlockType.HeadingBlock - ? HeadingBlockData - : Type extends BlockType.PageBlock - ? PageBlockData - : Type extends BlockType.TodoListBlock - ? TodoListBlockData - : Type extends BlockType.QuoteBlock - ? QuoteBlockData - : Type extends BlockType.BulletedListBlock - ? BulletListBlockData - : Type extends BlockType.NumberedListBlock - ? NumberedListBlockData - : Type extends BlockType.ToggleListBlock - ? ToggleListBlockData - : Type extends BlockType.CalloutBlock - ? CalloutBlockData - : Type extends BlockType.EquationBlock - ? EauqtionBlockData - : Type extends BlockType.ImageBlock - ? ImageBlockData - : Type extends BlockType.TextBlock - ? TextBlockData - : Type extends BlockType.GridBlock - ? ReferenceBlockData - : Data; - -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export interface NestedBlock<Type = any> { - id: string; - type: BlockType; - data: BlockData<Type>; - parent: string | null; - children: string; - externalId?: string; - externalType?: string; -} - -export type Node = NestedBlock; - -export interface DocumentData { - rootId: string; - // map of block id to block - nodes: Record<string, Node>; - // map of block id to children block ids - children: Record<string, string[]>; - - deltaMap: Record<string, string>; -} -export interface DocumentState { - // map of block id to block - nodes: Record<string, Node>; - // map of block id to children block ids - children: Record<string, string[]>; - deltaMap: Record<string, string>; -} - -export interface SlashCommandState { - isSlashCommand: boolean; - blockId?: string; - hoverOption?: SlashCommandOption; -} - -export enum SlashCommandOptionKey { - TEXT, - TODO, - BULLET, - NUMBER, - TOGGLE, - CODE, - EQUATION, - QUOTE, - CALLOUT, - DIVIDER, - HEADING_1, - HEADING_2, - HEADING_3, - IMAGE, - GRID_REFERENCE, -} - -export interface SlashCommandOption { - type: BlockType; - data?: BlockData; - key: SlashCommandOptionKey; - onClick?: () => void; -} - -export enum SlashCommandGroup { - BASIC = 'Basic', - MEDIA = 'Media', - ADVANCED = 'Advanced', -} - -export interface RectSelectionState { - selection: string[]; - isDragging: boolean; -} - -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 { - BlockInsert, - BlockUpdate, - BlockDelete, - ChildrenMapInsert, - ChildrenMapUpdate, - ChildrenMapDelete, - DeltaMapInsert, - DeltaMapUpdate, - DeltaMapDelete, -} - -export interface BlockPBValue { - id: string; - ty: string; - parent: string; - children: string; - data: string; - external_id?: string; - external_type?: string; -} - -export enum SplitRelationship { - NextSibling, - FirstChild, -} -export enum TextAction { - Turn = 'turn', - Bold = 'bold', - Italic = 'italic', - Underline = 'underline', - Strikethrough = 'strikethrough', - Code = 'code', - Equation = 'formula', - Link = 'href', - TextColor = 'font_color', - Highlight = 'bg_color', -} -export interface TextActionMenuProps { - /** - * The custom items that will be covered in the default items - */ - customItems?: TextAction[]; - /** - * The items that will be excluded from the default items - */ - excludeItems?: TextAction[]; -} - -export interface BlockConfig { - /** - * Whether the block can have children - */ - canAddChild: boolean; - - /** - * The default data of the block - */ - defaultData?: BlockData; - - /** - * The props that will be passed to the text split function - */ - splitProps?: { - /** - * The relationship between the next line block and the current block - */ - nextLineRelationShip: SplitRelationship; - /** - * The type of the next line block - */ - nextLineBlockType: BlockType; - }; -} - -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; - isDark: boolean; -} -export interface EditorProps { - isCodeBlock?: boolean; - placeholder?: string; - value?: Delta; - selection?: RangeStaticNoId; - decorateSelection?: RangeStaticNoId; - temporarySelection?: RangeStaticNoId; - onSelectionChange?: (range: RangeStaticNoId | null, oldRange: RangeStaticNoId | null, source?: Sources) => void; - onChange: (ops: Op[], newDelta: Delta) => void; - onKeyDown?: (event: React.KeyboardEvent<HTMLDivElement>) => void; -} - -export interface BlockCopyData { - json: string; - text: string; - html: string; -} - -export interface TemporaryState { - id: string; - type: TemporaryType; - selectedText: string; - data: TemporaryData; - selection: RangeStaticNoId; - popoverPosition?: { top: number; left: number } | null; -} - -export enum TemporaryType { - Equation = 'equation', - Link = 'link', -} - -export interface TemporaryData { - latex?: string; - href?: string; - text?: string; -} diff --git a/frontend/appflowy_tauri/src/appflowy_app/interfaces/index.ts b/frontend/appflowy_tauri/src/appflowy_app/interfaces/index.ts deleted file mode 100644 index 7d1466630a..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/interfaces/index.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { ThemeModePB as ThemeMode } from '@/services/backend'; - -export { ThemeMode }; - -export interface UserSetting { - theme?: Theme; - themeMode?: ThemeMode; - language?: string; -} - -export enum Theme { - Default = 'default', - Dandelion = 'dandelion', - Lavender = 'lavender', -} diff --git a/frontend/appflowy_tauri/src/appflowy_app/slate-editor.d.ts b/frontend/appflowy_tauri/src/appflowy_app/slate-editor.d.ts new file mode 100644 index 0000000000..8a3dab849a --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/slate-editor.d.ts @@ -0,0 +1,44 @@ +import { BaseEditor } from 'slate'; +import { ReactEditor } from 'slate-react'; + +interface EditorInlineAttributes { + bold?: boolean; + italic?: boolean; + underline?: boolean; + strikethrough?: boolean; + font_color?: string; + bg_color?: string; + href?: string; + code?: boolean; + formula?: string; + prism_token?: string; + temporary?: string; + mention?: { + type: string; + // inline page ref id + page?: string; + // reminder date ref id + date?: string; + }; +} + +type CustomElement = { + children: (CustomText | CustomElement)[]; + type: string; + level?: number; + data?: unknown; + isHidden?: boolean; + parentId?: string; + blockId?: string; + textId?: string; +}; + +type CustomText = { text: string } & EditorInlineAttributes; + +declare module 'slate' { + interface CustomTypes { + Editor: BaseEditor & ReactEditor; + Element: CustomElement; + Text: CustomText; + } +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/stores/effects/document/document_bd_svc.ts b/frontend/appflowy_tauri/src/appflowy_app/stores/effects/document/document_bd_svc.ts deleted file mode 100644 index ab24589a38..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/stores/effects/document/document_bd_svc.ts +++ /dev/null @@ -1,95 +0,0 @@ -import { - FlowyError, - DocumentDataPB, - OpenDocumentPayloadPB, - ApplyActionPayloadPB, - BlockActionPB, - CloseDocumentPayloadPB, - DocumentRedoUndoPayloadPB, - DocumentRedoUndoResponsePB, - TextDeltaPayloadPB, -} from '@/services/backend'; -import { Result } from 'ts-results'; -import { - DocumentEventApplyAction, - DocumentEventCloseDocument, - DocumentEventOpenDocument, - DocumentEventCanUndoRedo, - DocumentEventRedo, - DocumentEventUndo, - DocumentEventCreateText, - DocumentEventApplyTextDeltaEvent, -} from '@/services/backend/events/flowy-document2'; - -export class DocumentBackendService { - constructor(public readonly viewId: string) {} - - open = (): Promise<Result<DocumentDataPB, FlowyError>> => { - const payload = OpenDocumentPayloadPB.fromObject({ - document_id: this.viewId, - }); - - return DocumentEventOpenDocument(payload); - }; - - applyActions = (actions: ReturnType<typeof BlockActionPB.prototype.toObject>[]): Promise<Result<void, FlowyError>> => { - const payload = ApplyActionPayloadPB.fromObject({ - document_id: this.viewId, - actions: actions, - }); - - return DocumentEventApplyAction(payload); - }; - - createText = (textId: string, defaultDelta?: string): Promise<Result<void, FlowyError>> => { - const payload = TextDeltaPayloadPB.fromObject({ - document_id: this.viewId, - text_id: textId, - delta: defaultDelta, - }); - - return DocumentEventCreateText(payload); - }; - - applyTextDelta = (textId: string, delta: string): Promise<Result<void, FlowyError>> => { - const payload = TextDeltaPayloadPB.fromObject({ - document_id: this.viewId, - text_id: textId, - delta: delta, - }); - - return DocumentEventApplyTextDeltaEvent(payload); - }; - - close = (): Promise<Result<void, FlowyError>> => { - const payload = CloseDocumentPayloadPB.fromObject({ - document_id: this.viewId, - }); - - return DocumentEventCloseDocument(payload); - }; - - canUndoRedo = (): Promise<Result<DocumentRedoUndoResponsePB, FlowyError>> => { - const payload = DocumentRedoUndoPayloadPB.fromObject({ - document_id: this.viewId, - }); - - return DocumentEventCanUndoRedo(payload); - }; - - undo = (): Promise<Result<DocumentRedoUndoResponsePB, FlowyError>> => { - const payload = DocumentRedoUndoPayloadPB.fromObject({ - document_id: this.viewId, - }); - - return DocumentEventUndo(payload); - }; - - redo = (): Promise<Result<DocumentRedoUndoResponsePB, FlowyError>> => { - const payload = DocumentRedoUndoPayloadPB.fromObject({ - document_id: this.viewId, - }); - - return DocumentEventRedo(payload); - }; -} 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 deleted file mode 100644 index 495cfcfb96..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/stores/effects/document/document_controller.ts +++ /dev/null @@ -1,218 +0,0 @@ -import { DocumentData, Node } from '@/appflowy_app/interfaces/document'; -import { createContext } from 'react'; -import { DocumentBackendService } from './document_bd_svc'; -import { - BlockActionPB, - DocEventPB, - BlockActionTypePB, - BlockEventPayloadPB, - BlockPB, - ChildrenPB, -} from '@/services/backend'; -import { DocumentObserver } from './document_observer'; -import { get } from '@/appflowy_app/utils/tool'; -import { blockPB2Node } from '$app/utils/document/block'; -import { Log } from '$app/utils/log'; -import { BLOCK_MAP_NAME, CHILDREN_MAP_NAME, META_NAME, TEXT_MAP_NAME } from '$app/constants/document/name'; - -export class DocumentController { - private readonly backendService: DocumentBackendService; - private readonly observer: DocumentObserver; - - constructor( - public readonly documentId: string, - private onDocChange?: (props: { docId: string; isRemote: boolean; data: BlockEventPayloadPB }) => void - ) { - this.backendService = new DocumentBackendService(documentId); - this.observer = new DocumentObserver(documentId); - } - - get backend() { - return this.backendService; - } - - open = async (): Promise<DocumentData> => { - await this.observer.subscribe({ - didReceiveUpdate: this.updated, - }); - - const document = await this.backendService.open(); - - if (document.ok) { - const nodes: DocumentData['nodes'] = {}; - - get<Map<string, BlockPB>>(document.val, [BLOCK_MAP_NAME]).forEach((block) => { - Object.assign(nodes, { - [block.id]: blockPB2Node(block), - }); - }); - const children: Record<string, string[]> = {}; - const deltaMap: Record<string, string> = {}; - - get<Map<string, ChildrenPB>>(document.val, [META_NAME, CHILDREN_MAP_NAME]).forEach((child, key) => { - children[key] = child.children; - }); - - get<Map<string, string>>(document.val, [META_NAME, TEXT_MAP_NAME]).forEach((delta, key) => { - deltaMap[key] = delta; - }); - return { - rootId: document.val.page_id, - nodes, - children, - deltaMap, - }; - } - - return Promise.reject(document.val); - }; - - applyTextDelta = async (textId: string, delta: string) => { - const result = await this.backendService.applyTextDelta(textId, delta); - - if (result.ok) { - return; - } - - return Promise.reject(result.err); - }; - - applyActions = async (actions: ReturnType<typeof BlockActionPB.prototype.toObject>[]) => { - Log.debug('applyActions', actions); - if (actions.length === 0) return; - await this.backendService.applyActions(actions); - }; - - getInsertAction = (node: Node, prevId: string | null) => { - return { - action: BlockActionTypePB.Insert, - payload: this.getActionPayloadByNode(node, prevId), - }; - }; - - getInsertTextActions = (node: Node, delta: string, prevId: string | null) => { - const textId = node.externalId; - - return [ - { - action: BlockActionTypePB.InsertText, - payload: { - text_id: textId, - delta, - }, - }, - this.getInsertAction(node, prevId), - ]; - }; - - getApplyTextDeltaAction = (node: Node, delta: string) => { - const textId = node.externalId; - - return { - action: BlockActionTypePB.ApplyTextDelta, - payload: { - text_id: textId, - delta, - }, - }; - }; - - getUpdateAction = (node: Node) => { - return { - action: BlockActionTypePB.Update, - payload: this.getActionPayloadByNode(node, ''), - }; - }; - - getMoveAction = (node: Node, parentId: string, prevId: string | null) => { - return { - action: BlockActionTypePB.Move, - payload: this.getActionPayloadByNode( - { - ...node, - parent: parentId, - }, - prevId - ), - }; - }; - - getMoveChildrenAction = (children: Node[], parentId: string, prevId: string | null) => { - return children.reverse().map((child) => { - return this.getMoveAction(child, parentId, prevId); - }); - }; - - getDeleteAction = (node: Node) => { - return { - action: BlockActionTypePB.Delete, - payload: this.getActionPayloadByNode(node, ''), - }; - }; - - canUndo = async () => { - const result = await this.backendService.canUndoRedo(); - - return result.ok && result.val.can_undo; - }; - - canRedo = async () => { - const result = await this.backendService.canUndoRedo(); - - return result.ok && result.val.can_redo; - }; - - undo = async () => { - const result = await this.backendService.undo(); - - return result.ok && result.val.is_success; - }; - - redo = async () => { - const result = await this.backendService.redo(); - - return result.ok && result.val.is_success; - }; - - dispose = async () => { - this.onDocChange = undefined; - await this.backendService.close(); - }; - - private getActionPayloadByNode = (node: Node, prevId: string | null) => { - return { - block: this.getBlockByNode(node), - parent_id: node.parent || '', - prev_id: prevId || '', - }; - }; - - private getBlockByNode = (node: Node) => { - return { - id: node.id, - parent_id: node.parent || '', - children_id: node.children, - data: JSON.stringify(node.data), - ty: node.type, - external_id: node.externalId, - external_type: node.externalType, - }; - }; - - private updated = (payload: Uint8Array) => { - if (!this.onDocChange) return; - const { events, is_remote } = DocEventPB.deserializeBinary(payload); - - events.forEach((blockEvent) => { - blockEvent.event.forEach((_payload) => { - this.onDocChange?.({ - docId: this.documentId, - isRemote: is_remote, - data: _payload, - }); - }); - }); - }; -} - -export const DocumentControllerContext = createContext<DocumentController>(new DocumentController('')); diff --git a/frontend/appflowy_tauri/src/appflowy_app/stores/effects/document/document_observer.ts b/frontend/appflowy_tauri/src/appflowy_app/stores/effects/document/document_observer.ts deleted file mode 100644 index f124748105..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/stores/effects/document/document_observer.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { DocumentNotification } from '@/services/backend'; -import { DocumentNotificationObserver } from './notifications/observer'; - -export type DidReceiveUpdateCallback = (payload: Uint8Array) => void; // todo: add params - -export class DocumentObserver { - private listener?: DocumentNotificationObserver; - - constructor(public readonly workspaceId: string) {} - - subscribe = async (callbacks: { didReceiveUpdate: DidReceiveUpdateCallback }) => { - this.listener = new DocumentNotificationObserver({ - viewId: this.workspaceId, - parserHandler: (notification, result) => { - switch (notification) { - case DocumentNotification.DidReceiveUpdate: - if (!result.ok) break; - callbacks.didReceiveUpdate(result.val); - - break; - default: - break; - } - }, - }); - await this.listener.start(); - }; - - unsubscribe = async () => { - // this.appListNotifier.unsubscribe(); - // this.workspaceNotifier.unsubscribe(); - await this.listener?.stop(); - }; -} diff --git a/frontend/appflowy_tauri/src/appflowy_app/stores/effects/document/entities.ts b/frontend/appflowy_tauri/src/appflowy_app/stores/effects/document/entities.ts deleted file mode 100644 index 8ea0a151d3..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/stores/effects/document/entities.ts +++ /dev/null @@ -1 +0,0 @@ -export class Document {} diff --git a/frontend/appflowy_tauri/src/appflowy_app/stores/effects/document/notifications/observer.ts b/frontend/appflowy_tauri/src/appflowy_app/stores/effects/document/notifications/observer.ts deleted file mode 100644 index 0d7a3d97d1..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/stores/effects/document/notifications/observer.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { OnNotificationError, AFNotificationObserver } from '@/services/backend/notifications'; -import { DocumentNotificationParser } from './parser'; -import { FlowyError, DocumentNotification } from '@/services/backend'; -import { Result } from 'ts-results'; - -export type ParserHandler = (notification: DocumentNotification, payload: Result<Uint8Array, FlowyError>) => void; - -export class DocumentNotificationObserver extends AFNotificationObserver<DocumentNotification> { - constructor(params: { viewId?: string; parserHandler: ParserHandler; onError?: OnNotificationError }) { - const parser = new DocumentNotificationParser({ - callback: params.parserHandler, - id: params.viewId, - }); - - super(parser); - } -} diff --git a/frontend/appflowy_tauri/src/appflowy_app/stores/effects/document/notifications/parser.ts b/frontend/appflowy_tauri/src/appflowy_app/stores/effects/document/notifications/parser.ts deleted file mode 100644 index 8609148153..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/stores/effects/document/notifications/parser.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { NotificationParser, OnNotificationError } from '@/services/backend/notifications'; -import { FlowyError, DocumentNotification } from '@/services/backend'; -import { Result } from 'ts-results'; - -declare type DocumentNotificationCallback = (ty: DocumentNotification, payload: Result<Uint8Array, FlowyError>) => void; - -export class DocumentNotificationParser extends NotificationParser<DocumentNotification> { - constructor(params: { id?: string; callback: DocumentNotificationCallback; onError?: OnNotificationError }) { - super( - params.callback, - (ty) => { - const notification = DocumentNotification[ty]; - - if (isDocumentNotification(notification)) { - return DocumentNotification[notification]; - } else { - return DocumentNotification.Unknown; - } - }, - params.id - ); - } -} - -const isDocumentNotification = (notification: string): notification is keyof typeof DocumentNotification => { - return Object.values(DocumentNotification).indexOf(notification) !== -1; -}; diff --git a/frontend/appflowy_tauri/src/appflowy_app/stores/effects/user/user_setting_controller.ts b/frontend/appflowy_tauri/src/appflowy_app/stores/effects/user/user_setting_controller.ts index e753a30750..ec4ea12c49 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/stores/effects/user/user_setting_controller.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/stores/effects/user/user_setting_controller.ts @@ -1,6 +1,6 @@ import { UserBackendService } from '$app/stores/effects/user/user_bd_svc'; import { AppearanceSettingsPB } from '@/services/backend'; -import { Theme, ThemeMode, UserSetting } from '$app/interfaces'; +import { Theme, ThemeMode, UserSetting } from '$app/stores/reducers/current-user/slice'; export class UserSettingController { private readonly backendService: UserBackendService; diff --git a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/block-draggable/async_actions.ts b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/block-draggable/async_actions.ts deleted file mode 100644 index 5e3f471bd6..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/block-draggable/async_actions.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { createAsyncThunk } from '@reduxjs/toolkit'; -import { RootState } from '$app/stores/store'; -import { blockDraggableActions, BlockDraggableType } from '$app_reducers/block-draggable/slice'; -import { dragThunk } from '$app_reducers/document/async-actions/drag'; -import { DocumentController } from '$app/stores/effects/document/document_controller'; -import { movePageThunk } from '$app_reducers/pages/async_actions'; -import { Log } from '$app/utils/log'; - -export const onDragEndThunk = createAsyncThunk('blockDraggable/onDragEnd', async (payload: void, thunkAPI) => { - const { getState, dispatch } = thunkAPI; - const { dragging, draggingId, dropId, insertType, draggingContext, dropContext } = (getState() as RootState) - .blockDraggable; - - if (!dragging) return; - - dispatch(blockDraggableActions.endDrag()); - - if (!draggingId || !dropId || !insertType || !draggingContext || !dropContext) return; - if (draggingContext.type !== dropContext.type) { - // TODO: will support this in the future - Log.info('Unsupported drag this block to different type of block'); - return; - } - - if (dropContext.type === BlockDraggableType.BLOCK) { - const docId = dropContext.contextId; - - if (!docId) return; - await dispatch( - dragThunk({ - draggingId, - dropId, - insertType, - controller: new DocumentController(docId), - }) - ); - return; - } - - if (dropContext.type === BlockDraggableType.PAGE) { - const workspaceId = dropContext.contextId; - - if (!workspaceId) return; - await dispatch( - movePageThunk({ - sourceId: draggingId, - targetId: dropId, - insertType, - }) - ); - return; - } -}); diff --git a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/block-draggable/slice.ts b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/block-draggable/slice.ts deleted file mode 100644 index 123225a64d..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/block-draggable/slice.ts +++ /dev/null @@ -1,104 +0,0 @@ -import { createSlice, PayloadAction } from '@reduxjs/toolkit'; - -const DRAG_DISTANCE_THRESHOLD = 10; - -export enum BlockDraggableType { - BLOCK = 'BLOCK', - PAGE = 'PAGE', -} - -export interface DraggableContext { - type: BlockDraggableType; - contextId?: string; -} -export interface BlockDraggableState { - dragging: boolean; - startDraggingPosition?: { - x: number; - y: number; - }; - draggingPosition?: { - x: number; - y: number; - }; - isDraggable: boolean; - dragShadowVisible: boolean; - draggingId?: string; - insertType?: DragInsertType; - dropId?: string; - dropContext?: DraggableContext; - draggingContext?: DraggableContext; -} - -export enum DragInsertType { - BEFORE = 'BEFORE', - AFTER = 'AFTER', - CHILD = 'CHILD', -} - -const initialState: BlockDraggableState = { - dragging: false, - isDraggable: true, - dragShadowVisible: false, -}; - -export const blockDraggableSlice = createSlice({ - name: 'blockDraggable', - initialState: initialState, - reducers: { - startDrag: ( - state, - action: PayloadAction<{ - startDraggingPosition: { - x: number; - y: number; - }; - draggingId: string; - draggingContext: DraggableContext; - }> - ) => { - const { draggingContext, startDraggingPosition, draggingId } = action.payload; - - state.dragging = true; - state.startDraggingPosition = startDraggingPosition; - state.draggingId = draggingId; - state.draggingContext = draggingContext; - }, - - drag: ( - state, - action: PayloadAction<{ - draggingPosition: { - x: number; - y: number; - }; - insertType?: DragInsertType; - dropId?: string; - dropContext?: DraggableContext; - }> - ) => { - const { dropContext, dropId, draggingPosition, insertType } = action.payload; - - state.draggingPosition = draggingPosition; - state.dropContext = dropContext; - const { startDraggingPosition } = state; - - const moveDistance = startDraggingPosition - ? Math.sqrt( - Math.pow(draggingPosition.x - startDraggingPosition.x, 2) + - Math.pow(draggingPosition.y - startDraggingPosition.y, 2) - ) - : 0; - - state.dropId = dropId; - state.insertType = insertType; - state.dragShadowVisible = moveDistance > DRAG_DISTANCE_THRESHOLD; - }, - - endDrag: () => { - return initialState; - }, - }, -}); - -export const blockDraggableActions = blockDraggableSlice.actions; diff --git a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/current-user/slice.ts b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/current-user/slice.ts index 267c3e68f1..1af3c3446e 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/current-user/slice.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/current-user/slice.ts @@ -1,6 +1,20 @@ import { createSlice, PayloadAction } from '@reduxjs/toolkit'; import { WorkspaceSettingPB } from '@/services/backend/models/flowy-folder2/workspace'; -import { UserSetting } from '$app/interfaces'; +import { ThemeModePB as ThemeMode } from '@/services/backend'; + +export { ThemeMode }; + +export interface UserSetting { + theme?: Theme; + themeMode?: ThemeMode; + language?: string; +} + +export enum Theme { + Default = 'default', + Dandelion = 'dandelion', + Lavender = 'lavender', +} export interface ICurrentUser { id?: number; diff --git a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/delete.ts b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/delete.ts deleted file mode 100644 index bf625503ef..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/delete.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { DocumentController } from '$app/stores/effects/document/document_controller'; -import { createAsyncThunk } from '@reduxjs/toolkit'; - -import { RootState } from '$app/stores/store'; -import { DOCUMENT_NAME } from '$app/constants/document/name'; - -export const deleteNodeThunk = createAsyncThunk( - 'document/deleteNode', - async (payload: { id: string; controller: DocumentController }, thunkAPI) => { - const { id, controller } = payload; - const { getState } = thunkAPI; - const state = getState() as RootState; - const docId = controller.documentId; - const docState = state[DOCUMENT_NAME][docId]; - const node = docState.nodes[id]; - - if (!node) return; - await controller.applyActions([controller.getDeleteAction(node)]); - } -); 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 deleted file mode 100644 index 7a95846059..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/duplicate.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { DocumentController } from '$app/stores/effects/document/document_controller'; -import { createAsyncThunk } from '@reduxjs/toolkit'; -import { getDuplicateActions } from '$app/utils/document/action'; -import { RootState } from '$app/stores/store'; -import { DOCUMENT_NAME } from '$app/constants/document/name'; -import { setRectSelectionThunk } from '$app_reducers/document/async-actions/rect_selection'; - -export const duplicateBelowNodeThunk = createAsyncThunk( - 'document/duplicateBelowNode', - async (payload: { id: string; controller: DocumentController }, thunkAPI) => { - const { id, controller } = payload; - const { getState, dispatch } = thunkAPI; - const state = getState() as RootState; - const docId = controller.documentId; - const docState = state[DOCUMENT_NAME][docId]; - const node = docState.nodes[id]; - - if (!node || !node.parent) return; - const duplicateActions = getDuplicateActions(id, node.parent, docState, controller); - - if (!duplicateActions) return; - await controller.applyActions(duplicateActions.actions); - - await dispatch( - setRectSelectionThunk({ - docId, - selection: [duplicateActions.newNodeId], - }) - ); - } -); diff --git a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/indent.ts b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/indent.ts deleted file mode 100644 index 797e2db07d..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/indent.ts +++ /dev/null @@ -1,47 +0,0 @@ -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/block'; -import { RootState } from '$app/stores/store'; -import { DOCUMENT_NAME } from '$app/constants/document/name'; - -/** - * indent node - * 1. if node parent is root, do nothing - * 2. if node parent is not root - * 2.1. get prev node, if prev node is not allowed to have children, do nothing - * 2.2. if prev node is allowed to have children, move node to prev node's last child, and move node's children after node - */ -export const indentNodeThunk = createAsyncThunk( - 'document/indentNode', - async (payload: { id: string; controller: DocumentController }, thunkAPI) => { - const { id, controller } = payload; - const { getState } = thunkAPI; - const state = getState() as RootState; - const docId = controller.documentId; - const docState = state[DOCUMENT_NAME][docId]; - const node = docState.nodes[id]; - - if (!node.parent) return; - - // get prev node - const prevNodeId = getPrevNodeId(docState, id); - - if (!prevNodeId) return; - const newParentNode = docState.nodes[prevNodeId]; - // check if prev node is allowed to have children - const config = blockConfig[newParentNode.type]; - - if (!config.canAddChild) return; - - // check if prev node has children and get last child for new prev node - const newParentChildren = docState.children[newParentNode.children]; - const newPrevId = newParentChildren[newParentChildren.length - 1]; - - const moveAction = controller.getMoveAction(node, newParentNode.id, newPrevId); - const childrenNodes = docState.children[node.children].map((id) => docState.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 deleted file mode 100644 index e24226de12..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/index.ts +++ /dev/null @@ -1,6 +0,0 @@ -export * from './delete'; -export * from './duplicate'; -export * from './insert'; -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 deleted file mode 100644 index 5f19743be3..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/insert.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { BlockData, BlockType } from '$app/interfaces/document'; -import { DocumentController } from '$app/stores/effects/document/document_controller'; -import { createAsyncThunk } from '@reduxjs/toolkit'; -import { generateId, newBlock } from '$app/utils/document/block'; -import { RootState } from '$app/stores/store'; -import { DOCUMENT_NAME } from '$app/constants/document/name'; -import { BlockDeltaOperator } from '$app/utils/document/block_delta'; -import Delta from 'quill-delta'; - -export const insertAfterNodeThunk = createAsyncThunk( - 'document/insertAfterNode', - async ( - payload: { - id: string; - controller: DocumentController; - type: BlockType; - data?: BlockData; - defaultDelta?: Delta; - }, - thunkAPI - ) => { - const { controller, id, type, data, defaultDelta } = payload; - const { getState } = thunkAPI; - const state = getState() as RootState; - const documentState = state[DOCUMENT_NAME][controller.documentId]; - const node = documentState.nodes[id]; - - if (!node) return; - const parentId = node.parent; - - if (!parentId) return; - // create new node - const actions = []; - let newNodeId; - const deltaOperator = new BlockDeltaOperator(documentState, controller); - - if (type === BlockType.DividerBlock) { - const newNode = newBlock(type, parentId, data); - - actions.push(controller.getInsertAction(newNode, node.id)); - newNodeId = newNode.id; - const nodeId = generateId(); - - actions.push( - ...deltaOperator.getNewTextLineActions({ - blockId: nodeId, - parentId, - prevId: newNodeId, - delta: new Delta([{ insert: '' }]), - type: BlockType.TextBlock, - }) - ); - newNodeId = nodeId; - } else { - if (defaultDelta) { - newNodeId = generateId(); - actions.push( - ...deltaOperator.getNewTextLineActions({ - blockId: newNodeId, - parentId, - prevId: node.id, - delta: defaultDelta, - type, - }) - ); - } else { - const newNode = newBlock(type, parentId, data); - - actions.push(controller.getInsertAction(newNode, node.id)); - newNodeId = newNode.id; - } - } - - await controller.applyActions(actions); - - return newNodeId; - } -); diff --git a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/outdent.ts b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/outdent.ts deleted file mode 100644 index 06332ac62c..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/outdent.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { DocumentController } from '$app/stores/effects/document/document_controller'; -import { createAsyncThunk } from '@reduxjs/toolkit'; -import { blockConfig } from '$app/constants/document/config'; -import { RootState } from '$app/stores/store'; -import { DOCUMENT_NAME } from '$app/constants/document/name'; - -/** - * outdent node - * 1. if node parent is root, do nothing - * 2. if node parent is not root, move node to after parent and record next sibling ids - * 2.1. if next sibling ids is empty, do nothing - * 2.2. if next sibling ids is not empty - * 2.2.1. if node can add child, move next sibling ids to node's children - * 2.2.2. if node can not add child, move next sibling ids to after node - */ -export const outdentNodeThunk = createAsyncThunk( - 'document/outdentNode', - async (payload: { id: string; controller: DocumentController }, thunkAPI) => { - const { id, controller } = payload; - const { getState } = thunkAPI; - const state = getState() as RootState; - const docId = controller.documentId; - const docState = state[DOCUMENT_NAME][docId]; - const node = docState.nodes[id]; - const parentId = node.parent; - - if (!parentId) return; - const ancestorId = docState.nodes[parentId].parent; - - if (!ancestorId) return; - - const parent = docState.nodes[parentId]; - const index = docState.children[parent.children].indexOf(id); - const nextSiblingIds = docState.children[parent.children].slice(index + 1); - - const actions = []; - const moveAction = controller.getMoveAction(node, ancestorId, parentId); - - actions.push(moveAction); - - const config = blockConfig[node.type]; - - if (nextSiblingIds.length > 0) { - if (config.canAddChild) { - const children = docState.children[node.children]; - let lastChildId: string | null = null; - const lastIndex = children.length - 1; - - if (lastIndex >= 0) { - lastChildId = children[lastIndex]; - } - - const moveChildrenActions = nextSiblingIds - .reverse() - .map((id) => controller.getMoveAction(docState.nodes[id], node.id, lastChildId)); - - actions.push(...moveChildrenActions); - } else { - const moveChildrenActions = nextSiblingIds - .reverse() - .map((id) => controller.getMoveAction(docState.nodes[id], ancestorId, node.id)); - - actions.push(...moveChildrenActions); - } - } - - await controller.applyActions(actions); - } -); diff --git a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/update.ts b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/update.ts deleted file mode 100644 index 6e211cc728..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/update.ts +++ /dev/null @@ -1,114 +0,0 @@ -import { BlockData } from '$app/interfaces/document'; -import { DocumentController } from '$app/stores/effects/document/document_controller'; -import { createAsyncThunk } from '@reduxjs/toolkit'; -import Delta, { Op } from 'quill-delta'; -import { RootState } from '$app/stores/store'; -import { DOCUMENT_NAME } from '$app/constants/document/name'; -import { updatePageName } from '$app_reducers/pages/async_actions'; -import { getDeltaText } from '$app/utils/document/delta'; -import { BlockDeltaOperator } from '$app/utils/document/block_delta'; -import { openMention, closeMention } from '$app_reducers/document/async-actions/mention'; -import { slashCommandActions } from '$app_reducers/document/slice'; - -const updateNodeDeltaAfterThunk = createAsyncThunk( - 'document/updateNodeDeltaAfter', - async ( - payload: { docId: string; id: string; ops: Op[]; newDelta: Delta; oldDelta: Delta; controller: DocumentController }, - thunkAPI - ) => { - const { dispatch } = thunkAPI; - const { docId, ops, oldDelta, newDelta, id } = payload; - const insertOps = ops.filter((op) => op.insert !== undefined); - - const deleteOps = ops.filter((op) => op.delete !== undefined); - const oldText = getDeltaText(oldDelta); - const newText = getDeltaText(newDelta); - const deleteText = oldText.slice(newText.length); - - if (insertOps.length === 1) { - const char = insertOps[0].insert; - - if (char === '@' && (oldText.endsWith(' ') || oldText === '')) { - await dispatch(openMention({ docId })); - } - - if (char === '/') { - dispatch( - slashCommandActions.openSlashCommand({ - blockId: id, - docId, - }) - ); - } - } - - if (deleteOps.length === 1) { - if (deleteText === '@') { - await dispatch(closeMention({ docId })); - } - - if (deleteText === '/') { - dispatch(slashCommandActions.closeSlashCommand(docId)); - } - } - } -); - -export const updateNodeDeltaThunk = createAsyncThunk( - 'document/updateNodeDelta', - async (payload: { id: string; ops: Op[]; newDelta: Delta; controller: DocumentController }, thunkAPI) => { - const { id, ops, newDelta, controller } = payload; - const { getState, dispatch } = thunkAPI; - const state = getState() as RootState; - const docId = controller.documentId; - const docState = state[DOCUMENT_NAME][docId]; - const node = docState.nodes[id]; - - // If the node is the root node, update the page name - if (!node.parent) { - await dispatch( - updatePageName({ - id: docId, - name: getDeltaText(newDelta), - }) - ); - return; - } - - const deltaOperator = new BlockDeltaOperator(docState, controller); - const oldDelta = deltaOperator.getDeltaWithBlockId(id); - - if (!oldDelta) return; - const diff = oldDelta?.diff(newDelta); - - if (ops.length === 0 || diff?.ops.length === 0 || !node.externalId) return; - - await controller.applyTextDelta(node.externalId, JSON.stringify(ops)); - await dispatch(updateNodeDeltaAfterThunk({ docId, id, ops, newDelta, oldDelta, controller })); - } -); - -export const updateNodeDataThunk = createAsyncThunk< - void, - { - id: string; - data: Partial<BlockData>; - controller: DocumentController; - } ->('document/updateNodeDataExceptDelta', async (payload, thunkAPI) => { - const { id, data, controller } = payload; - const { getState } = thunkAPI; - const state = getState() as RootState; - const docId = controller.documentId; - const docState = state[DOCUMENT_NAME][docId]; - const node = docState.nodes[id]; - - const newData = { ...node.data, ...data }; - - await controller.applyActions([ - controller.getUpdateAction({ - ...node, - data: newData, - }), - ]); -}); diff --git a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/copy_paste.ts b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/copy_paste.ts deleted file mode 100644 index 97392462ff..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/copy_paste.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { createAsyncThunk } from '@reduxjs/toolkit'; -import { BlockCopyData } from '$app/interfaces/document'; -import { DocumentController } from '$app/stores/effects/document/document_controller'; - -export const copyThunk = createAsyncThunk< - void, - { - isCut?: boolean; - controller: DocumentController; - setClipboardData: (data: BlockCopyData) => void; - } ->('document/copy', async () => { - // TODO: Migrate to Rust implementation. -}); - -/** - * Paste data to document - * 1. delete range blocks - * 2. if current block is empty text block, insert paste data below current block and delete current block - * 3. otherwise: - * 3.1 split current block, before part merge the first block of paste data and update current block - * 3.2 after part append to the last block of paste data - * 3.3 move the first block children of paste data to current block - * 3.4 delete the first block of paste data - */ -export const pasteThunk = createAsyncThunk< - void, - { - data: BlockCopyData; - controller: DocumentController; - } ->('document/paste', async () => { - // TODO: Migrate to Rust implementation. -}); 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 8fcd0ada96..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/cursor.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { createAsyncThunk } from '@reduxjs/toolkit'; -import { rangeActions } from '$app_reducers/document/slice'; - -export const setCursorRangeThunk = createAsyncThunk( - 'document/setCursorRange', - async (payload: { docId: string; blockId: string; index: number; length?: number }, thunkAPI) => { - const { blockId, index, docId, length = 0 } = payload; - const { dispatch } = thunkAPI; - - dispatch(rangeActions.initialState(docId)); - dispatch( - rangeActions.setCaret({ - docId, - caret: { - id: blockId, - index, - length, - }, - }) - ); - } -); diff --git a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/drag.ts b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/drag.ts deleted file mode 100644 index 3425c2cd50..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/drag.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { createAsyncThunk } from '@reduxjs/toolkit'; -import { RootState } from '$app/stores/store'; -import { DragInsertType } from '$app_reducers/block-draggable/slice'; -import { DocumentController } from '$app/stores/effects/document/document_controller'; - -export const dragThunk = createAsyncThunk( - 'document/drag', - async ( - payload: { - draggingId: string; - dropId: string; - insertType: DragInsertType; - controller: DocumentController; - }, - thunkAPI - ) => { - const { getState } = thunkAPI; - const { draggingId, dropId, insertType, controller } = payload; - const docId = controller.documentId; - const documentState = (getState() as RootState).document[docId]; - const { nodes, children } = documentState; - const draggingNode = nodes[draggingId]; - const targetNode = nodes[dropId]; - const targetChildren = children[targetNode.children] || []; - const targetParentId = targetNode.parent; - - if (!targetParentId) return; - const targetParent = nodes[targetParentId]; - const targetParentChildren = children[targetParent.children] || []; - let prevId, parentId; - - if (insertType === DragInsertType.BEFORE) { - const targetIndex = targetParentChildren.indexOf(dropId); - const prevIndex = targetIndex - 1; - - parentId = targetParentId; - if (prevIndex >= 0) { - prevId = targetParentChildren[prevIndex]; - } - } else if (insertType === DragInsertType.AFTER) { - prevId = dropId; - parentId = targetParentId; - } else { - parentId = dropId; - if (targetChildren.length > 0) { - prevId = targetChildren[targetChildren.length - 1]; - } - } - - const actions = [controller.getMoveAction(draggingNode, parentId, prevId || null)]; - - await controller.applyActions(actions); - } -); 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 deleted file mode 100644 index 600be47182..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/format.ts +++ /dev/null @@ -1,117 +0,0 @@ -import { createAsyncThunk } from '@reduxjs/toolkit'; -import { RootState } from '$app/stores/store'; -import { TextAction } from '$app/interfaces/document'; -import { DocumentController } from '$app/stores/effects/document/document_controller'; -import Delta from 'quill-delta'; -import { DOCUMENT_NAME, RANGE_NAME } from '$app/constants/document/name'; -import { BlockDeltaOperator } from '$app/utils/document/block_delta'; -import { BlockActionPB } from '@/services/backend'; - -type FormatValues = Record<string, (boolean | string | undefined)[]>; - -export const getFormatValuesThunk = createAsyncThunk( - 'document/getFormatValues', - ({ docId, format }: { docId: string; format: TextAction }, thunkAPI) => { - const { getState } = thunkAPI; - const state = getState() as RootState; - const document = state[DOCUMENT_NAME][docId]; - const documentRange = state[RANGE_NAME][docId]; - const { ranges } = documentRange; - const deltaOperator = new BlockDeltaOperator(document); - const mapAttrs = (delta: Delta, format: TextAction) => { - return delta.ops.map((op) => op.attributes?.[format] as boolean | string | undefined); - }; - - const formatValues: FormatValues = {}; - - Object.entries(ranges).forEach(([id, range]) => { - const node = document.nodes[id]; - const index = range?.index || 0; - const length = range?.length || 0; - const rangeDelta = deltaOperator.sliceDeltaWithBlockId(node.id, index, index + length); - - if (rangeDelta) { - formatValues[id] = mapAttrs(rangeDelta, format); - } - }); - return formatValues; - } -); - -export const getFormatActiveThunk = createAsyncThunk< - boolean, - { - format: TextAction; - docId: string; - } ->('document/getFormatActive', async ({ format, docId }, thunkAPI) => { - const { dispatch } = thunkAPI; - const { payload } = (await dispatch(getFormatValuesThunk({ docId, format }))) as { - payload: FormatValues; - }; - - return Object.values(payload).every((values) => { - return values.every((value) => { - return value !== undefined; - }); - }); -}); - -export const toggleFormatThunk = createAsyncThunk( - 'document/toggleFormat', - async (payload: { format: TextAction; controller: DocumentController; isActive?: boolean }, thunkAPI) => { - const { dispatch } = thunkAPI; - const { format, controller } = payload; - const docId = controller.documentId; - let isActive = payload.isActive; - - if (isActive === undefined) { - const { payload: active } = await dispatch( - getFormatActiveThunk({ - format, - docId, - }) - ); - - isActive = !!active; - } - - const formatValue = isActive ? null : true; - - await dispatch(formatThunk({ format, value: formatValue, controller })); - } -); - -export const formatThunk = createAsyncThunk( - 'document/format', - async (payload: { format: TextAction; value: string | boolean | null; controller: DocumentController }, thunkAPI) => { - const { getState } = thunkAPI; - const { format, controller, value } = payload; - const docId = controller.documentId; - const state = getState() as RootState; - const document = state[DOCUMENT_NAME][docId]; - const documentRange = state[RANGE_NAME][docId]; - const { ranges } = documentRange; - const deltaOperator = new BlockDeltaOperator(document, controller); - const actions: ReturnType<typeof BlockActionPB.prototype.toObject>[] = []; - - Object.entries(ranges).forEach(([id, range]) => { - const node = document.nodes[id]; - const delta = deltaOperator.getDeltaWithBlockId(node.id); - - if (!delta) return; - const index = range?.index || 0; - const length = range?.length || 0; - const diffDelta: Delta = new Delta(); - - diffDelta.retain(index).retain(length, { [format]: value }); - const action = deltaOperator.getApplyDeltaAction(node.id, diffDelta); - - if (action) { - actions.push(action); - } - }); - - await controller.applyActions(actions); - } -); 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 deleted file mode 100644 index 73eb214085..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -export * from './blocks'; -export * from './turn_to'; -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 deleted file mode 100644 index 449438b559..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/keydown.ts +++ /dev/null @@ -1,370 +0,0 @@ -import { createAsyncThunk } from '@reduxjs/toolkit'; -import { DocumentController } from '$app/stores/effects/document/document_controller'; -import { BlockType, RangeStatic } from '$app/interfaces/document'; -import { turnToTextBlockThunk } from '$app_reducers/document/async-actions/turn_to'; -import { - getLeftCaretByRange, - getRightCaretByRange, - transformToNextLineCaret, - transformToPrevLineCaret, -} from '$app/utils/document/action'; -import { indentNodeThunk, outdentNodeThunk } from '$app_reducers/document/async-actions/blocks'; -import { rangeActions } from '$app_reducers/document/slice'; -import { RootState } from '$app/stores/store'; -import { Keyboard } from '$app/constants/document/keyboard'; -import { DOCUMENT_NAME, RANGE_NAME } from '$app/constants/document/name'; -import { getPreviousWordIndex } from '$app/utils/document/delta'; -import { updatePageName } from '$app_reducers/pages/async_actions'; -import { BlockDeltaOperator } from '$app/utils/document/block_delta'; -import { setCursorRangeThunk } from '$app_reducers/document/async-actions/cursor'; - -/** - - Deletes a block using the backspace or delete key. - - If the block is not a text block, it is converted into a text block. - - If the block is a text block: - - - If the block has a next sibling, it is merged into the prev line (including its children). - - - If the block has no next sibling, it is outdented (moved to a higher level in the hierarchy). - */ -export const backspaceDeleteActionForBlockThunk = createAsyncThunk( - 'document/backspaceDeleteActionForBlock', - async (payload: { id: string; controller: DocumentController }, thunkAPI) => { - const { id, controller } = payload; - const docId = controller.documentId; - const { dispatch, getState } = thunkAPI; - const state = (getState() as RootState).document[docId]; - const node = state.nodes[id]; - - if (!node.parent) return; - const deltaOperator = new BlockDeltaOperator(state, controller, async (name: string) => { - await dispatch( - updatePageName({ - id: docId, - name, - }) - ); - }); - 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 prevLineId = deltaOperator.findPrevTextLine(id); - - if (!prevLineId) return; - - const res = await deltaOperator.mergeText(prevLineId, id); - - if (!res) return; - const caret = { - id: res.id, - index: res.index, - length: 0, - }; - - await dispatch( - setCursorRangeThunk({ - docId, - blockId: caret.id, - index: caret.index, - length: caret.length, - }) - ); - - return; - } - - // outdent - await dispatch(outdentNodeThunk({ id, controller })); - } -); - -/** - * enter key handler - * 1. If node is empty, and it is not a text block, turn it into a text block. - * 2. Otherwise, split the node into two nodes. - */ -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 docId = controller.documentId; - const documentState = state[DOCUMENT_NAME][docId]; - const node = documentState.nodes[id]; - const caret = state[RANGE_NAME][docId]?.caret; - - if (!node || !caret || caret.id !== id) return; - - const deltaOperator = new BlockDeltaOperator(documentState, controller, async (name: string) => { - await dispatch( - updatePageName({ - id: docId, - name, - }) - ); - }); - const isDocumentTitle = !node.parent; - - const delta = deltaOperator.getDeltaWithBlockId(node.id); - - if (!delta) return; - if (!isDocumentTitle && delta.length() === 0 && node.type !== BlockType.TextBlock) { - // If the node is not a text block, turn it to a text block - await dispatch(turnToTextBlockThunk({ id, controller })); - return; - } - - const newLineId = await deltaOperator.splitText( - { - id: node.id, - index: caret.index, - }, - { - id: node.id, - index: caret.index + caret.length, - } - ); - - if (!newLineId) return; - await dispatch( - setCursorRangeThunk({ - docId, - blockId: newLineId, - 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: { docId: string; id: string; down?: boolean }, thunkAPI) => { - const { docId, id, down } = payload; - const { dispatch, getState } = thunkAPI; - const state = getState() as RootState; - const documentState = state[DOCUMENT_NAME][docId]; - const rangeState = state[RANGE_NAME][docId]; - const caret = rangeState.caret; - const node = documentState.nodes[id]; - - if (!node || !caret || id !== caret.id) return; - - let newCaret; - - if (down) { - newCaret = transformToNextLineCaret(documentState, caret); - } else { - newCaret = transformToPrevLineCaret(documentState, caret); - } - - if (!newCaret) { - return; - } - - dispatch(rangeActions.initialState(docId)); - dispatch( - rangeActions.setCaret({ - docId, - caret: newCaret, - }) - ); - } -); - -export const leftActionForBlockThunk = createAsyncThunk( - 'document/leftActionForBlock', - async (payload: { docId: string; id: string }, thunkAPI) => { - const { id, docId } = payload; - const { dispatch, getState } = thunkAPI; - const state = getState() as RootState; - const documentState = state[DOCUMENT_NAME][docId]; - const rangeState = state[RANGE_NAME][docId]; - const caret = rangeState.caret; - const node = documentState.nodes[id]; - - if (!node || !caret || id !== caret.id) return; - let newCaret: RangeStatic; - const deltaOperator = new BlockDeltaOperator(documentState); - const delta = deltaOperator.getDeltaWithBlockId(node.id); - - if (!delta) return; - if (caret.length > 0) { - newCaret = { - id, - index: caret.index, - length: 0, - }; - } else { - if (caret.index > 0) { - const newIndex = getPreviousWordIndex(delta, caret.index); - - newCaret = { - id, - index: newIndex, - length: 0, - }; - } else { - const prevNodeId = deltaOperator.findPrevTextLine(id); - - if (!prevNodeId) return; - const prevDelta = deltaOperator.getDeltaWithBlockId(prevNodeId); - - if (!prevDelta) return; - newCaret = { - id: prevNodeId, - index: prevDelta.length(), - length: 0, - }; - } - } - - if (!newCaret) { - return; - } - - dispatch(rangeActions.initialState(docId)); - dispatch( - rangeActions.setCaret({ - docId, - caret: newCaret, - }) - ); - } -); - -export const rightActionForBlockThunk = createAsyncThunk( - 'document/rightActionForBlock', - async (payload: { id: string; docId: string }, thunkAPI) => { - const { id, docId } = payload; - const { dispatch, getState } = thunkAPI; - const state = getState() as RootState; - const documentState = state[DOCUMENT_NAME][docId]; - const rangeState = state[RANGE_NAME][docId]; - const caret = rangeState.caret; - const node = documentState.nodes[id]; - - if (!node || !caret || id !== caret.id) return; - let newCaret: RangeStatic; - const deltaOperator = new BlockDeltaOperator(documentState); - const delta = deltaOperator.getDeltaWithBlockId(node.id); - - if (!delta) return; - 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 nextNodeId = deltaOperator.findNextTextLine(id); - - if (!nextNodeId) return; - newCaret = { - id: nextNodeId, - index: 0, - length: 0, - }; - } - } - - if (!newCaret) { - return; - } - - dispatch(rangeActions.initialState(docId)); - - dispatch( - rangeActions.setCaret({ - caret: newCaret, - docId, - }) - ); - } -); - -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; - docId: string; - }, - thunkAPI - ) => { - const { dispatch, getState } = thunkAPI; - const { key, docId } = payload; - const state = getState() as RootState; - const documentState = state[DOCUMENT_NAME][docId]; - const rangeState = state[RANGE_NAME][docId]; - let caret; - const leftCaret = getLeftCaretByRange(rangeState); - const rightCaret = getRightCaretByRange(rangeState); - - if (!leftCaret || !rightCaret) return; - - switch (key) { - case Keyboard.keys.LEFT: - caret = leftCaret; - break; - case Keyboard.keys.RIGHT: - caret = rightCaret; - break; - case Keyboard.keys.UP: - caret = transformToPrevLineCaret(documentState, leftCaret); - break; - case Keyboard.keys.DOWN: - caret = transformToNextLineCaret(documentState, rightCaret); - break; - } - - if (!caret) return; - dispatch(rangeActions.initialState(docId)); - dispatch( - rangeActions.setCaret({ - docId, - caret, - }) - ); - } -); diff --git a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/mention.ts b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/mention.ts deleted file mode 100644 index f9379a1b78..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/mention.ts +++ /dev/null @@ -1,90 +0,0 @@ -import { createAsyncThunk } from '@reduxjs/toolkit'; -import { RootState } from '$app/stores/store'; -import { DOCUMENT_NAME, MENTION_NAME, RANGE_NAME } from '$app/constants/document/name'; -import Delta from 'quill-delta'; -import { mentionActions } from '$app_reducers/document/mention_slice'; -import { DocumentController } from '$app/stores/effects/document/document_controller'; -import { BlockDeltaOperator } from '$app/utils/document/block_delta'; -import { setCursorRangeThunk } from '$app_reducers/document/async-actions/cursor'; - -export enum MentionType { - PAGE = 'page', -} -export const openMention = createAsyncThunk('document/mention/open', async (payload: { docId: string }, thunkAPI) => { - const { docId } = payload; - const { dispatch, getState } = thunkAPI; - const state = getState() as RootState; - const rangeState = state[RANGE_NAME][docId]; - const documentState = state[DOCUMENT_NAME][docId]; - const { caret } = rangeState; - - if (!caret) return; - const { id } = caret; - const node = documentState.nodes[id]; - - if (!node.parent) { - return; - } - - dispatch( - mentionActions.open({ - docId, - blockId: id, - }) - ); -}); - -export const closeMention = createAsyncThunk('document/mention/close', async (payload: { docId: string }, thunkAPI) => { - const { docId } = payload; - const { dispatch } = thunkAPI; - - dispatch( - mentionActions.close({ - docId, - }) - ); -}); - -export const formatMention = createAsyncThunk( - 'document/mention/format', - async ( - payload: { controller: DocumentController; type: MentionType; value: string; searchTextLength: number }, - thunkAPI - ) => { - const { controller, type, value, searchTextLength } = payload; - const docId = controller.documentId; - const { dispatch, getState } = thunkAPI; - const state = getState() as RootState; - const mentionState = state[MENTION_NAME][docId]; - const { blockId } = mentionState; - const rangeState = state[RANGE_NAME][docId]; - const documentState = state[DOCUMENT_NAME][docId]; - const caret = rangeState.caret; - - if (!caret) return; - const charLength = searchTextLength + 1; - const index = caret.index - charLength; - - const deltaOperator = new BlockDeltaOperator(documentState, controller); - - const nodeDelta = deltaOperator.getDeltaWithBlockId(blockId); - - if (!nodeDelta) return; - const diffDelta = new Delta() - .retain(index) - .delete(charLength) - .insert('@', { mention: { type, [type]: value } }); - const applyTextDeltaAction = deltaOperator.getApplyDeltaAction(blockId, diffDelta); - - if (!applyTextDeltaAction) return; - await controller.applyActions([applyTextDeltaAction]); - await dispatch( - setCursorRangeThunk({ - docId, - blockId, - index, - length: 0, - }) - ); - } -); 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 deleted file mode 100644 index 1f1f50321c..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/menu.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { createAsyncThunk } from '@reduxjs/toolkit'; -import { BlockType } from '$app/interfaces/document'; -import { insertAfterNodeThunk } from '$app_reducers/document/async-actions/blocks'; -import { DocumentController } from '$app/stores/effects/document/document_controller'; -import { rangeActions, slashCommandActions } from '$app_reducers/document/slice'; -import Delta from 'quill-delta'; -import { RootState } from '$app/stores/store'; -import { BlockDeltaOperator } from '$app/utils/document/block_delta'; - -/** - * add block below click - * 1. if current block is not empty, insert a new block after current block - * 2. if current block is empty, open slash command below current block - */ -export const addBlockBelowClickThunk = createAsyncThunk( - 'document/addBlockBelowClick', - async (payload: { id: string; controller: DocumentController }, thunkAPI) => { - const { id, controller } = payload; - const docId = controller.documentId; - const { dispatch, getState } = thunkAPI; - const state = (getState() as RootState).document[docId]; - const node = state.nodes[id]; - - if (!node) return; - const deltaOperator = new BlockDeltaOperator(state, controller); - const delta = deltaOperator.getDeltaWithBlockId(id); - - // if current block is not empty, insert a new block after current block - if (!delta || delta.length() > 1) { - const { payload: newBlockId } = await dispatch( - insertAfterNodeThunk({ - id: id, - type: BlockType.TextBlock, - controller, - data: {}, - defaultDelta: new Delta([{ insert: '' }]), - }) - ); - - if (newBlockId) { - dispatch( - rangeActions.setCaret({ - docId, - caret: { id: newBlockId as string, index: 0, length: 0 }, - }) - ); - dispatch(slashCommandActions.openSlashCommand({ docId, blockId: newBlockId as string })); - } - - return; - } - - // if current block is empty, open slash command - dispatch( - rangeActions.setCaret({ - docId, - caret: { id, index: 0, length: 0 }, - }) - ); - - dispatch(slashCommandActions.openSlashCommand({ docId, blockId: id })); - } -); 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 deleted file mode 100644 index 14b8e1e00d..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/range.ts +++ /dev/null @@ -1,223 +0,0 @@ -import { createAsyncThunk } from '@reduxjs/toolkit'; -import { RootState } from '$app/stores/store'; -import { rangeActions } from '$app_reducers/document/slice'; -import { DocumentController } from '$app/stores/effects/document/document_controller'; -import { getMiddleIds, getStartAndEndIdsByRange } from '$app/utils/document/action'; -import { RangeState } from '$app/interfaces/document'; -import { DOCUMENT_NAME, RANGE_NAME } from '$app/constants/document/name'; -import { BlockDeltaOperator } from '$app/utils/document/block_delta'; -import { setCursorRangeThunk } from '$app_reducers/document/async-actions/cursor'; -import { updatePageName } from '$app_reducers/pages/async_actions'; - -interface storeRangeThunkPayload { - docId: string; - 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 { docId, id, range } = payload; - const { dispatch, getState } = thunkAPI; - const state = getState() as RootState; - const rangeState = state[RANGE_NAME][docId]; - const documentState = state[DOCUMENT_NAME][docId]; - // 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, - docId, - }) - ); - 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, - docId, - }) - ); - return; - } - - // amend anchor range because slatejs will stop update selection when dragging quickly - const isForward = anchor.point.y < focus.point.y; - const deltaOperator = new BlockDeltaOperator(documentState); - - if (isForward) { - const selectedDelta = deltaOperator.sliceDeltaWithBlockId(anchor.id, anchorIndex); - - if (!selectedDelta) return; - ranges[anchor.id] = { - index: anchorIndex, - length: selectedDelta.length(), - }; - } else { - const selectedDelta = deltaOperator.sliceDeltaWithBlockId(anchor.id, 0, anchorIndex + anchorLength); - - if (!selectedDelta) return; - 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; - - const middleIds = getMiddleIds(documentState, startId, endId); - - middleIds.forEach((id) => { - const node = documentState.nodes[id]; - - if (!node) return; - const delta = deltaOperator.getDeltaWithBlockId(node.id); - - if (!delta) return; - const rangeStatic = { - index: 0, - length: delta.length(), - }; - - ranges[id] = rangeStatic; - }); - - dispatch( - rangeActions.setRanges({ - ranges, - docId, - }) - ); -}); - -/** - * delete range and insert delta - * 1. merge start and end delta to start node and delete end node - * 2. delete middle nodes - * 3. move end node's children to start node - * 3. clear range - */ -export const deleteRangeAndInsertThunk = createAsyncThunk( - 'document/deleteRange', - async (payload: { controller: DocumentController; insertChar?: string }, thunkAPI) => { - const { controller, insertChar } = payload; - const docId = controller.documentId; - const { getState, dispatch } = thunkAPI; - const state = getState() as RootState; - const rangeState = state[RANGE_NAME][docId]; - const documentState = state[DOCUMENT_NAME][docId]; - const deltaOperator = new BlockDeltaOperator(documentState, controller, async (name: string) => { - await dispatch( - updatePageName({ - id: docId, - name, - }) - ); - }); - const [startId, endId] = getStartAndEndIdsByRange(rangeState); - const startSelection = rangeState.ranges[startId]; - const endSelection = rangeState.ranges[endId]; - - if (!startSelection || !endSelection) return; - const id = await deltaOperator.deleteText( - { - id: startId, - index: startSelection.index, - }, - { - id: endId, - index: endSelection.length, - }, - insertChar - ); - - if (!id) return; - await dispatch( - setCursorRangeThunk({ - docId, - blockId: id, - index: insertChar ? startSelection.index + insertChar.length : startSelection.index, - length: 0, - }) - ); - } -); - -/** - * 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 - * 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 docId = controller.documentId; - const state = getState() as RootState; - const rangeState = state[RANGE_NAME][docId]; - const documentState = state[DOCUMENT_NAME][docId]; - const deltaOperator = new BlockDeltaOperator(documentState, controller, async (name: string) => { - await dispatch( - updatePageName({ - id: docId, - name, - }) - ); - }); - const [startId, endId] = getStartAndEndIdsByRange(rangeState); - const startSelection = rangeState.ranges[startId]; - const endSelection = rangeState.ranges[endId]; - - if (!startSelection || !endSelection) return; - const newLineId = await deltaOperator.splitText( - { - id: startId, - index: startSelection.index, - }, - { - id: endId, - index: endSelection.length, - }, - shiftKey - ); - - if (!newLineId) return; - await dispatch( - setCursorRangeThunk({ - docId, - blockId: newLineId, - index: shiftKey ? startSelection.index + 1 : 0, - length: 0, - }) - ); - } -); 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 deleted file mode 100644 index dc5eee32db..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/rect_selection.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { createAsyncThunk } from '@reduxjs/toolkit'; -import { getNextNodeId, getPrevNodeId } from '$app/utils/document/block'; -import { rangeActions, rectSelectionActions } from '$app_reducers/document/slice'; -import { RootState } from '$app/stores/store'; - -export const setRectSelectionThunk = createAsyncThunk( - 'document/setRectSelection', - async ( - payload: { - docId: string; - selection: string[]; - }, - thunkAPI - ) => { - const { getState, dispatch } = thunkAPI; - const { docId, selection } = payload; - const documentState = (getState() as RootState).document[docId]; - const selected: Record<string, boolean> = {}; - - selection.forEach((id) => { - const node = documentState.nodes[id]; - - if (!node.parent) { - return; - } - - selected[id] = selected[id] === undefined ? true : selected[id]; - selected[node.parent] = false; - const nextNodeId = getNextNodeId(documentState, node.parent); - const prevNodeId = getPrevNodeId(documentState, node.parent); - - if ((nextNodeId && selection.includes(nextNodeId)) || (prevNodeId && selection.includes(prevNodeId))) { - selected[node.parent] = true; - } - }); - dispatch(rangeActions.initialState(docId)); - dispatch( - rectSelectionActions.updateSelections({ - docId, - selection: selection.filter((id) => selected[id]), - }) - ); - } -); diff --git a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/temporary.ts b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/temporary.ts deleted file mode 100644 index 7864c66387..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/temporary.ts +++ /dev/null @@ -1,152 +0,0 @@ -import { createAsyncThunk } from '@reduxjs/toolkit'; -import { RootState } from '$app/stores/store'; -import { DOCUMENT_NAME, EQUATION_PLACEHOLDER, RANGE_NAME, TEMPORARY_NAME } from '$app/constants/document/name'; -import Delta from 'quill-delta'; -import { TemporaryState, TemporaryType } from '$app/interfaces/document'; -import { temporaryActions } from '$app_reducers/document/temporary_slice'; -import { DocumentController } from '$app/stores/effects/document/document_controller'; -import { rangeActions } from '$app_reducers/document/slice'; -import { BlockDeltaOperator } from '$app/utils/document/block_delta'; - -export const createTemporary = createAsyncThunk( - 'document/temporary/create', - async (payload: { docId: string; type?: TemporaryType; state?: TemporaryState }, thunkAPI) => { - const { docId, type } = payload; - const { dispatch, getState } = thunkAPI; - const state = getState() as RootState; - let temporaryState = payload.state; - const documentState = state[DOCUMENT_NAME][docId]; - const deltaOperator = new BlockDeltaOperator(documentState); - - if (!temporaryState && type) { - const caret = state[RANGE_NAME][docId].caret; - - if (!caret) { - return; - } - - const { id, index, length } = caret; - const selection = { - index, - length, - }; - - const node = state[DOCUMENT_NAME][docId].nodes[id]; - const nodeDelta = deltaOperator.getDeltaWithBlockId(node.id); - - if (!nodeDelta) return; - const rangeDelta = deltaOperator.sliceDeltaWithBlockId( - node.id, - selection.index, - selection.index + selection.length - ); - - if (!rangeDelta) return; - const text = deltaOperator.getDeltaText(rangeDelta); - - const data = newDataWithTemporaryType(type, text); - - temporaryState = { - id, - selection, - selectedText: text, - type, - data, - }; - } - - if (!temporaryState) return; - dispatch(rangeActions.initialState(docId)); - - dispatch(temporaryActions.setTemporaryState({ id: docId, state: temporaryState })); - } -); - -function newDataWithTemporaryType(type: TemporaryType, text: string) { - switch (type) { - case TemporaryType.Equation: - return { - latex: text, - }; - case TemporaryType.Link: - return { - href: '', - text: text, - }; - default: - return {}; - } -} - -export const formatTemporary = createAsyncThunk( - 'document/temporary/format', - async (payload: { controller: DocumentController }, thunkAPI) => { - const { controller } = payload; - const docId = controller.documentId; - const { getState } = thunkAPI; - const state = getState() as RootState; - const temporaryState = state[TEMPORARY_NAME][docId]; - const documentState = state[DOCUMENT_NAME][docId]; - const deltaOperator = new BlockDeltaOperator(documentState, controller); - - if (!temporaryState) { - return; - } - - const { id, selection, type, data } = temporaryState; - const { index, length } = selection; - const diffDelta: Delta = new Delta(); - let newSelection = selection; - - switch (type) { - case TemporaryType.Equation: { - if (data.latex) { - newSelection = { - index: selection.index, - length: 1, - }; - diffDelta.retain(index).delete(length).insert(EQUATION_PLACEHOLDER, { - formula: data.latex, - }); - } else { - newSelection = { - index: selection.index, - length: 0, - }; - diffDelta.retain(index).delete(length); - } - - break; - } - - case TemporaryType.Link: { - if (!data.text) return; - if (!data.href) { - diffDelta.retain(index).delete(length).insert(data.text); - } else { - diffDelta.retain(index).delete(length).insert(data.text, { - href: data.href, - }); - } - - newSelection = { - index: selection.index, - length: data.text.length, - }; - break; - } - - default: - break; - } - - const applyTextDeltaAction = deltaOperator.getApplyDeltaAction(id, diffDelta); - - if (!applyTextDeltaAction) return; - await controller.applyActions([applyTextDeltaAction]); - return { - ...temporaryState, - selection: newSelection, - }; - } -); 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 deleted file mode 100644 index 2c9e9939db..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/turn_to.ts +++ /dev/null @@ -1,153 +0,0 @@ -import { createAsyncThunk } from '@reduxjs/toolkit'; -import { DocumentController } from '$app/stores/effects/document/document_controller'; -import { BlockData, BlockType } from '$app/interfaces/document'; -import { blockConfig } from '$app/constants/document/config'; -import { generateId, newBlock } from '$app/utils/document/block'; -import { RootState } from '$app/stores/store'; -import { BlockDeltaOperator } from '$app/utils/document/block_delta'; -import Delta from 'quill-delta'; -import { setCursorRangeThunk } from '$app_reducers/document/async-actions/cursor'; -import { blockEditActions } from '$app_reducers/document/block_edit_slice'; -import { DOCUMENT_NAME, RANGE_NAME } from '$app/constants/document/name'; - -/** - * transform to block - * 1. insert block after current block - * 2. move all children - * - if new block is not allowed to have children, move children to parent - * - otherwise, move children to new block - * 3. delete current block - */ -export const turnToBlockThunk = createAsyncThunk( - 'document/turnToBlock', - async (payload: { id: string; controller: DocumentController; type: BlockType; data: BlockData }, thunkAPI) => { - const { id, controller, type, data } = payload; - const docId = controller.documentId; - const { dispatch, getState } = thunkAPI; - const state = getState() as RootState; - const documentState = state[DOCUMENT_NAME][docId]; - const caret = state[RANGE_NAME][docId].caret; - const node = documentState.nodes[id]; - - if (!node.parent) return; - - const parent = documentState.nodes[node.parent]; - const children = documentState.children[node.children].map((id) => documentState.nodes[id]); - let caretId, - caretIndex = caret?.index || 0; - const deltaOperator = new BlockDeltaOperator(documentState, controller); - let delta = deltaOperator.getDeltaWithBlockId(node.id) || new Delta([{ insert: '' }]); - // insert new block after current block - const insertActions = []; - - if (node.type === BlockType.EquationBlock) { - delta = new Delta([{ insert: node.data.formula }]); - } - - const block = newBlock(type, parent.id, data); - - caretId = block.id; - - switch (type) { - case BlockType.GridBlock: - insertActions.push(controller.getInsertAction(block, node.id)); - caretIndex = 0; - break; - case BlockType.EquationBlock: - data.formula = deltaOperator.getDeltaText(delta); - insertActions.push(controller.getInsertAction(block, node.id)); - caretIndex = 0; - break; - case BlockType.DividerBlock: { - insertActions.push(controller.getInsertAction(block, node.id)); - - const nodeId = generateId(); - - caretId = nodeId; - caretIndex = 0; - insertActions.push( - ...deltaOperator.getNewTextLineActions({ - blockId: nodeId, - parentId: parent.id, - prevId: block.id || null, - delta: delta ? delta : new Delta([{ insert: '' }]), - type: BlockType.TextBlock, - data, - }) - ); - break; - } - - default: - caretId = generateId(); - - insertActions.push( - ...deltaOperator.getNewTextLineActions({ - blockId: caretId, - parentId: parent.id, - prevId: node.id, - delta, - type, - data, - }) - ); - break; - } - - if (!caretId) return; - // check if prev node is allowed to have children - const config = blockConfig[type]; - // if new block is not allowed to have children, move children to parent - const newParentId = config?.canAddChild ? caretId : parent.id; - // if move children to parent, set prev to current block, otherwise the prev is empty - const newPrev = config?.canAddChild ? null : caretId; - const moveChildrenActions = controller.getMoveChildrenAction(children, newParentId, newPrev); - - // delete current block - const deleteAction = controller.getDeleteAction(node); - - // submit actions - await controller.applyActions([...insertActions, ...moveChildrenActions, deleteAction]); - await dispatch( - setCursorRangeThunk({ - docId, - blockId: caretId, - index: caretIndex, - length: 0, - }) - ); - dispatch( - blockEditActions.setBlockEditState({ - id: docId, - state: { - id: caretId, - editing: true, - }, - }) - ); - return caretId; - } -); - -/** - * 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 } = thunkAPI; - - await dispatch( - turnToBlockThunk({ - id, - controller, - type: BlockType.TextBlock, - data: {}, - }) - ); - } -); diff --git a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/block_edit_slice.ts b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/block_edit_slice.ts deleted file mode 100644 index 3aa80bfd36..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/block_edit_slice.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { createSlice, PayloadAction } from '@reduxjs/toolkit'; -import { BLOCK_EDIT_NAME } from '$app/constants/document/name'; - -interface BlockEditState { - id: string; - editing: boolean; -} - -const initialState: Record<string, BlockEditState> = {}; - -export const blockEditSlice = createSlice({ - name: BLOCK_EDIT_NAME, - initialState, - reducers: { - setBlockEditState: (state, action: PayloadAction<{ id: string; state: BlockEditState }>) => { - const { id, state: blockEditState } = action.payload; - - state[id] = blockEditState; - }, - initBlockEditState: (state, action: PayloadAction<string>) => { - const docId = action.payload; - - state[docId] = { - ...state[docId], - editing: false, - }; - }, - }, -}); - -export const blockEditActions = blockEditSlice.actions; diff --git a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/mention_slice.ts b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/mention_slice.ts deleted file mode 100644 index d27bb87855..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/mention_slice.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { MENTION_NAME } from '$app/constants/document/name'; -import { createSlice } from '@reduxjs/toolkit'; - -export interface MentionState { - open: boolean; - blockId: string; -} -const initialState: Record<string, MentionState> = {}; - -export const mentionSlice = createSlice({ - name: MENTION_NAME, - initialState, - reducers: { - open: ( - state, - action: { - payload: { - docId: string; - blockId: string; - }; - } - ) => { - const { docId, blockId } = action.payload; - - state[docId] = { - open: true, - blockId, - }; - }, - close: (state, action: { payload: { docId: string } }) => { - const { docId } = action.payload; - - delete state[docId]; - }, - }, -}); - -export const mentionActions = mentionSlice.actions; 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 deleted file mode 100644 index ba7c1a2d26..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/slice.ts +++ /dev/null @@ -1,406 +0,0 @@ -import { - DocumentState, - Node, - RectSelectionState, - SlashCommandState, - RangeState, - RangeStatic, - SlashCommandOption, -} from '@/appflowy_app/interfaces/document'; -import { BlockEventPayloadPB } from '@/services/backend'; -import { createSlice, PayloadAction } from '@reduxjs/toolkit'; -import { parseValue, matchChange } from '$app/utils/document/subscribe'; -import { temporarySlice } from '$app_reducers/document/temporary_slice'; -import { DOCUMENT_NAME, RANGE_NAME, RECT_RANGE_NAME, SLASH_COMMAND_NAME } from '$app/constants/document/name'; -import { blockEditSlice } from '$app_reducers/document/block_edit_slice'; -import { Op } from 'quill-delta'; -import { mentionSlice } from '$app_reducers/document/mention_slice'; -import { generateId } from '$app/utils/document/block'; - -const initialState: Record<string, DocumentState> = {}; - -const rectSelectionInitialState: Record<string, RectSelectionState> = {}; - -const rangeInitialState: Record<string, RangeState> = {}; - -const slashCommandInitialState: Record<string, SlashCommandState> = {}; - -export const documentSlice = createSlice({ - name: DOCUMENT_NAME, - initialState: initialState, - // Here we can't offer actions to update the document state. - // Because the document state is updated by the `onDataChange` - reducers: { - // initialize the document - initialState: (state, action: PayloadAction<string>) => { - const docId = action.payload; - - state[docId] = { - nodes: {}, - children: {}, - deltaMap: {}, - }; - }, - clear: (state, action: PayloadAction<string>) => { - const docId = action.payload; - - delete state[docId]; - }, - - // set document data - create: ( - state, - action: PayloadAction<{ - docId: string; - nodes: Record<string, Node>; - children: Record<string, string[]>; - deltaMap: Record<string, string>; - }> - ) => { - const { docId, nodes, children, deltaMap } = action.payload; - - state[docId] = { - nodes, - children, - deltaMap, - }; - }, - - updateRootNodeDelta: ( - state, - action: PayloadAction<{ - docId: string; - rootId: string; - delta: Op[]; - }> - ) => { - const { docId, delta, rootId } = action.payload; - const documentState = state[docId]; - - if (!documentState) return; - const rootNode = documentState.nodes[rootId]; - - if (!rootNode) return; - let externalId = rootNode.externalId; - - if (!externalId) externalId = generateId(); - rootNode.externalId = externalId; - documentState.deltaMap[externalId] = JSON.stringify(delta); - }, - /** - This function listens for changes in the data layer triggered by the data API, - and updates the UI state accordingly. - It enables a unidirectional data flow, - where changes in the data layer update the UI layer, - but not the other way around. - */ - onDataChange: ( - state, - action: PayloadAction<{ - docId: string; - data: BlockEventPayloadPB; - isRemote: boolean; - }> - ) => { - const { docId, data } = action.payload; - const { path, id, value, command } = data; - - const documentState = state[docId]; - - if (!documentState) return; - const valueJson = parseValue(value); - - if (!valueJson) return; - - // match change - matchChange(documentState, { path, id, value: valueJson, command }); - }, - }, -}); - -export const rectSelectionSlice = createSlice({ - name: RECT_RANGE_NAME, - initialState: rectSelectionInitialState, - reducers: { - initialState: (state, action: PayloadAction<string>) => { - const docId = action.payload; - - state[docId] = { - selection: [], - isDragging: false, - }; - }, - clear: (state, action: PayloadAction<string>) => { - const docId = action.payload; - - delete state[docId]; - }, - // update block selections - updateSelections: ( - state, - action: PayloadAction<{ - docId: string; - selection: string[]; - }> - ) => { - const { docId, selection } = action.payload; - - state[docId].selection = selection; - }, - - setDragging: ( - state, - action: PayloadAction<{ - docId: string; - isDragging: boolean; - }> - ) => { - const { docId, isDragging } = action.payload; - - state[docId].isDragging = isDragging; - }, - }, -}); - -export const rangeSlice = createSlice({ - name: RANGE_NAME, - initialState: rangeInitialState, - reducers: { - initialState: (state, action: PayloadAction<string>) => { - const docId = action.payload; - - state[docId] = { - isDragging: false, - ranges: {}, - }; - }, - clear: (state, action: PayloadAction<string>) => { - const docId = action.payload; - - delete state[docId]; - }, - setRanges: ( - state, - action: PayloadAction<{ - docId: string; - ranges: RangeState['ranges']; - }> - ) => { - const { docId, ranges } = action.payload; - - state[docId].ranges = ranges; - }, - setRange: ( - state, - action: PayloadAction<{ - docId: string; - id: string; - rangeStatic: { - index: number; - length: number; - }; - }> - ) => { - const { docId, id, rangeStatic } = action.payload; - - state[docId].ranges[id] = rangeStatic; - }, - removeRange: ( - state, - action: PayloadAction<{ - docId: string; - id: string; - }> - ) => { - const { docId, id } = action.payload; - const ranges = state[docId].ranges; - - delete ranges[id]; - }, - setAnchorPoint: ( - state, - action: PayloadAction<{ - docId: string; - anchorPoint?: { - id: string; - point: { x: number; y: number }; - }; - }> - ) => { - const { docId, anchorPoint } = action.payload; - - if (anchorPoint) { - state[docId].anchor = { ...anchorPoint }; - } else { - delete state[docId].anchor; - } - }, - setAnchorPointRange: ( - state, - action: PayloadAction<{ - docId: string; - index: number; - length: number; - }> - ) => { - const { docId, index, length } = action.payload; - const anchor = state[docId].anchor; - - if (!anchor) return; - anchor.point = { - ...anchor.point, - index, - length, - }; - }, - setFocusPoint: ( - state, - action: PayloadAction<{ - docId: string; - focusPoint?: { - id: string; - point: { x: number; y: number }; - }; - }> - ) => { - const { docId, focusPoint } = action.payload; - - if (focusPoint) { - state[docId].focus = { ...focusPoint }; - } else { - delete state[docId].focus; - } - }, - - setDragging: ( - state, - action: PayloadAction<{ - docId: string; - isDragging: boolean; - }> - ) => { - const { docId, isDragging } = action.payload; - - state[docId].isDragging = isDragging; - }, - setCaret: ( - state, - action: PayloadAction<{ - docId: string; - caret: RangeStatic | null; - }> - ) => { - const { docId, caret } = action.payload; - const rangeState = state[docId]; - - if (!caret) { - rangeState.caret = undefined; - return; - } - - const { id, index, length } = caret; - - rangeState.ranges[id] = { - index, - length, - }; - rangeState.caret = caret; - }, - clearRanges: ( - state, - action: PayloadAction<{ - docId: string; - exclude?: string; - }> - ) => { - const { docId, exclude } = action.payload; - const ranges = state[docId].ranges; - - if (!exclude) { - state[docId].ranges = {}; - return; - } - - const newRanges = Object.keys(ranges).reduce((acc, id) => { - if (id !== exclude) return { ...acc }; - return { - ...acc, - [id]: ranges[id], - }; - }, {}); - - state[docId].ranges = newRanges; - }, - }, -}); - -export const slashCommandSlice = createSlice({ - name: SLASH_COMMAND_NAME, - initialState: slashCommandInitialState, - reducers: { - initialState: (state, action: PayloadAction<string>) => { - const docId = action.payload; - - state[docId] = { - isSlashCommand: false, - }; - }, - clear: (state, action: PayloadAction<string>) => { - const docId = action.payload; - - delete state[docId]; - }, - openSlashCommand: ( - state, - action: PayloadAction<{ - docId: string; - blockId: string; - }> - ) => { - const { blockId, docId } = action.payload; - - state[docId] = { - ...state[docId], - isSlashCommand: true, - blockId, - }; - }, - closeSlashCommand: (state, action: PayloadAction<string>) => { - const docId = action.payload; - - state[docId] = { - ...state[docId], - isSlashCommand: false, - }; - }, - setHoverOption: ( - state, - action: PayloadAction<{ - docId: string; - option: SlashCommandOption; - }> - ) => { - const { docId, option } = action.payload; - - state[docId] = { - ...state[docId], - hoverOption: option, - }; - }, - }, -}); - -export const documentReducers = { - [documentSlice.name]: documentSlice.reducer, - [rectSelectionSlice.name]: rectSelectionSlice.reducer, - [rangeSlice.name]: rangeSlice.reducer, - [slashCommandSlice.name]: slashCommandSlice.reducer, - [temporarySlice.name]: temporarySlice.reducer, - [blockEditSlice.name]: blockEditSlice.reducer, - [mentionSlice.name]: mentionSlice.reducer, -}; - -export const documentActions = documentSlice.actions; -export const rectSelectionActions = rectSelectionSlice.actions; -export const rangeActions = rangeSlice.actions; -export const slashCommandActions = slashCommandSlice.actions; diff --git a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/temporary_slice.ts b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/temporary_slice.ts deleted file mode 100644 index 7ace97d7bc..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/temporary_slice.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { TemporaryState } from '$app/interfaces/document'; -import { createSlice, PayloadAction } from '@reduxjs/toolkit'; -import { TEMPORARY_NAME } from '$app/constants/document/name'; - -const initialState: Record<string, TemporaryState> = {}; - -export const temporarySlice = createSlice({ - name: TEMPORARY_NAME, - initialState, - reducers: { - setTemporaryState: (state, action: PayloadAction<{ id: string; state: TemporaryState }>) => { - const { id, state: temporaryState } = action.payload; - - state[id] = temporaryState; - }, - updateTemporaryState: (state, action: PayloadAction<{ id: string; state: Partial<TemporaryState> }>) => { - const { id, state: temporaryState } = action.payload; - - if (!state[id]) { - return; - } - - if (temporaryState.id !== state[id].id) { - return; - } - - state[id] = { ...state[id], ...temporaryState }; - }, - deleteTemporaryState: (state, action: PayloadAction<string>) => { - const id = action.payload; - - delete state[id]; - }, - }, -}); - -export const temporaryActions = temporarySlice.actions; diff --git a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/pages/async_actions.ts b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/pages/async_actions.ts index 901a7a65eb..55edf36545 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/pages/async_actions.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/pages/async_actions.ts @@ -1,8 +1,7 @@ import { createAsyncThunk } from '@reduxjs/toolkit'; import { RootState } from '$app/stores/store'; -import { DragInsertType } from '$app_reducers/block-draggable/slice'; import { PageController } from '$app/stores/effects/workspace/page/page_controller'; -import { PageIcon } from '$app_reducers/pages/slice'; +import { PageIcon, pagesActions } from '$app_reducers/pages/slice'; export const movePageThunk = createAsyncThunk( 'pages/movePage', @@ -10,7 +9,7 @@ export const movePageThunk = createAsyncThunk( payload: { sourceId: string; targetId: string; - insertType: DragInsertType; + insertType: 'before' | 'after' | 'inside'; }, thunkAPI ) => { @@ -33,14 +32,14 @@ export const movePageThunk = createAsyncThunk( let prevId, parentId; - if (insertType === DragInsertType.BEFORE) { + if (insertType === 'before') { const prevIndex = targetIndex - 1; parentId = targetParentId; if (prevIndex >= 0) { prevId = targetParentChildren[prevIndex]; } - } else if (insertType === DragInsertType.AFTER) { + } else if (insertType === 'after') { prevId = targetId; parentId = targetParentId; } else { @@ -58,14 +57,27 @@ export const movePageThunk = createAsyncThunk( } ); -export const updatePageName = createAsyncThunk('pages/updateName', async (payload: { id: string; name: string }) => { - const controller = new PageController(payload.id); +export const updatePageName = createAsyncThunk( + 'pages/updateName', + async (payload: { id: string; name: string }, thunkAPI) => { + const controller = new PageController(payload.id); + const { dispatch, getState } = thunkAPI; + const { pageMap } = (getState() as RootState).pages; + const { id, name } = payload; + const page = pageMap[id]; - await controller.updatePage({ - id: payload.id, - name: payload.name, - }); -}); + dispatch( + pagesActions.onPageChanged({ + ...page, + name, + }) + ); + await controller.updatePage({ + id: payload.id, + name: payload.name, + }); + } +); export const updatePageIcon = createAsyncThunk('pages/updateIcon', async (payload: { id: string; icon?: PageIcon }) => { const controller = new PageController(payload.id); diff --git a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/pages/slice.ts b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/pages/slice.ts index 5d7d63e003..44be51b534 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/pages/slice.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/pages/slice.ts @@ -1,6 +1,12 @@ import { ViewIconTypePB, ViewLayoutPB, ViewPB } from '@/services/backend'; import { createSlice, PayloadAction } from '@reduxjs/toolkit'; +export const pageTypeMap = { + [ViewLayoutPB.Document]: 'document', + [ViewLayoutPB.Board]: 'board', + [ViewLayoutPB.Grid]: 'grid', + [ViewLayoutPB.Calendar]: 'calendar', +}; export interface Page { id: string; parentId: string; @@ -90,7 +96,7 @@ export const pagesSlice = createSlice({ const id = action.payload; state.expandedIdMap[id] = true; - const ids = Object.keys(state.expandedIdMap).filter(id => state.expandedIdMap[id]); + const ids = Object.keys(state.expandedIdMap).filter((id) => state.expandedIdMap[id]); storeExpandedPageIds(ids); }, @@ -99,7 +105,7 @@ export const pagesSlice = createSlice({ const id = action.payload; state.expandedIdMap[id] = false; - const ids = Object.keys(state.expandedIdMap).filter(id => state.expandedIdMap[id]); + const ids = Object.keys(state.expandedIdMap).filter((id) => state.expandedIdMap[id]); storeExpandedPageIds(ids); }, @@ -116,4 +122,4 @@ function getExpandedPageIds(): string[] { const expandedPageIds = localStorage.getItem('expandedPageIds'); return expandedPageIds ? JSON.parse(expandedPageIds) : []; -} \ No newline at end of file +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/stores/store.ts b/frontend/appflowy_tauri/src/appflowy_app/stores/store.ts index 1c8660c7d6..6ee441ad33 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/stores/store.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/stores/store.ts @@ -11,11 +11,9 @@ import { pagesSlice } from './reducers/pages/slice'; import { currentUserSlice } from './reducers/current-user/slice'; import { workspaceSlice } from './reducers/workspace/slice'; import { databaseSlice } from './reducers/database/slice'; -import { documentReducers } from './reducers/document/slice'; import { boardSlice } from './reducers/board/slice'; import { errorSlice } from './reducers/error/slice'; import { sidebarSlice } from '$app_reducers/sidebar/slice'; -import { blockDraggableSlice } from '$app_reducers/block-draggable/slice'; import { trashSlice } from '$app_reducers/trash/slice'; const listenerMiddlewareInstance = createListenerMiddleware({ @@ -31,9 +29,7 @@ const store = configureStore({ [workspaceSlice.name]: workspaceSlice.reducer, [errorSlice.name]: errorSlice.reducer, [sidebarSlice.name]: sidebarSlice.reducer, - [blockDraggableSlice.name]: blockDraggableSlice.reducer, [trashSlice.name]: trashSlice.reducer, - ...documentReducers, }, middleware: (gDM) => gDM({ serializableCheck: false }).prepend(listenerMiddlewareInstance.middleware), }); diff --git a/frontend/appflowy_tauri/src/appflowy_app/utils/__tests__/block_delta.test.ts b/frontend/appflowy_tauri/src/appflowy_app/utils/__tests__/block_delta.test.ts deleted file mode 100644 index c16bb327c3..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/utils/__tests__/block_delta.test.ts +++ /dev/null @@ -1,277 +0,0 @@ -import { BlockDeltaOperator } from '$app/utils/document/block_delta'; -import { mockDocument } from './document_state'; -import { DocumentController } from '$app/stores/effects/document/document_controller'; -import { generateId } from '$app/utils/document/block'; - -jest.mock('nanoid', () => ({ nanoid: jest.fn().mockReturnValue(String(Math.random())) })); - -jest.mock('$app/utils/document/emoji', () => ({ - randomEmoji: jest.fn().mockReturnValue('👍'), -})); - -jest.mock('$app/stores/effects/document/document_observer', () => ({ - DocumentObserver: jest.fn().mockImplementation(() => ({ - subscribe: jest.fn().mockReturnValue(Promise.resolve()), - })), -})); - -jest.mock('$app/stores/effects/document/document_bd_svc', () => ({ - DocumentBackendService: jest.fn().mockImplementation(() => ({ - open: jest.fn().mockReturnValue(Promise.resolve({ ok: true, val: mockDocument })), - applyActions: jest.fn().mockReturnValue(Promise.resolve({ ok: true })), - createText: jest.fn().mockReturnValue(Promise.resolve({ ok: true })), - applyTextDelta: jest.fn().mockReturnValue(Promise.resolve({ ok: true })), - close: jest.fn().mockReturnValue(Promise.resolve({ ok: true })), - canUndoRedo: jest.fn().mockReturnValue(Promise.resolve({ ok: true })), - undo: jest.fn().mockReturnValue(Promise.resolve({ ok: true })), - })), -})); - -describe('Test BlockDeltaOperator', () => { - let operator: BlockDeltaOperator; - let controller: DocumentController; - beforeEach(() => { - controller = new DocumentController(generateId()); - operator = new BlockDeltaOperator(mockDocument, controller); - }); - test('get block', () => { - const block = operator.getBlock('1'); - expect(block).toEqual(undefined); - - const blockId = Object.keys(mockDocument.nodes)[0]; - const block2 = operator.getBlock(blockId); - expect(block2).toEqual(mockDocument.nodes[blockId]); - }); - - test('get delta with block id', () => { - const blockId = 'gtYcSzwLYw'; - const delta = operator.getDeltaWithBlockId(blockId); - expect(delta).toBeTruthy(); - const deltaStr = JSON.stringify(delta!.ops); - const externalId = mockDocument.nodes[blockId].externalId; - expect(externalId).toBeTruthy(); - expect(deltaStr).toEqual(mockDocument.deltaMap[externalId!]); - }); - - test('get delta text', () => { - const blockId = 'gtYcSzwLYw'; - const delta = operator.getDeltaWithBlockId(blockId); - expect(delta).toBeTruthy(); - const text = operator.getDeltaText(delta!); - expect(text).toEqual('Welcome to AppFlowy!'); - }); - - test('get split delta', () => { - const blockId = 'gtYcSzwLYw'; - const splitDeltaResult = operator.getSplitDelta(blockId, 7, 4); - expect(splitDeltaResult).toBeTruthy(); - const { updateDelta, diff, insertDelta } = splitDeltaResult!; - expect(updateDelta).toBeTruthy(); - expect(diff).toBeTruthy(); - expect(insertDelta).toBeTruthy(); - expect(updateDelta.ops).toEqual([{ insert: 'Welcome' }]); - expect(diff.ops).toEqual([{ retain: 7 }, { delete: 13 }]); - expect(insertDelta.ops).toEqual([{ insert: 'AppFlowy!' }]); - - const blockId1 = 'wh475aelU_'; - const splitDeltaResult1 = operator.getSplitDelta(blockId1, 14, 0); - expect(splitDeltaResult1).toBeTruthy(); - const { updateDelta: updateDelta1, diff: diff1, insertDelta: insertDelta1 } = splitDeltaResult1!; - expect(updateDelta1).toBeTruthy(); - expect(diff1).toBeTruthy(); - expect(insertDelta1).toBeTruthy(); - expect(updateDelta1.ops).toEqual([ - { insert: 'Markdown ' }, - { insert: 'refer', attributes: { href: 'https://appflowy.gitbook.io/docs/essential-documentation/markdown' } }, - ]); - expect(diff1.ops).toEqual([{ retain: 14 }, { delete: 4 }]); - expect(insertDelta1.ops).toEqual([ - { insert: 'ence', attributes: { href: 'https://appflowy.gitbook.io/docs/essential-documentation/markdown' } }, - ]); - }); - - test('split a line text', async () => { - const startId = 'gtYcSzwLYw'; - const endId = 'gtYcSzwLYw'; - const index = 7; - await operator.splitText( - { - id: startId, - index, - }, - { - id: endId, - index, - } - ); - const backendService = controller.backend; - expect(backendService.applyActions).toBeCalledTimes(1); - // @ts-ignore - const actions = backendService.applyActions.mock.calls[0][0]; - expect(actions).toBeTruthy(); - expect(actions.length).toEqual(3); - expect(actions[0].action).toEqual(5); - expect(actions[0].payload).toEqual({ - delta: '[{"retain":7},{"delete":13}]', - text_id: 'KbkL-wXQrN', - }); - expect(actions[1].action).toEqual(4); - expect(actions[1].payload).toHaveProperty('text_id'); - expect(actions[1].payload).toHaveProperty('delta'); - expect(actions[1].payload.delta).toEqual('[{"insert":" to AppFlowy!"}]'); - expect(actions[1].payload.text_id).toEqual(actions[2].payload.block.external_id); - expect(actions[2].action).toEqual(0); - expect(actions[2].payload).toHaveProperty('block'); - expect(actions[2].payload.block.parent_id).toEqual('ifF_PvQeOu'); - expect(actions[2].payload.block.ty).toEqual('paragraph'); - expect(actions[2].payload.block).toHaveProperty('external_id'); - expect(actions[2].payload.block.external_id).toBeTruthy(); - expect(actions[2].payload.parent_id).toEqual('ifF_PvQeOu'); - expect(actions[2].payload.prev_id).toEqual('gtYcSzwLYw'); - }); - - test('split multi line text', async () => { - const startId = 'pYV_AGVqEE'; - const endId = 'eqf0luv-Fy'; - const startIndex = 8; - const endIndex = 5; - await operator.splitText( - { - id: startId, - index: startIndex, - }, - { - id: endId, - index: endIndex, - } - ); - const backendService = controller.backend; - expect(backendService.applyActions).toBeCalledTimes(1); - // @ts-ignore - const actions = backendService.applyActions.mock.calls[0][0]; - expect(actions).toBeTruthy(); - expect(actions.length).toEqual(6); - expect(actions[0].action).toEqual(5); - expect(actions[0].payload.text_id).toEqual('F3zvDsXHha'); - expect(actions[0].payload.delta).toEqual('[{"retain":8},{"delete":87}]'); - expect(actions[1].action).toEqual(2); - expect(actions[1].payload.parent_id).toEqual('ifF_PvQeOu'); - expect(actions[1].payload.prev_id).toEqual(''); - expect(actions[2].action).toEqual(2); - expect(actions[2].payload.parent_id).toEqual('ifF_PvQeOu'); - expect(actions[2].payload.prev_id).toEqual(''); - expect(actions[3].action).toEqual(4); - expect(actions[3].payload.text_id).toEqual(actions[4].payload.block.external_id); - expect(actions[3].payload.delta).toEqual( - '[{"insert":" "},{"attributes":{"code":true},"insert":"+"},{"insert":" next to any page title in the sidebar to "},{"attributes":{"font_color":"0xff8427e0"},"insert":"quickly"},{"insert":" add a new subpage, "},{"attributes":{"code":true},"insert":"Document"},{"attributes":{"code":false},"insert":", "},{"attributes":{"code":true},"insert":"Grid"},{"attributes":{"code":false},"insert":", or "},{"attributes":{"code":true},"insert":"Kanban Board"},{"attributes":{"code":false},"insert":"."}]' - ); - expect(actions[4].action).toEqual(0); - expect(actions[4].payload.parent_id).toEqual('ifF_PvQeOu'); - expect(actions[4].payload.prev_id).toEqual('pYV_AGVqEE'); - expect(actions[5].action).toEqual(2); - expect(actions[5].payload.parent_id).toEqual('ifF_PvQeOu'); - expect(actions[5].payload.prev_id).toEqual(''); - }); - - test('delete a line text', async () => { - const startId = 'gtYcSzwLYw'; - const endId = 'gtYcSzwLYw'; - await operator.deleteText( - { - id: startId, - index: 7, - }, - { - id: endId, - index: 8, - } - ); - const backendService = controller.backend; - expect(backendService.applyActions).toBeCalledTimes(1); - // @ts-ignore - const actions = backendService.applyActions.mock.calls[0][0]; - expect(actions).toBeTruthy(); - expect(actions.length).toEqual(1); - expect(actions[0].action).toEqual(5); - expect(actions[0].payload).toEqual({ - delta: '[{"retain":7},{"delete":1}]', - text_id: 'KbkL-wXQrN', - }); - }); - - test('delete multi line text', async () => { - const startId = 'pYV_AGVqEE'; - const endId = 'eqf0luv-Fy'; - const startIndex = 8; - const endIndex = 5; - await operator.splitText( - { - id: startId, - index: startIndex, - }, - { - id: endId, - index: endIndex, - } - ); - const backendService = controller.backend; - expect(backendService.applyActions).toBeCalledTimes(1); - // @ts-ignore - const actions = backendService.applyActions.mock.calls[0][0]; - expect(actions).toBeTruthy(); - expect(actions.length).toEqual(6); - expect(actions[0].action).toEqual(5); - expect(actions[0].payload.text_id).toEqual('F3zvDsXHha'); - expect(actions[0].payload.delta).toEqual('[{"retain":8},{"delete":87}]'); - expect(actions[1].action).toEqual(2); - expect(actions[1].payload.parent_id).toEqual('ifF_PvQeOu'); - expect(actions[1].payload.prev_id).toEqual(''); - expect(actions[2].action).toEqual(2); - expect(actions[2].payload.parent_id).toEqual('ifF_PvQeOu'); - expect(actions[2].payload.prev_id).toEqual(''); - expect(actions[3].action).toEqual(4); - expect(actions[3].payload.delta).toEqual( - '[{"insert":" "},{"attributes":{"code":true},"insert":"+"},{"insert":" next to any page title in the sidebar to "},{"attributes":{"font_color":"0xff8427e0"},"insert":"quickly"},{"insert":" add a new subpage, "},{"attributes":{"code":true},"insert":"Document"},{"attributes":{"code":false},"insert":", "},{"attributes":{"code":true},"insert":"Grid"},{"attributes":{"code":false},"insert":", or "},{"attributes":{"code":true},"insert":"Kanban Board"},{"attributes":{"code":false},"insert":"."}]' - ); - expect(actions[3].payload.text_id).toEqual(actions[4].payload.block.external_id); - expect(actions[4].action).toEqual(0); - expect(actions[4].payload.parent_id).toEqual('ifF_PvQeOu'); - expect(actions[4].payload.prev_id).toEqual('pYV_AGVqEE'); - expect(actions[5].action).toEqual(2); - expect(actions[5].payload.parent_id).toEqual('ifF_PvQeOu'); - expect(actions[5].payload.prev_id).toEqual(''); - }); - - test('merge two line text', async () => { - const startId = 'gtYcSzwLYw'; - const endId = 'YsJ-DVO-sC'; - await operator.mergeText(startId, endId); - const backendService = controller.backend; - expect(backendService.applyActions).toBeCalledTimes(1); - // @ts-ignore - const actions = backendService.applyActions.mock.calls[0][0]; - expect(actions).toBeTruthy(); - expect(actions.length).toEqual(2); - expect(actions[0].action).toEqual(5); - expect(actions[0].payload).toEqual({ - delta: '[{"retain":20},{"insert":"Here are the basics"}]', - text_id: 'KbkL-wXQrN', - }); - expect(actions[1].action).toEqual(2); - expect(actions[1].payload).toEqual({ - block: { - id: 'YsJ-DVO-sC', - ty: 'heading', - parent_id: 'ifF_PvQeOu', - children_id: 'PM5MctaruD', - data: '{"level":2}', - external_id: 'QHPzz4O1mV', - external_type: 'text', - }, - parent_id: 'ifF_PvQeOu', - prev_id: '', - }); - }); -}); - -export {}; diff --git a/frontend/appflowy_tauri/src/appflowy_app/utils/__tests__/document_state.ts b/frontend/appflowy_tauri/src/appflowy_app/utils/__tests__/document_state.ts deleted file mode 100644 index 7bf3ac39a4..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/utils/__tests__/document_state.ts +++ /dev/null @@ -1,322 +0,0 @@ -import { DocumentState } from '$app/interfaces/document'; - -export const mockDocument = { - nodes: { - wh475aelU_: { - id: 'wh475aelU_', - type: 'numbered_list', - parent: 'ifF_PvQeOu', - children: 'VcfuvGuodm', - data: {}, - externalId: 'sUF-3L5JHd', - externalType: 'text', - }, - pYV_AGVqEE: { - id: 'pYV_AGVqEE', - type: 'todo_list', - parent: 'ifF_PvQeOu', - children: 'e6ByZ0nZk9', - data: { checked: false }, - externalId: 'F3zvDsXHha', - externalType: 'text', - }, - '0whp025621': { - id: '0whp025621', - type: 'callout', - parent: 'ifF_PvQeOu', - children: 'b5ypKcGf5_', - data: { bgColor: '#F0F0F0', icon: '🥰' }, - externalId: 'P_ODpxtY-S', - externalType: 'text', - }, - d4Qo2OFOpX: { - id: 'd4Qo2OFOpX', - type: 'paragraph', - parent: 'ifF_PvQeOu', - children: '2lNOUVOJJ5', - data: {}, - externalId: 'QT_VkSHge-', - externalType: 'text', - }, - tLi0Tg4dBc: { - id: 'tLi0Tg4dBc', - type: 'paragraph', - parent: 'ifF_PvQeOu', - children: 'rgDc-GrgOa', - data: {}, - externalId: '7FQuBVPxeZ', - externalType: 'text', - }, - '-sili1kmaR': { - id: '-sili1kmaR', - type: 'todo_list', - parent: 'ifF_PvQeOu', - children: 'mAxPJngROh', - data: { checked: false }, - externalId: 'VGLCGgx_rk', - externalType: 'text', - }, - '5I64JF3Hzw': { - id: '5I64JF3Hzw', - type: 'numbered_list', - parent: 'ifF_PvQeOu', - children: 'TzJU1gv2PE', - data: {}, - externalId: 'zYOHSlXpWE', - externalType: 'text', - }, - 'eqf0luv-Fy': { - id: 'eqf0luv-Fy', - type: 'todo_list', - parent: 'ifF_PvQeOu', - children: 'oxKR2cHeeH', - data: { - checked: false, - }, - externalId: '6BnmM6ZkJV', - externalType: 'text', - }, - ZMPoVs7lC4: { - id: 'ZMPoVs7lC4', - type: 'numbered_list', - parent: 'ifF_PvQeOu', - children: 'jwB_QmOn21', - data: {}, - externalId: 'qIDnwwdSQF', - externalType: 'text', - }, - 'PM-4wkNVlu': { - id: 'PM-4wkNVlu', - type: 'paragraph', - parent: 'ifF_PvQeOu', - children: 'HdHqxm7-e-', - data: {}, - externalId: 'lPI1KU7usc', - externalType: 'text', - }, - '5qS3sKv9C2': { - id: '5qS3sKv9C2', - type: 'heading', - parent: 'ifF_PvQeOu', - children: 'LaCFrFbNeA', - data: { level: 2 }, - externalId: 'fy82xqO08a', - externalType: 'text', - }, - tEGSjQM2LP: { - id: 'tEGSjQM2LP', - type: 'todo_list', - parent: 'ifF_PvQeOu', - children: 'G_zBND8YZl', - data: { checked: true }, - externalId: 'xWJGGIB-fp', - externalType: 'text', - }, - IteP77UNrr: { - id: 'IteP77UNrr', - type: 'divider', - parent: 'ifF_PvQeOu', - children: '8ZAdHr4H4J', - data: {}, - externalId: '', - externalType: '', - }, - vMc1WwxjJu: { - id: 'vMc1WwxjJu', - type: 'quote', - parent: 'ifF_PvQeOu', - children: 'zWkL_b8_Mi', - data: {}, - externalId: 'oOxRotTYg2', - externalType: 'text', - }, - gtYcSzwLYw: { - id: 'gtYcSzwLYw', - type: 'heading', - parent: 'ifF_PvQeOu', - children: 'WhIA288H8O', - data: { level: 1 }, - externalId: 'KbkL-wXQrN', - externalType: 'text', - }, - jk7YrtfAgz: { - id: 'jk7YrtfAgz', - type: 'paragraph', - parent: 'ifF_PvQeOu', - children: 'KIO68twg3J', - data: {}, - externalId: 'b3BIaLzS_o', - externalType: 'text', - }, - jAl6GnPNB_: { - id: 'jAl6GnPNB_', - type: 'todo_list', - parent: 'ifF_PvQeOu', - children: 'HR3s1f_gpD', - data: { checked: false }, - externalId: 'qiW6xN-o5Q', - externalType: 'text', - }, - NFtEOGjXEm: { - id: 'NFtEOGjXEm', - type: 'paragraph', - parent: 'ifF_PvQeOu', - children: 'kDx1WbW6ni', - data: {}, - externalId: 'r19i_oNV3O', - externalType: 'text', - }, - '4f6_TWg8x5': { - id: '4f6_TWg8x5', - type: 'paragraph', - parent: 'ifF_PvQeOu', - children: 'RGXTAjco5O', - data: {}, - externalId: 'pf1dV9EJer', - externalType: 'text', - }, - xFhJgOxACc: { - id: 'xFhJgOxACc', - type: 'heading', - parent: 'ifF_PvQeOu', - children: 'CMqq7y9JTX', - data: { level: 2 }, - externalId: 'b3mbfhloLa', - externalType: 'text', - }, - 'kih-t9tRZr': { - id: 'kih-t9tRZr', - type: 'code', - parent: 'ifF_PvQeOu', - children: 'fnWMHsa5if', - data: { language: 'rust' }, - externalId: 'HBZkdYM6Ka', - externalType: 'text', - }, - ifF_PvQeOu: { - id: 'ifF_PvQeOu', - type: 'page', - parent: '', - children: '5_bawmri6x', - data: {}, - externalId: 'm_SX-ck0GL', - externalType: 'text', - }, - 'YsJ-DVO-sC': { - id: 'YsJ-DVO-sC', - type: 'heading', - parent: 'ifF_PvQeOu', - children: 'PM5MctaruD', - data: { level: 2 }, - externalId: 'QHPzz4O1mV', - externalType: 'text', - }, - JcIU0PjpyD: { - id: 'JcIU0PjpyD', - type: 'todo_list', - parent: 'ifF_PvQeOu', - children: 'xcYFnxMXai', - data: { checked: false }, - externalId: 'g4WQvF8doI', - externalType: 'text', - }, - Oi2cxSuUls: { - id: 'Oi2cxSuUls', - type: 'paragraph', - parent: 'ifF_PvQeOu', - children: 'NI4TCeq2Lv', - data: {}, - externalId: 'D27H4Hf9re', - externalType: 'text', - }, - }, - children: { - xcYFnxMXai: [], - '5_bawmri6x': [ - 'gtYcSzwLYw', - 'YsJ-DVO-sC', - 'jAl6GnPNB_', - '-sili1kmaR', - 'pYV_AGVqEE', - 'JcIU0PjpyD', - 'tEGSjQM2LP', - 'eqf0luv-Fy', - '4f6_TWg8x5', - 'IteP77UNrr', - 'PM-4wkNVlu', - '5qS3sKv9C2', - '5I64JF3Hzw', - 'wh475aelU_', - 'ZMPoVs7lC4', - 'kih-t9tRZr', - 'Oi2cxSuUls', - 'xFhJgOxACc', - 'vMc1WwxjJu', - 'd4Qo2OFOpX', - '0whp025621', - 'tLi0Tg4dBc', - 'jk7YrtfAgz', - 'NFtEOGjXEm', - ], - 'rgDc-GrgOa': [], - jwB_QmOn21: [], - b5ypKcGf5_: [], - LaCFrFbNeA: [], - '8ZAdHr4H4J': [], - 'HdHqxm7-e-': [], - G_zBND8YZl: [], - CMqq7y9JTX: [], - WhIA288H8O: [], - HR3s1f_gpD: [], - zWkL_b8_Mi: [], - KIO68twg3J: [], - oxKR2cHeeH: [], - fnWMHsa5if: [], - kDx1WbW6ni: [], - '2lNOUVOJJ5': [], - PM5MctaruD: [], - TzJU1gv2PE: [], - RGXTAjco5O: [], - e6ByZ0nZk9: [], - VcfuvGuodm: [], - mAxPJngROh: [], - NI4TCeq2Lv: [], - }, - deltaMap: { - VGLCGgx_rk: - '[{"insert":"Highlight ","attributes":{"bg_color":"0x4dffeb3b"}},{"insert":"any text, and use the editing menu to "},{"insert":"style","attributes":{"italic":true}},{"insert":" "},{"insert":"your","attributes":{"bold":true}},{"insert":" "},{"insert":"writing","attributes":{"underline":true}},{"insert":" "},{"insert":"however","attributes":{"code":true}},{"insert":" you "},{"insert":"like.","attributes":{"strikethrough":true}}]', - '6BnmM6ZkJV': - '[{"insert":"Click "},{"insert":"+","attributes":{"code":true}},{"insert":" next to any page title in the sidebar to "},{"insert":"quickly","attributes":{"font_color":"0xff8427e0"}},{"insert":" add a new subpage, "},{"insert":"Document","attributes":{"code":true}},{"insert":", ","attributes":{"code":false}},{"insert":"Grid","attributes":{"code":true}},{"insert":", or ","attributes":{"code":false}},{"insert":"Kanban Board","attributes":{"code":true}},{"insert":".","attributes":{"code":false}}]', - g4WQvF8doI: - '[{"insert":"Type "},{"insert":"/","attributes":{"code":true}},{"insert":" followed by "},{"insert":"/bullet","attributes":{"code":true}},{"insert":" or "},{"insert":"/num","attributes":{"code":true}},{"insert":" to create a list.","attributes":{"code":false}}]', - HBZkdYM6Ka: - '[{"insert":"// This is the main function.\\nfn main() {\\n // Print text to the console.\\n println!(\\"Hello World!\\");\\n}"}]', - 'qiW6xN-o5Q': '[{"insert":"Click anywhere and just start typing."}]', - 'KbkL-wXQrN': '[{"insert":"Welcome to AppFlowy!"}]', - lPI1KU7usc: '[]', - D27H4Hf9re: '[]', - oOxRotTYg2: - '[{"insert":"Click "},{"insert":"?","attributes":{"code":true}},{"insert":" at the bottom right for help and support."}]', - 'P_ODpxtY-S': - '[{"insert":"\\nLike AppFlowy? Follow us:\\n"},{"insert":"GitHub","attributes":{"href":"https://github.com/AppFlowy-IO/AppFlowy"}},{"insert":"\\n"},{"insert":"Twitter","attributes":{"href":"https://twitter.com/appflowy"}},{"insert":": @appflowy\\n"},{"insert":"Newsletter","attributes":{"href":"https://blog-appflowy.ghost.io/"}},{"insert":"\\n"}]', - F3zvDsXHha: - '[{"insert":"As soon as you type "},{"insert":"/","attributes":{"code":true,"font_color":"0xff00b5ff"}},{"insert":" a menu will pop up. Select "},{"insert":"different types","attributes":{"bg_color":"0x4d9c27b0"}},{"insert":" of content blocks you can add."}]', - fy82xqO08a: '[{"insert":"Keyboard shortcuts, markdown, and code block"}]', - 'sUF-3L5JHd': - '[{"insert":"Markdown "},{"insert":"reference","attributes":{"href":"https://appflowy.gitbook.io/docs/essential-documentation/markdown"}}]', - r19i_oNV3O: '[]', - 'm_SX-ck0GL': '[]', - b3mbfhloLa: '[{"insert":"Have a question❓"}]', - 'xWJGGIB-fp': - '[{"insert":"Click "},{"insert":"+ New Page ","attributes":{"code":true}},{"insert":"button at the bottom of your sidebar to add a new page."}]', - 'QT_VkSHge-': '[]', - zYOHSlXpWE: - '[{"insert":"Keyboard shortcuts "},{"insert":"guide","attributes":{"href":"https://appflowy.gitbook.io/docs/essential-documentation/shortcuts"}}]', - b3BIaLzS_o: '[]', - '7FQuBVPxeZ': '[]', - pf1dV9EJer: '[]', - QHPzz4O1mV: '[{"insert":"Here are the basics"}]', - qIDnwwdSQF: - '[{"insert":"Type "},{"insert":"/code","attributes":{"code":true}},{"insert":" to insert a code block","attributes":{"code":false}}]', - }, -} as DocumentState; diff --git a/frontend/appflowy_tauri/src/appflowy_app/utils/document/action.ts b/frontend/appflowy_tauri/src/appflowy_app/utils/document/action.ts deleted file mode 100644 index 3152246999..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/utils/document/action.ts +++ /dev/null @@ -1,186 +0,0 @@ -import { ControllerAction, DocumentState, RangeState, RangeStatic } from '$app/interfaces/document'; -import { getNextLineId, newBlock } from '$app/utils/document/block'; -import { DocumentController } from '$app/stores/effects/document/document_controller'; -import { - caretInBottomEdgeByDelta, - caretInTopEdgeByDelta, - getIndexRelativeEnter, - getLastLineIndex, - transformIndexToNextLine, - transformIndexToPrevLine, -} from '$app/utils/document/delta'; -import { BlockDeltaOperator } from '$app/utils/document/block_delta'; - -export function getMiddleIds(document: DocumentState, startId: string, endId: string) { - const middleIds = []; - let currentId: string | undefined = startId; - - while (currentId && currentId !== endId) { - const nextId = getNextLineId(document, currentId); - - if (nextId && nextId !== endId) { - middleIds.push(nextId); - } - - currentId = nextId; - } - - return middleIds; -} - -export function getStartAndEndIdsByRange(rangeState: RangeState) { - const { anchor, focus } = rangeState; - - if (!anchor || !focus) return []; - if (anchor.id === focus.id) return [anchor.id]; - const isForward = anchor.point.y < focus.point.y; - const startId = isForward ? anchor.id : focus.id; - const endId = isForward ? focus.id : anchor.id; - - return [startId, endId]; -} - -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 } = 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 deltaOperator = new BlockDeltaOperator(document); - const delta = deltaOperator.getDeltaWithBlockId(caret.id); - - if (!delta) return; - const inTopEdge = caretInTopEdgeByDelta(delta, caret.index); - - if (!inTopEdge) { - const index = transformIndexToPrevLine(delta, caret.index); - - return { - id: caret.id, - index, - length: 0, - }; - } - - const prevLineId = deltaOperator.findPrevTextLine(caret.id); - - if (!prevLineId) return; - const relativeIndex = getIndexRelativeEnter(delta, caret.index); - const prevLineDelta = deltaOperator.getDeltaWithBlockId(prevLineId); - - if (!prevLineDelta) return; - const prevLineIndex = getLastLineIndex(prevLineDelta); - const prevLineText = deltaOperator.getDeltaText(prevLineDelta); - const newPrevLineIndex = prevLineIndex + relativeIndex; - const prevLineLength = prevLineText.length; - const index = newPrevLineIndex > prevLineLength ? prevLineLength : newPrevLineIndex; - - return { - id: prevLineId, - index, - length: 0, - }; -} - -export function transformToNextLineCaret(document: DocumentState, caret: RangeStatic) { - const deltaOperator = new BlockDeltaOperator(document); - const delta = deltaOperator.getDeltaWithBlockId(caret.id); - - if (!delta) return; - const inBottomEdge = caretInBottomEdgeByDelta(delta, caret.index); - - if (!inBottomEdge) { - const index = transformIndexToNextLine(delta, caret.index); - - return { - id: caret.id, - index, - length: 0, - }; - return; - } - - const nextLineId = deltaOperator.findNextTextLine(caret.id); - - if (!nextLineId) return; - const nextLineDelta = deltaOperator.getDeltaWithBlockId(nextLineId); - - if (!nextLineDelta) return; - const nextLineText = deltaOperator.getDeltaText(nextLineDelta); - const relativeIndex = getIndexRelativeEnter(delta, caret.index); - const index = relativeIndex >= nextLineText.length ? nextLineText.length : relativeIndex; - - return { - id: nextLineId, - 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 deleted file mode 100644 index 9b231756a8..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/utils/document/block.ts +++ /dev/null @@ -1,112 +0,0 @@ -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, - externalId: block.external_id, - externalType: block.external_type, - }; - - 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]; - const firstChild = state.children[node.children][0]; - - if (firstChild) return firstChild; - - let nextNodeId = getNextNodeId(state, id); - - if (!node.parent) return; - 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>(type: BlockType, parentId: string, data?: BlockData<Type>): NestedBlock<Type> { - const blockData = data || ({} as BlockData<Type>); - - return { - id: generateId(), - type, - parent: parentId, - children: generateId(), - data: blockData, - }; -} diff --git a/frontend/appflowy_tauri/src/appflowy_app/utils/document/block_delta.ts b/frontend/appflowy_tauri/src/appflowy_app/utils/document/block_delta.ts deleted file mode 100644 index 4b10d9c9f6..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/utils/document/block_delta.ts +++ /dev/null @@ -1,453 +0,0 @@ -import { BlockData, BlockType, DocumentState, SplitRelationship } from '$app/interfaces/document'; -import { generateId, getNextLineId, getPrevLineId } from '$app/utils/document/block'; -import { DocumentController } from '$app/stores/effects/document/document_controller'; -import Delta, { Op } from 'quill-delta'; -import { blockConfig } from '$app/constants/document/config'; - -export class BlockDeltaOperator { - constructor( - private state: DocumentState, - private controller?: DocumentController, - private updatePageName?: (name: string) => Promise<void> - ) {} - - getBlock = (blockId: string) => { - return this.state.nodes[blockId]; - }; - - getExternalId = (blockId: string) => { - return this.getBlock(blockId)?.externalId; - }; - - getDeltaStrWithExternalId = (externalId: string) => { - return this.state.deltaMap[externalId]; - }; - - getDeltaWithExternalId = (externalId: string) => { - const deltaStr = this.getDeltaStrWithExternalId(externalId); - - if (!deltaStr) return; - return new Delta(JSON.parse(deltaStr)); - }; - - getDeltaWithBlockId = (blockId: string) => { - const externalId = this.getExternalId(blockId); - - if (!externalId) return; - return this.getDeltaWithExternalId(externalId); - }; - - hasDelta = (blockId: string) => { - const externalId = this.getExternalId(blockId); - - if (!externalId) return false; - return !!this.getDeltaStrWithExternalId(externalId); - }; - - getDeltaText = (delta: Delta) => { - return delta.ops.map((op) => op.insert).join(''); - }; - - sliceDeltaWithBlockId = (blockId: string, startIndex: number, endIndex?: number) => { - const delta = this.getDeltaWithBlockId(blockId); - - return delta?.slice(startIndex, endIndex); - }; - - getSplitDelta = (blockId: string, index: number, length: number) => { - const externalId = this.getExternalId(blockId); - - if (!externalId) return; - const delta = this.getDeltaWithExternalId(externalId); - - if (!delta) return; - const diff = new Delta().retain(index).delete(delta.length() - index); - const updateDelta = delta.slice(0, index); - const insertDelta = delta.slice(index + length); - - return { - diff, - updateDelta, - insertDelta, - }; - }; - - getApplyDeltaAction = (blockId: string, delta: Delta) => { - const block = this.getBlock(blockId); - const deltaStr = JSON.stringify(delta.ops); - - return this.controller?.getApplyTextDeltaAction(block, deltaStr); - }; - - getNewTextLineActions = ({ - blockId, - delta, - parentId, - type = BlockType.TextBlock, - prevId, - data = {}, - }: { - blockId: string; - delta: Delta; - parentId: string; - type: BlockType; - prevId: string | null; - data?: BlockData; - }) => { - const externalId = generateId(); - const block = { - id: blockId, - type, - externalId, - externalType: 'text', - parent: parentId, - children: generateId(), - data, - }; - const deltaStr = JSON.stringify(delta.ops); - - if (!this.controller) return []; - return this.controller?.getInsertTextActions(block, deltaStr, prevId); - }; - - splitText = async ( - startBlock: { - id: string; - index: number; - }, - endBlock: { - id: string; - index: number; - }, - shiftKey?: boolean - ) => { - if (!this.controller) return; - - const startNode = this.getBlock(startBlock.id); - const endNode = this.getBlock(endBlock.id); - const startNodeIsRoot = !startNode.parent; - - if (!startNode || !endNode) return; - const startNodeDelta = this.getDeltaWithBlockId(startNode.id); - const endNodeDelta = this.getDeltaWithBlockId(endNode.id); - - if (!startNodeDelta || !endNodeDelta) return; - let diff: Delta, insertDelta; - - if (startNode.id === endNode.id) { - const splitResult = this.getSplitDelta(startNode.id, startBlock.index, endBlock.index - startBlock.index); - - if (!splitResult) return; - diff = splitResult.diff; - insertDelta = splitResult.insertDelta; - } else { - const startSplitResult = this.getSplitDelta( - startNode.id, - startBlock.index, - startNodeDelta.length() - startBlock.index - ); - - const endSplitResult = this.getSplitDelta(endNode.id, 0, endBlock.index); - - if (!startSplitResult || !endSplitResult) return; - diff = startSplitResult.diff; - insertDelta = endSplitResult.insertDelta; - } - - if (!diff || !insertDelta) return; - - const actions = []; - - const { nextLineBlockType, nextLineRelationShip } = blockConfig[startNode.type]?.splitProps || { - nextLineBlockType: BlockType.TextBlock, - nextLineRelationShip: SplitRelationship.NextSibling, - }; - const parentId = - nextLineRelationShip === SplitRelationship.NextSibling && startNode.parent ? startNode.parent : startNode.id; - const prevId = nextLineRelationShip === SplitRelationship.NextSibling && startNode.parent ? startNode.id : null; - - let newLineId = startNode.id; - - // delete middle nodes - if (startNode.id !== endNode.id) { - actions.push(...this.getDeleteMiddleNodesActions(startNode.id, endNode.id)); - } - - if (shiftKey) { - const enter = new Delta().insert('\n'); - const newOps = diff.ops.concat(enter.ops.concat(insertDelta.ops)); - - diff = new Delta(newOps); - if (startNode.id !== endNode.id) { - // move the children of endNode to startNode - actions.push(...this.getMoveChildrenActions(endNode.id, startNode)); - } - } else { - newLineId = generateId(); - actions.push( - ...this.getNewTextLineActions({ - blockId: newLineId, - delta: insertDelta, - parentId, - type: nextLineBlockType, - prevId, - }) - ); - if (!startNodeIsRoot) { - // move the children of startNode to newLine - actions.push( - ...this.getMoveChildrenActions( - startNode.id, - { - id: newLineId, - type: nextLineBlockType, - }, - [endNode.id] - ) - ); - } - - if (startNode.id !== endNode.id) { - // move the children of endNode to newLine - actions.push( - ...this.getMoveChildrenActions(endNode.id, { - id: newLineId, - type: nextLineBlockType, - }) - ); - } - } - - if (startNode.id !== endNode.id) { - // delete end node - const deleteEndNodeAction = this.controller.getDeleteAction(endNode); - - actions.push(deleteEndNodeAction); - } - - if (startNode.parent) { - // apply delta - const applyDeltaAction = this.getApplyDeltaAction(startNode.id, diff); - - if (applyDeltaAction) actions.unshift(applyDeltaAction); - } else { - await this.updateRootNodeDelta(startNode.id, diff); - } - - await this.controller.applyActions(actions); - - return newLineId; - }; - - deleteText = async ( - startBlock: { - id: string; - index: number; - }, - endBlock: { - id: string; - index: number; - }, - insertChar?: string - ) => { - if (!this.controller) return; - const startNode = this.getBlock(startBlock.id); - const endNode = this.getBlock(endBlock.id); - - if (!startNode || !endNode) return; - const startNodeDelta = this.getDeltaWithBlockId(startNode.id); - const endNodeDelta = this.getDeltaWithBlockId(endNode.id); - - if (!startNodeDelta || !endNodeDelta) return; - - let startDiff: Delta | undefined; - const actions = []; - - if (startNode.id === endNode.id) { - const length = endBlock.index - startBlock.index; - - const newOps: Op[] = [ - { - retain: startBlock.index, - }, - { - delete: length, - }, - ]; - - if (insertChar) { - newOps.push({ - insert: insertChar, - }); - } - - startDiff = new Delta(newOps); - } else { - const startSplitResult = this.getSplitDelta( - startNode.id, - startBlock.index, - startNodeDelta.length() - startBlock.index - ); - const endSplitResult = this.getSplitDelta(endNode.id, 0, endBlock.index); - - if (!startSplitResult || !endSplitResult) return; - const insertDelta = endSplitResult.insertDelta; - const newOps = [...startSplitResult.diff.ops]; - - if (insertChar) { - newOps.push({ - insert: insertChar, - }); - } - - newOps.push(...insertDelta.ops); - startDiff = new Delta(newOps); - // delete middle nodes - actions.push(...this.getDeleteMiddleNodesActions(startNode.id, endNode.id)); - // move the children of endNode to startNode - actions.push(...this.getMoveChildrenActions(endNode.id, startNode)); - // delete end node - const deleteEndNodeAction = this.controller.getDeleteAction(endNode); - - actions.push(deleteEndNodeAction); - } - - if (!startDiff) return; - if (startNode.parent) { - const applyDeltaAction = this.getApplyDeltaAction(startNode.id, startDiff); - - if (applyDeltaAction) actions.unshift(applyDeltaAction); - } else { - await this.updateRootNodeDelta(startNode.id, startDiff); - } - - await this.controller.applyActions(actions); - - return startNode.id; - }; - - mergeText = async (targetId: string, sourceId: string) => { - if (!this.controller || targetId === sourceId) return; - const startNode = this.getBlock(targetId); - const endNode = this.getBlock(sourceId); - - if (!startNode || !endNode) return; - const startNodeDelta = this.getDeltaWithBlockId(startNode.id); - const endNodeDelta = this.getDeltaWithBlockId(endNode.id); - - if (!startNodeDelta || !endNodeDelta) return; - - const startNodeIsRoot = !startNode.parent; - const actions = []; - const index = startNodeDelta.length(); - const retain = new Delta().retain(startNodeDelta.length()); - const newOps = [...retain.ops, ...endNodeDelta.ops]; - const diff = new Delta(newOps); - - if (!startNodeIsRoot) { - const applyDeltaAction = this.getApplyDeltaAction(startNode.id, diff); - - if (applyDeltaAction) actions.push(applyDeltaAction); - } else { - await this.updateRootNodeDelta(startNode.id, diff); - } - - const moveChildrenActions = this.getMoveChildrenActions(endNode.id, startNode); - - // move the children of endNode to startNode - actions.push(...moveChildrenActions); - // delete end node - const deleteEndNodeAction = this.controller.getDeleteAction(endNode); - - actions.push(deleteEndNodeAction); - - await this.controller.applyActions(actions); - return { - id: targetId, - index, - }; - }; - updateRootNodeDelta = async (id: string, diff: Delta) => { - const nodeDelta = this.getDeltaWithBlockId(id); - const delta = nodeDelta?.compose(diff); - - const name = delta ? this.getDeltaText(delta) : ''; - - await this.updatePageName?.(name); - }; - - getMoveChildrenActions = ( - blockId: string, - newParent: { - id: string; - type: BlockType; - }, - excludeIds?: string[] - ) => { - if (!this.controller) return []; - const block = this.getBlock(blockId); - const config = blockConfig[newParent.type]; - - if (!config.canAddChild) return []; - const childrenId = block.children; - const children = this.state.children[childrenId] - .filter((id) => !excludeIds || (excludeIds && !excludeIds.includes(id))) - .map((id) => this.getBlock(id)); - - return this.controller.getMoveChildrenAction(children, newParent.id, null); - }; - - getDeleteMiddleNodesActions = (startId: string, endId: string) => { - const controller = this.controller; - - if (!controller) return []; - const middleIds = this.getMiddleIds(startId, endId); - - return middleIds.map((id) => controller.getDeleteAction(this.getBlock(id))); - }; - - getMiddleIds = (startId: string, endId: string) => { - const middleIds = []; - let currentId: string | undefined = startId; - - while (currentId && currentId !== endId) { - const nextId = getNextLineId(this.state, currentId); - - if (nextId && nextId !== endId) { - middleIds.push(nextId); - } - - currentId = nextId; - } - - return middleIds; - }; - - findPrevTextLine = (blockId: string) => { - let currentId: string | undefined = blockId; - - while (currentId) { - const prevId = getPrevLineId(this.state, currentId); - - if (prevId && this.hasDelta(prevId)) { - return prevId; - } - - currentId = prevId; - } - }; - - findNextTextLine = (blockId: string) => { - let currentId: string | undefined = blockId; - - while (currentId) { - const nextId = getNextLineId(this.state, currentId); - - if (nextId && this.hasDelta(nextId)) { - return nextId; - } - - currentId = nextId; - } - }; -} diff --git a/frontend/appflowy_tauri/src/appflowy_app/utils/document/copy_paste.ts b/frontend/appflowy_tauri/src/appflowy_app/utils/document/copy_paste.ts deleted file mode 100644 index de8aaca8cd..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/utils/document/copy_paste.ts +++ /dev/null @@ -1,3 +0,0 @@ -export function copyText(text: string) { - return navigator.clipboard.writeText(text); -} diff --git a/frontend/appflowy_tauri/src/appflowy_app/utils/document/delta.ts b/frontend/appflowy_tauri/src/appflowy_app/utils/document/delta.ts deleted file mode 100644 index 8a85aacb15..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/utils/document/delta.ts +++ /dev/null @@ -1,158 +0,0 @@ -import Delta from 'quill-delta'; -import emojiRegex from 'emoji-regex'; - -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; -} - -export function getDeltaByRange( - delta: Delta, - range: { - index: number; - length: number; - } -) { - const start = range.index; - const end = range.index + range.length; - - return new Delta(delta.slice(start, end)); -} - -export function getBeofreExtentDeltaByRange( - delta: Delta, - range: { - index: number; - length: number; - } -) { - const start = range.index; - - return new Delta(delta.slice(0, start)); -} - -export function getAfterExtentDeltaByRange( - delta: Delta, - range: { - index: number; - length: number; - } -) { - const start = range.index + range.length; - - return new Delta(delta.slice(start)); -} - -export function getPreviousWordIndex(delta: Delta, index: number) { - if (index === 0) return 0; - const text = getDeltaText(delta.slice(0, index)); - const prevChar = text.charAt(index - 1); - - if (!prevChar) return index; - - if (isEmojiTail(prevChar)) { - // the char is emoji tail - // get all emojis from 0 to index - const emojis = getEmojis(text.substring(0, index)); - - if (emojis && emojis.length > 0) { - // get the last emoji - const lastEmoji = emojis[emojis.length - 1]; - // move the index to the last emoji head - const distance = lastEmoji.length; - - return index - distance; - } - } - - // default return the index - 1 - return index - 1; -} - -const regex = emojiRegex(); - -function getEmojis(text: string) { - const emojis = text.match(regex); - - return emojis; -} - -function isEmojiTail(character: string) { - const codepoint = character.charCodeAt(0); - - return 0xdc00 <= codepoint && codepoint <= 0xdfff; -} diff --git a/frontend/appflowy_tauri/src/appflowy_app/utils/document/format.ts b/frontend/appflowy_tauri/src/appflowy_app/utils/document/format.ts deleted file mode 100644 index 66fe95caaf..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/utils/document/format.ts +++ /dev/null @@ -1,29 +0,0 @@ -import isHotkey from 'is-hotkey'; -import { Keyboard } from '$app/constants/document/keyboard'; -import { TextAction } from '$app/interfaces/document'; - -export function isFormatHotkey(e: KeyboardEvent | React.KeyboardEvent<HTMLDivElement>) { - return ( - isHotkey(Keyboard.keys.FORMAT.BOLD, e) || - isHotkey(Keyboard.keys.FORMAT.ITALIC, e) || - isHotkey(Keyboard.keys.FORMAT.UNDERLINE, e) || - isHotkey(Keyboard.keys.FORMAT.STRIKE, e) || - isHotkey(Keyboard.keys.FORMAT.CODE, e) - ); -} - -export function parseFormat(e: KeyboardEvent | React.KeyboardEvent<HTMLDivElement>) { - if (isHotkey(Keyboard.keys.FORMAT.BOLD, e)) { - return TextAction.Bold; - } else if (isHotkey(Keyboard.keys.FORMAT.ITALIC, e)) { - return TextAction.Italic; - } else if (isHotkey(Keyboard.keys.FORMAT.UNDERLINE, e)) { - return TextAction.Underline; - } else if (isHotkey(Keyboard.keys.FORMAT.STRIKE, e)) { - return TextAction.Strikethrough; - } else if (isHotkey(Keyboard.keys.FORMAT.CODE, e)) { - return TextAction.Code; - } - - return null; -} diff --git a/frontend/appflowy_tauri/src/appflowy_app/utils/document/image.ts b/frontend/appflowy_tauri/src/appflowy_app/utils/document/image.ts deleted file mode 100644 index 9b02618df7..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/utils/document/image.ts +++ /dev/null @@ -1,94 +0,0 @@ -export async function readImage(url: string) { - const { BaseDirectory, readBinaryFile } = await import('@tauri-apps/api/fs'); - - try { - const data = await readBinaryFile(url, { dir: BaseDirectory.AppLocalData }); - const type = url.split('.').pop(); - const blob = new Blob([data], { - type: `image/${type}`, - }); - - return URL.createObjectURL(blob); - } catch (e) { - return Promise.reject(e); - } -} - -export async function readCoverImageUrls(): Promise<{ - images: { url: string }[]; -}> { - const { BaseDirectory, readTextFile, exists } = await import('@tauri-apps/api/fs'); - - try { - const existDir = await exists('cover/image_urls.json', { dir: BaseDirectory.AppLocalData }); - - if (!existDir) { - return { - images: [], - }; - } - - const data = await readTextFile('cover/image_urls.json', { dir: BaseDirectory.AppLocalData }); - - return JSON.parse(data); - } catch (e) { - return Promise.reject(e); - } -} - -export async function writeCoverImageUrls(images: { url: string }[]) { - const { BaseDirectory, createDir, exists, writeTextFile } = await import('@tauri-apps/api/fs'); - - const fileName = 'cover/image_urls.json'; - const jsonString = JSON.stringify({ images }); - - try { - const existDir = await exists('cover', { dir: BaseDirectory.AppLocalData }); - - if (!existDir) { - await createDir('cover', { dir: BaseDirectory.AppLocalData }); - } - - await writeTextFile(fileName, jsonString, { dir: BaseDirectory.AppLocalData }); - } catch (e) { - return Promise.reject(e); - } -} - -export function convertBlobToBase64(blob: Blob) { - return new Promise((resolve, reject) => { - const reader = new FileReader(); - - reader.onloadend = () => { - if (!reader.result) return; - - resolve(reader.result); - }; - - reader.onerror = reject; - reader.readAsDataURL(blob); - }); -} - -export async function writeImage(file: File) { - const { BaseDirectory, createDir, exists, writeBinaryFile } = await import('@tauri-apps/api/fs'); - - const fileName = `${Date.now()}-${file.name}`; - const arrayBuffer = await file.arrayBuffer(); - const unit8Array = new Uint8Array(arrayBuffer); - - try { - const existDir = await exists('images', { dir: BaseDirectory.AppLocalData }); - - if (!existDir) { - await createDir('images', { dir: BaseDirectory.AppLocalData }); - } - - const filePath = 'images/' + fileName; - - await writeBinaryFile(filePath, unit8Array, { dir: BaseDirectory.AppLocalData }); - return filePath; - } catch (e) { - return Promise.reject(e); - } -} diff --git a/frontend/appflowy_tauri/src/appflowy_app/utils/document/menu.ts b/frontend/appflowy_tauri/src/appflowy_app/utils/document/menu.ts deleted file mode 100644 index 39e84adaa3..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/utils/document/menu.ts +++ /dev/null @@ -1,8 +0,0 @@ -export function selectOptionByUpDown(isUp: boolean, selected: string | null, options: string[]) { - const index = options.findIndex((option) => option === selected); - const length = options.length; - - const nextIndex = isUp ? (index - 1 + length) % length : (index + 1) % length; - - return options[nextIndex]; -} diff --git a/frontend/appflowy_tauri/src/appflowy_app/utils/document/node.ts b/frontend/appflowy_tauri/src/appflowy_app/utils/document/node.ts deleted file mode 100644 index 5d7dc8a565..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/utils/document/node.ts +++ /dev/null @@ -1,292 +0,0 @@ -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; -} - -export function findFirstTextNode(node: Node): Node | null { - if (isTextNode(node)) { - return node; - } - - if (exclude && exclude(node as Element)) { - return null; - } - - const children = node.childNodes; - - for (const child of children) { - const textNode = findFirstTextNode(child); - - 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); -} - -export 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 getRangeByIndex(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); - return range; -} - -export function focusNodeByIndex(node: Element, index: number, length: number) { - const range = getRangeByIndex(node, index, length); - - if (!range) return false; - const selection = window.getSelection(); - - selection?.removeAllRanges(); - - selection?.addRange(range); - const focusNode = selection?.focusNode; - - if (!focusNode) return false; - - const parent = findParent(focusNode as Element, node); - - return Boolean(parent); -} - -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 | Element) { - let parentNode: Element | null = node; - - while (parentNode) { - if (typeof parentSelector === 'string' && parentNode.matches(parentSelector)) { - return parentNode; - } - - if (parentNode === parentSelector) { - return parentNode; - } - - parentNode = parentNode.parentElement; - } - - return null; -} 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 deleted file mode 100644 index 7b9690b894..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/utils/document/quill_editor.ts +++ /dev/null @@ -1,60 +0,0 @@ -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 deleted file mode 100644 index 619dcf06f0..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/utils/document/slate_editor.ts +++ /dev/null @@ -1,169 +0,0 @@ -import { BaseElement, BasePoint, Descendant, Editor, Element, Selection, Text } from 'slate'; -import Delta from 'quill-delta'; -import { getLineByIndex } from '$app/utils/document/delta'; - -export function converToSlatePoint(editor: Editor, index: number) { - const children = editor.children; - const texts = (children[0] as BaseElement).children.map((child) => (child as Text).text); - let path = [0, 0]; - let offset = 0; - let charCount = 0; - - texts.forEach((text, i) => { - const endOffset = charCount + text.length; - - if (index >= charCount && index <= endOffset) { - path = [0, i]; - offset = index - charCount; - } - - charCount += text.length; - }); - return { path, offset }; -} - -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, - }); -} diff --git a/frontend/appflowy_tauri/src/appflowy_app/utils/document/subscribe.ts b/frontend/appflowy_tauri/src/appflowy_app/utils/document/subscribe.ts deleted file mode 100644 index dd062ebe0d..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/utils/document/subscribe.ts +++ /dev/null @@ -1,228 +0,0 @@ -import { DeltaTypePB } from '@/services/backend/models/flowy-document2'; -import { BlockPBValue, BlockType, ChangeType, DocumentState, NestedBlock } from '$app/interfaces/document'; -import { Log } from '../log'; -import { isEqual } from '$app/utils/tool'; -import { BLOCK_MAP_NAME, CHILDREN_MAP_NAME, META_NAME, TEXT_MAP_NAME } from '$app/constants/document/name'; -import Delta, { Op } from 'quill-delta'; - -// This is a list of all the possible changes that can happen to document data -const matchCases = [ - { match: matchBlockInsert, type: ChangeType.BlockInsert, onMatch: onMatchBlockInsert }, - { match: matchBlockUpdate, type: ChangeType.BlockUpdate, onMatch: onMatchBlockUpdate }, - { match: matchBlockDelete, type: ChangeType.BlockDelete, onMatch: onMatchBlockDelete }, - { match: matchChildrenMapInsert, type: ChangeType.ChildrenMapInsert, onMatch: onMatchChildrenInsert }, - { match: matchChildrenMapUpdate, type: ChangeType.ChildrenMapUpdate, onMatch: onMatchChildrenUpdate }, - { match: matchChildrenMapDelete, type: ChangeType.ChildrenMapDelete, onMatch: onMatchChildrenDelete }, - { match: matchDeltaMapInsert, type: ChangeType.DeltaMapInsert, onMatch: onMatchDeltaInsert }, - { match: matchDeltaMapUpdate, type: ChangeType.DeltaMapUpdate, onMatch: onMatchDeltaUpdate }, - { match: matchDeltaMapDelete, type: ChangeType.DeltaMapDelete, onMatch: onMatchDeltaDelete }, -]; - -export function matchChange( - state: DocumentState, - { - command, - path, - id, - value, - }: { - command: DeltaTypePB; - path: string[]; - id: string; - value: BlockPBValue & string[] & Op[]; - } -) { - const matchCase = matchCases.find((item) => item.match(command, path)); - - if (matchCase) { - matchCase.onMatch(state, id, value); - } -} - -/** - * @param command DeltaTypePB.Inserted - * @param path [BLOCK_MAP_NAME] - */ -function matchBlockInsert(command: DeltaTypePB, path: string[]) { - if (path.length !== 1) return false; - return command === DeltaTypePB.Inserted && path[0] === BLOCK_MAP_NAME; -} - -/** - * @param command DeltaTypePB.Updated - * @param path [BLOCK_MAP_NAME, blockId] - */ -function matchBlockUpdate(command: DeltaTypePB, path: string[]) { - if (path.length !== 2) return false; - return command === DeltaTypePB.Updated && path[0] === BLOCK_MAP_NAME && typeof path[1] === 'string'; -} - -/** - * @param command DeltaTypePB.Removed - * @param path [BLOCK_MAP_NAME, blockId] - */ -function matchBlockDelete(command: DeltaTypePB, path: string[]) { - if (path.length !== 2) return false; - return command === DeltaTypePB.Removed && path[0] === BLOCK_MAP_NAME && typeof path[1] === 'string'; -} - -/** - * @param command DeltaTypePB.Inserted - * @param path [META_NAME, CHILDREN_MAP_NAME] - */ -function matchChildrenMapInsert(command: DeltaTypePB, path: string[]) { - if (path.length !== 2) return false; - return command === DeltaTypePB.Inserted && path[0] === META_NAME && path[1] === CHILDREN_MAP_NAME; -} - -/** - * @param command DeltaTypePB.Updated - * @param path [META_NAME, CHILDREN_MAP_NAME, id] - */ -function matchChildrenMapUpdate(command: DeltaTypePB, path: string[]) { - if (path.length !== 3) return false; - return ( - command === DeltaTypePB.Updated && - path[0] === META_NAME && - path[1] === CHILDREN_MAP_NAME && - typeof path[2] === 'string' - ); -} - -/** - * @param command DeltaTypePB.Removed - * @param path [META_NAME, CHILDREN_MAP_NAME, id] - */ -function matchChildrenMapDelete(command: DeltaTypePB, path: string[]) { - if (path.length !== 3) return false; - return ( - command === DeltaTypePB.Removed && - path[0] === META_NAME && - path[1] === CHILDREN_MAP_NAME && - typeof path[2] === 'string' - ); -} - -/** - * @param command DeltaTypePB.Inserted - * @param command - * @param path [META_NAME, TEXT_MAP_NAME] - */ -function matchDeltaMapInsert(command: DeltaTypePB, path: string[]) { - if (path.length !== 2) return false; - return command === DeltaTypePB.Inserted && path[0] === META_NAME && path[1] === TEXT_MAP_NAME; -} - -/** - * @param command DeltaTypePB.Updated - * @param command - * @param path [META_NAME, TEXT_MAP_NAME, id] - */ -function matchDeltaMapUpdate(command: DeltaTypePB, path: string[]) { - if (path.length !== 3) return false; - return ( - command === DeltaTypePB.Updated && path[0] === META_NAME && path[1] === TEXT_MAP_NAME && typeof path[2] === 'string' - ); -} - -/** - * @param command DeltaTypePB.Removed - * @param path [META_NAME, TEXT_MAP_NAME, id] - */ -function matchDeltaMapDelete(command: DeltaTypePB, path: string[]) { - if (path.length !== 3) return false; - return ( - command === DeltaTypePB.Removed && path[0] === META_NAME && path[1] === TEXT_MAP_NAME && typeof path[2] === 'string' - ); -} - -function onMatchBlockInsert(state: DocumentState, blockId: string, blockValue: BlockPBValue) { - state.nodes[blockId] = blockChangeValue2Node(blockValue); -} - -function onMatchBlockUpdate(state: DocumentState, blockId: string, blockValue: BlockPBValue) { - const block = blockChangeValue2Node(blockValue); - const node = state.nodes[blockId]; - - if (!node) return; - - if (isEqual(node, block)) return; - state.nodes[blockId] = block; - return; -} - -function onMatchBlockDelete(state: DocumentState, blockId: string, _blockValue: BlockPBValue) { - delete state.nodes[blockId]; -} - -function onMatchChildrenInsert(state: DocumentState, id: string, children: string[]) { - state.children[id] = children; -} - -function onMatchChildrenUpdate(state: DocumentState, id: string, newChildren: string[]) { - const children = state.children[id]; - - if (!children) return; - state.children[id] = newChildren; -} - -function onMatchChildrenDelete(state: DocumentState, id: string, _children: string[]) { - delete state.children[id]; -} - -function onMatchDeltaInsert(state: DocumentState, id: string, ops: Op[]) { - state.deltaMap[id] = JSON.stringify(ops); -} - -function onMatchDeltaUpdate(state: DocumentState, id: string, ops: Op[]) { - const delta = new Delta(ops); - const oldDelta = new Delta(JSON.parse(state.deltaMap[id])); - const newDelta = oldDelta.compose(delta); - - state.deltaMap[id] = JSON.stringify(newDelta.ops); -} - -function onMatchDeltaDelete(state: DocumentState, id: string, _ops: Op[]) { - delete state.deltaMap[id]; -} - -/** - * convert block change value to node - * @param value - */ -export function blockChangeValue2Node(value: BlockPBValue): NestedBlock { - const block: NestedBlock = { - id: value.id, - type: value.ty as BlockType, - parent: value.parent, - children: value.children, - data: {}, - externalId: value.external_id, - externalType: value.external_type, - }; - - if ('data' in value && typeof value.data === 'string') { - try { - Object.assign(block, { - data: JSON.parse(value.data), - }); - } catch { - Log.error('[onDataChange] valueJson data parse error', block.data); - } - } - - return block; -} - -export function parseValue(value: string) { - let valueJson; - - try { - valueJson = JSON.parse(value); - } catch { - Log.error('[onDataChange] json parse error', value); - return value; - } - - return valueJson; -} diff --git a/frontend/appflowy_tauri/src/appflowy_app/utils/document/temporary.ts b/frontend/appflowy_tauri/src/appflowy_app/utils/document/temporary.ts deleted file mode 100644 index 059c8b2e4c..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/utils/document/temporary.ts +++ /dev/null @@ -1,14 +0,0 @@ -export function isOverlappingPrefix(first: string, second: string): boolean { - if (first.length === 0 || second.length === 0) return false; - let i = 0; - - while (i < first.length) { - const chars = first.substring(i); - - if (chars.length > second.length) return false; - if (second.startsWith(chars)) return true; - i++; - } - - return false; -} diff --git a/frontend/appflowy_tauri/src/appflowy_app/utils/document/toolbar.ts b/frontend/appflowy_tauri/src/appflowy_app/utils/document/toolbar.ts deleted file mode 100644 index fa8318ac42..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/utils/document/toolbar.ts +++ /dev/null @@ -1,33 +0,0 @@ -export function calcToolbarPosition(toolbarDom: HTMLDivElement, node: Element, container: HTMLDivElement) { - const domSelection = window.getSelection(); - let domRange; - - if (domSelection?.rangeCount === 0) { - return; - } else { - domRange = domSelection?.getRangeAt(0); - } - - const nodeRect = node.getBoundingClientRect(); - const rect = domRange?.getBoundingClientRect() || { top: 0, left: 0, width: 0, height: 0 }; - 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, - left, - }; -} diff --git a/frontend/appflowy_tauri/src/appflowy_app/utils/draggable.ts b/frontend/appflowy_tauri/src/appflowy_app/utils/draggable.ts deleted file mode 100644 index 090ab871d7..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/utils/draggable.ts +++ /dev/null @@ -1,111 +0,0 @@ -import { BlockDraggableType, DragInsertType } from '$app_reducers/block-draggable/slice'; -import { findParent } from '$app/utils/document/node'; -import { nanoid } from 'nanoid'; - -export function getDraggableIdByPoint(target: HTMLElement | null) { - let node = target; - - while (node) { - const id = node.getAttribute('data-draggable-id'); - - if (id) { - return id; - } - - node = node.parentElement; - } - - return null; -} - -export function getDraggableNode(id: string) { - return document.querySelector(`[data-draggable-id="${id}"]`); -} - -export function getDragDropContext(id: string) { - const node = getDraggableNode(id); - - if (!node) return; - const type = node.getAttribute('data-draggable-type') as BlockDraggableType; - const container = node.closest('[id^=appflowy-scroller]'); - - if (!container) return; - const containerId = container.id; - const contextId = containerId.split('_')[1]; - - return { - contextId, - container, - type, - }; -} - -export function collisionNode(event: MouseEvent, draggingId: string) { - event.stopPropagation(); - const { clientY, target, clientX } = event; - - if (!target) return; - let id = getDraggableIdByPoint(target as HTMLElement); - - if (!id) return; - - if (id === draggingId) return; - - const parentIsDraggingId = (target as HTMLElement).closest(`[data-draggable-id="${draggingId}"]`); - - if (parentIsDraggingId) return; - - const node = getDraggableNode(id); - - if (!node) return; - const { top, bottom, left } = node.getBoundingClientRect(); - - let parent = node.parentElement; - let nodeLeft = left; - - while (parent && clientX < nodeLeft) { - const parentNode = findParent(parent, '[data-draggable-id]'); - - if (!parentNode) break; - const parentId = parentNode.getAttribute('data-draggable-id'); - - id = parentId || id; - nodeLeft = parentNode.getBoundingClientRect().left; - parent = parentNode.parentElement; - } - - let insertType = DragInsertType.CHILD; - - if (clientY - top < 4) { - insertType = DragInsertType.BEFORE; - } - - if (clientY > bottom - 4) { - insertType = DragInsertType.AFTER; - } - - return { - id, - insertType, - }; -} - -const scrollThreshold = 20; - -export function scrollIntoViewIfNeeded(e: MouseEvent, container: HTMLDivElement) { - const { top, bottom } = container.getBoundingClientRect(); - - let delta = 0; - - if (e.clientY + scrollThreshold >= bottom) { - delta = e.clientY + scrollThreshold - bottom; - } else if (e.clientY - scrollThreshold <= top) { - delta = e.clientY - scrollThreshold - top; - } - - container.scrollBy(0, delta); -} - -export function generateDragContextId() { - return nanoid(10); -} diff --git a/frontend/appflowy_tauri/src/appflowy_app/utils/document/emoji.ts b/frontend/appflowy_tauri/src/appflowy_app/utils/emoji.ts similarity index 100% rename from frontend/appflowy_tauri/src/appflowy_app/utils/document/emoji.ts rename to frontend/appflowy_tauri/src/appflowy_app/utils/emoji.ts diff --git a/frontend/appflowy_tauri/src/appflowy_app/utils/mui.ts b/frontend/appflowy_tauri/src/appflowy_app/utils/mui.ts index f31a6dd280..84465a8056 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/utils/mui.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/utils/mui.ts @@ -1,4 +1,4 @@ -import { ThemeMode } from '$app/interfaces'; +import { ThemeMode } from '$app/stores/reducers/current-user/slice'; import { ThemeOptions } from '@mui/material'; // eslint-disable-next-line @typescript-eslint/ban-ts-comment diff --git a/frontend/appflowy_tauri/src/appflowy_app/utils/region_grid.ts b/frontend/appflowy_tauri/src/appflowy_app/utils/region_grid.ts deleted file mode 100644 index 75129c3bea..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/utils/region_grid.ts +++ /dev/null @@ -1,105 +0,0 @@ -export interface BlockPosition { - id: string; - x: number; - y: number; - height: number; - width: number; -} - -interface Rectangle { - x: number; - y: number; - height: number; - width: number; -} - -export class RegionGrid { - private readonly gridSize: number; - private readonly grid: Map<string, BlockPosition[]>; - private readonly blockKeysMap: Map<string, string[]>; - - constructor(gridSize: number) { - this.gridSize = gridSize; - this.grid = new Map(); - this.blockKeysMap = new Map(); - } - - private getKeys(x: number, y: number, width: number, height: number): string[] { - const keys: string[] = []; - - for (let i = Math.floor(x / this.gridSize); i <= Math.floor((x + width) / this.gridSize); i++) { - for (let j = Math.floor(y / this.gridSize); j <= Math.floor((y + height) / this.gridSize); j++) { - keys.push(`${i},${j}`); - } - } - - return keys; - } - - addBlock(block: BlockPosition): void { - const keys = this.getKeys(block.x, block.y, block.width, block.height); - - this.blockKeysMap.set(block.id, keys); - - for (const key of keys) { - if (!this.grid.has(key)) { - this.grid.set(key, []); - } - - this.grid.get(key)?.push(block); - } - } - - hasBlock(id: string) { - return this.blockKeysMap.has(id); - } - - updateBlock(block: BlockPosition): void { - if (this.hasBlock(block.id)) { - this.removeBlock(block); - } - - this.addBlock(block); - } - - removeBlock(block: BlockPosition): void { - const keys = this.blockKeysMap.get(block.id) || []; - - for (const key of keys) { - const blocks = this.grid.get(key); - - if (blocks) { - const index = blocks.findIndex((b) => b.id === block.id); - - if (index !== -1) { - blocks.splice(index, 1); - - if (blocks.length === 0) { - this.grid.delete(key); - } - } - } - } - } - - getIntersectingBlocks(rect: Rectangle): BlockPosition[] { - const blocks = new Set<BlockPosition>(); - const keys = this.getKeys(rect.x, rect.y, rect.width, rect.height); - - for (const key of keys) { - if (this.grid.has(key)) { - this.grid.get(key)?.forEach((block) => { - if ( - rect.x < block.x + block.width && - rect.x + rect.width > block.x && - rect.y < block.y + block.height && - rect.y + rect.height > block.y - ) - blocks.add(block); - }); - } - } - - return Array.from(blocks); - } -} diff --git a/frontend/appflowy_tauri/src/appflowy_app/views/DocumentPage.hooks.ts b/frontend/appflowy_tauri/src/appflowy_app/views/DocumentPage.hooks.ts deleted file mode 100644 index e4383d04a4..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/views/DocumentPage.hooks.ts +++ /dev/null @@ -1,90 +0,0 @@ -import { useCallback, useEffect, useState } from 'react'; -import { DocumentData } from '../interfaces/document'; -import { DocumentController } from '$app/stores/effects/document/document_controller'; -import { useAppDispatch } from '../stores/store'; -import { Log } from '../utils/log'; -import { - documentActions, - rangeActions, - rectSelectionActions, - slashCommandActions, -} from '$app/stores/reducers/document/slice'; -import { BlockEventPayloadPB } from '@/services/backend/models/flowy-document2'; - -export const useDocument = (documentId?: string) => { - const [documentData, setDocumentData] = useState<DocumentData>(); - const [controller, setController] = useState<DocumentController | null>(null); - const dispatch = useAppDispatch(); - - const onDocumentChange = useCallback( - (props: { docId: string; isRemote: boolean; data: BlockEventPayloadPB }) => { - dispatch(documentActions.onDataChange(props)); - }, - [dispatch] - ); - - const initializeDocument = useCallback( - (docId: string) => { - Log.debug('initialize document', docId); - dispatch(documentActions.initialState(docId)); - dispatch(rangeActions.initialState(docId)); - dispatch(rectSelectionActions.initialState(docId)); - dispatch(slashCommandActions.initialState(docId)); - }, - [dispatch] - ); - - const clearDocument = useCallback( - (docId: string) => { - Log.debug('clear document', docId); - dispatch(documentActions.clear(docId)); - dispatch(rangeActions.clear(docId)); - dispatch(rectSelectionActions.clear(docId)); - dispatch(slashCommandActions.clear(docId)); - }, - [dispatch] - ); - - useEffect(() => { - let documentController: DocumentController | null = null; - - void (async () => { - if (!documentId) return; - documentController = new DocumentController(documentId, onDocumentChange); - const docId = documentController.documentId; - - Log.debug('open document', documentId); - - initializeDocument(documentController.documentId); - - setController(documentController); - try { - const res = await documentController.open(); - - if (!res) return; - dispatch( - documentActions.create({ - ...res, - docId, - }) - ); - setDocumentData(res); - } catch (e) { - Log.error(e); - } - })(); - - return () => { - if (documentController) { - void (async () => { - await documentController.dispose(); - clearDocument(documentController.documentId); - })(); - } - - Log.debug('close document', documentId); - }; - }, [clearDocument, dispatch, initializeDocument, onDocumentChange, documentId]); - - return { documentId, documentData, controller }; -}; diff --git a/frontend/appflowy_tauri/src/appflowy_app/views/DocumentPage.tsx b/frontend/appflowy_tauri/src/appflowy_app/views/DocumentPage.tsx index e229f07859..cbd71ec7d4 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/views/DocumentPage.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/views/DocumentPage.tsx @@ -1,12 +1,20 @@ +import React from 'react'; import { useParams } from 'react-router-dom'; -import Document from '$app/components/document'; +import { Document } from '$app/components/document'; -export const DocumentPage = () => { +function DocumentPage() { const params = useParams(); const documentId = params.id; if (!documentId) return null; + return ( + <div className={'flex w-full justify-center'}> + <div className={'max-w-screen w-[964px] min-w-0'}> + <Document id={documentId} /> + </div> + </div> + ); +} - return <Document documentId={documentId} />; -}; +export default DocumentPage; diff --git a/frontend/appflowy_tauri/src/appflowy_app/views/GridPage.tsx b/frontend/appflowy_tauri/src/appflowy_app/views/GridPage.tsx deleted file mode 100644 index 9d8d910853..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/views/GridPage.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import { useParams } from 'react-router-dom'; - -import { useEffect, useState } from 'react'; -import { Grid } from '../components/grid/Grid/Grid'; - -export const GridPage = () => { - const params = useParams(); - const [viewId, setViewId] = useState(''); - - useEffect(() => { - if (params?.id?.length) { - setViewId(params.id); - } - }, [params]); - - return ( - <div className='flex h-full flex-col gap-8 px-8 pt-8'> - {viewId?.length && <Grid viewId={viewId} />} - </div> - ); -}; diff --git a/frontend/appflowy_tauri/src/styles/mui.css b/frontend/appflowy_tauri/src/styles/mui.css index 6d81bb64c4..86822710d2 100644 --- a/frontend/appflowy_tauri/src/styles/mui.css +++ b/frontend/appflowy_tauri/src/styles/mui.css @@ -11,7 +11,7 @@ background-color: var(--fill-list-active); } -[class$='-MuiButtonBase-root-MuiMenuItem-root'].MuiButtonBase-root:hover { +[class$='-MuiButtonBase-root-MuiMenuItem-root'].MuiButtonBase-root:hover, [class$='-MuiButtonBase-root-MuiMenuItem-root'].MuiButtonBase-root.Mui-selected:hover { background-color: var(--fill-list-active); } diff --git a/frontend/appflowy_tauri/tsconfig.json b/frontend/appflowy_tauri/tsconfig.json index 589351d575..63b15b6039 100644 --- a/frontend/appflowy_tauri/tsconfig.json +++ b/frontend/appflowy_tauri/tsconfig.json @@ -20,10 +20,11 @@ "paths": { "@/*": ["src/*"], "$app/*": ["src/appflowy_app/*"], - "$app_reducers/*": ["src/appflowy_app/stores/reducers/*"] + "$app_reducers/*": ["src/appflowy_app/stores/reducers/*"], + "src/*": ["src/*"] } }, - "include": ["src", "vite.config.ts", "../app_flowy/assets/translations"], + "include": ["src", "vite.config.ts"], "exclude": ["node_modules"], "references": [{ "path": "./tsconfig.node.json" }] } diff --git a/frontend/appflowy_tauri/vite.config.ts b/frontend/appflowy_tauri/vite.config.ts index 568983dd1f..f202808a17 100644 --- a/frontend/appflowy_tauri/vite.config.ts +++ b/frontend/appflowy_tauri/vite.config.ts @@ -19,7 +19,7 @@ export default defineConfig({ params: { overrides: { removeViewBox: false, - } + }, }, }, ], @@ -41,6 +41,9 @@ export default defineConfig({ server: { port: 1420, strictPort: true, + watch: { + ignored: ['**/__tests__/**'], + }, }, // to make use of `TAURI_DEBUG` and other env variables // https://tauri.studio/v1/api/config#buildconfig.beforedevcommand @@ -55,9 +58,13 @@ export default defineConfig({ }, resolve: { alias: [ + { find: 'src/', replacement: `${__dirname}/src/` }, { find: '@/', replacement: `${__dirname}/src/` }, { find: '$app/', replacement: `${__dirname}/src/appflowy_app/` }, { find: '$app_reducers/', replacement: `${__dirname}/src/appflowy_app/stores/reducers/` }, ], }, + optimizeDeps: { + include: ['@mui/material/Tooltip', '@emotion/styled', '@mui/material/Unstable_Grid2'], + }, }); diff --git a/frontend/resources/translations/en.json b/frontend/resources/translations/en.json index 74eefc1aa4..e7a5a6e0bf 100644 --- a/frontend/resources/translations/en.json +++ b/frontend/resources/translations/en.json @@ -752,7 +752,15 @@ "cut": "Cut", "paste": "Paste" }, - "action": "Actions" + "action": "Actions", + "database": { + "selectDataSource": "Select data source", + "noDataSource": "No data source", + "selectADataSource": "Select a data source", + "toContinue": "to continue", + "newDatabase": "New Database", + "linkToDatabase": "Link to Database" + } }, "textBlock": { "placeholder": "Type '/' for commands"