chore: optimize the experience of the document (#4152)
* fix: scroll bug of grid * chore: optimize the experience of the document * fix: drag folder * fix: add unit test to provider
@ -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']
|
||||
};
|
||||
|
@ -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: ['<rootDir>'],
|
||||
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?)$",
|
||||
};
|
@ -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",
|
||||
|
@ -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:
|
||||
|
@ -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';
|
||||
|
@ -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();
|
||||
|
@ -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<string, EditorNode>;
|
||||
// key: block's children id, value: block's id
|
||||
childrenMap: Record<string, string[]>;
|
||||
// key: block's children id, value: block's id
|
||||
relativeMap: Record<string, string>;
|
||||
// key: block's externalId, value: delta
|
||||
deltaMap: Record<string, Op[]>;
|
||||
// key: block's externalId, value: block's id
|
||||
externalIdMap: Record<string, string>;
|
||||
}
|
||||
|
||||
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<T = Element> extends HTMLAttributes<HTMLDivElement> {
|
||||
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;
|
||||
}
|
@ -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<EditorData> {
|
||||
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<typeof BlockActionPB.prototype.toObject>[]) {
|
||||
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;
|
||||
}
|
@ -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<K extends (abstract new (...args: any) => 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<K>
|
||||
: void;
|
||||
export 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 = Notification[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);
|
||||
}
|
@ -0,0 +1,4 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M9 2.5V6C9 6.55228 9.44772 7 10 7H12" stroke="#333333"/>
|
||||
<path d="M3.5 3.5C3.5 2.94771 3.94772 2.5 4.5 2.5H8H8.5C9.12951 2.5 9.72229 2.79639 10.1 3.3L12.1 5.96667C12.3596 6.31286 12.5 6.73393 12.5 7.16667V8V12.5C12.5 13.0523 12.0523 13.5 11.5 13.5H4.5C3.94772 13.5 3.5 13.0523 3.5 12.5V3.5Z" stroke="#333333"/>
|
||||
</svg>
|
After Width: | Height: | Size: 423 B |
@ -0,0 +1,5 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M4 4L12 4" stroke="#333333" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M5 8H11" stroke="#333333" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M4 12L12 12" stroke="#333333" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
After Width: | Height: | Size: 361 B |
@ -0,0 +1,5 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M4 4L12 4" stroke="#333333" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M4 8H10" stroke="#333333" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M4 12L12 12" stroke="#333333" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
After Width: | Height: | Size: 361 B |
@ -0,0 +1,5 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M4 4L12 4" stroke="#333333" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M6 8H12" stroke="#333333" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M4 12L12 12" stroke="#333333" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
After Width: | Height: | Size: 361 B |
3
frontend/appflowy_tauri/src/appflowy_app/assets/bold.svg
Normal file
@ -0,0 +1,3 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M9 8C9.66667 8 11 8.4 11 10C11 11.6 9.66667 12 9 12H6V8M9 8H6M9 8C9.5 8 10.5171 6.97616 10.5 6C10.4806 4.8956 9.5 4 8.5 4H6V8" stroke="#333333" stroke-width="1.5"/>
|
||||
</svg>
|
After Width: | Height: | Size: 277 B |
6
frontend/appflowy_tauri/src/appflowy_app/assets/date.svg
Normal file
@ -0,0 +1,6 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M11.8889 3.5H4.11111C3.49746 3.5 3 3.94772 3 4.5V11.5C3 12.0523 3.49746 12.5 4.11111 12.5H11.8889C12.5025 12.5 13 12.0523 13 11.5V4.5C13 3.94772 12.5025 3.5 11.8889 3.5Z" stroke="#333333" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M10 2.5V4.58181" stroke="#333333" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M6 2.5V4.58181" stroke="#333333" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M3 6.5H13" stroke="#333333" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
After Width: | Height: | Size: 618 B |
4
frontend/appflowy_tauri/src/appflowy_app/assets/h1.svg
Normal file
@ -0,0 +1,4 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M8.25368 3.6001H9.63368V12.0001H8.25368V8.3641H4.65368V12.0001H3.27368V3.6001H4.65368V7.0441H8.25368V3.6001Z" fill="#333333"/>
|
||||
<path d="M12.0327 6.4001H12.9927V12.0001H11.8887V7.5681L10.8327 7.8641L10.5607 6.9201L12.0327 6.4001Z" fill="#333333"/>
|
||||
</svg>
|
After Width: | Height: | Size: 359 B |
4
frontend/appflowy_tauri/src/appflowy_app/assets/h2.svg
Normal file
@ -0,0 +1,4 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M7.67531 3.6001H9.05531V12.0001H7.67531V8.3641H4.07531V12.0001H2.69531V3.6001H4.07531V7.0441H7.67531V3.6001Z" fill="#333333"/>
|
||||
<path d="M10.1104 12.0001V11.1761L12.0224 9.2081C12.449 8.7601 12.6624 8.38676 12.6624 8.0881C12.6624 7.86943 12.593 7.69343 12.4544 7.5601C12.321 7.42676 12.1477 7.3601 11.9344 7.3601C11.513 7.3601 11.201 7.57876 10.9984 8.0161L10.0704 7.4721C10.2464 7.0881 10.4997 6.79476 10.8304 6.5921C11.161 6.38943 11.5237 6.2881 11.9184 6.2881C12.425 6.2881 12.8597 6.4481 13.2224 6.7681C13.585 7.08276 13.7664 7.50943 13.7664 8.0481C13.7664 8.62943 13.4597 9.22677 12.8464 9.8401L11.7504 10.9361H13.8544V12.0001H10.1104Z" fill="#333333"/>
|
||||
</svg>
|
After Width: | Height: | Size: 770 B |
4
frontend/appflowy_tauri/src/appflowy_app/assets/h3.svg
Normal file
@ -0,0 +1,4 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M7.62063 3.6001H9.00063V12.0001H7.62063V8.3641H4.02062V12.0001H2.64062V3.6001H4.02062V7.0441H7.62063V3.6001Z" fill="#333333"/>
|
||||
<path d="M12.6637 8.6721C13.0424 8.7841 13.349 8.98143 13.5837 9.2641C13.8237 9.54143 13.9437 9.87743 13.9437 10.2721C13.9437 10.8481 13.749 11.2988 13.3597 11.6241C12.9757 11.9494 12.5037 12.1121 11.9437 12.1121C11.5064 12.1121 11.1144 12.0134 10.7677 11.8161C10.4264 11.6134 10.1784 11.3174 10.0237 10.9281L10.9677 10.3841C11.1064 10.8161 11.4317 11.0321 11.9437 11.0321C12.2264 11.0321 12.445 10.9654 12.5997 10.8321C12.7597 10.6934 12.8397 10.5068 12.8397 10.2721C12.8397 10.0428 12.7597 9.85876 12.5997 9.7201C12.445 9.58143 12.2264 9.5121 11.9437 9.5121H11.7037L11.2797 8.8721L12.3837 7.4321H10.1917V6.4001H13.7117V7.3121L12.6637 8.6721Z" fill="#333333"/>
|
||||
</svg>
|
After Width: | Height: | Size: 901 B |
@ -0,0 +1,6 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M6 5L3 8L6 11" stroke="#333333" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<rect width="4" height="1" rx="0.5" transform="matrix(-1 0 0 1 13 5)" fill="#333333"/>
|
||||
<rect width="6" height="1" rx="0.5" transform="matrix(-1 0 0 1 13 7.5)" fill="#333333"/>
|
||||
<rect width="4" height="1" rx="0.5" transform="matrix(-1 0 0 1 13 10)" fill="#333333"/>
|
||||
</svg>
|
After Width: | Height: | Size: 457 B |
@ -0,0 +1,4 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M10 4L14 8L10 12" stroke="#333333" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M6 4L2 8L6 12" stroke="#333333" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
After Width: | Height: | Size: 286 B |
@ -0,0 +1,3 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M8.7 4L7.3 12M8.7 4H11.5M8.7 4H5.9M7.3 12H10.1M7.3 12H4.5" stroke="#333333" stroke-width="1.2"/>
|
||||
</svg>
|
After Width: | Height: | Size: 209 B |
4
frontend/appflowy_tauri/src/appflowy_app/assets/link.svg
Normal file
@ -0,0 +1,4 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M7 8.91521C7.21574 9.22582 7.49099 9.48283 7.80707 9.66881C8.12315 9.85479 8.47268 9.96538 8.83194 9.99309C9.1912 10.0208 9.5518 9.96497 9.88926 9.8294C10.2267 9.69383 10.5332 9.48169 10.7878 9.20736L12.2949 7.58431C12.7525 7.07413 13.0056 6.39083 12.9999 5.68156C12.9942 4.9723 12.73 4.29384 12.2643 3.7923C11.7986 3.29075 11.1686 3.00627 10.51 3.0001C9.85142 2.99394 9.21693 3.26659 8.7432 3.75935L7.87913 4.68448" stroke="#333333" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M9 7.08479C8.78426 6.77418 8.50901 6.51717 8.19293 6.33119C7.87685 6.14521 7.52732 6.03462 7.16806 6.00691C6.8088 5.9792 6.4482 6.03503 6.11074 6.1706C5.77327 6.30617 5.46683 6.51831 5.21218 6.79264L3.7051 8.41569C3.24755 8.92587 2.99437 9.60918 3.00009 10.3184C3.00582 11.0277 3.26998 11.7062 3.73569 12.2077C4.2014 12.7092 4.8314 12.9937 5.48999 12.9999C6.14858 13.0061 6.78307 12.7334 7.2568 12.2407L8.11584 11.3155" stroke="#333333" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
After Width: | Height: | Size: 1.1 KiB |
@ -0,0 +1,4 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M8.28284 4.28284L10.3172 6.31716C10.5691 6.56914 10.3907 7 10.0343 7H5.96569C5.60932 7 5.43086 6.56914 5.68284 6.31716L7.71716 4.28284C7.87337 4.12663 8.12663 4.12663 8.28284 4.28284Z" fill="#00BCF0"/>
|
||||
<path d="M8.28284 11.7172L10.3172 9.68284C10.5691 9.43086 10.3907 9 10.0343 9H5.96569C5.60932 9 5.43086 9.43086 5.68284 9.68284L7.71716 11.7172C7.87337 11.8734 8.12663 11.8734 8.28284 11.7172Z" fill="#00BCF0"/>
|
||||
</svg>
|
After Width: | Height: | Size: 525 B |
8
frontend/appflowy_tauri/src/appflowy_app/assets/list.svg
Normal file
@ -0,0 +1,8 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M6.5 4L12.5 4" stroke="#333333" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M6.5 8H12.5" stroke="#333333" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M6.5 12H12.5" stroke="#333333" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<circle cx="4" cy="4" r="0.5" fill="#333333"/>
|
||||
<circle cx="4" cy="8" r="0.5" fill="#333333"/>
|
||||
<circle cx="4" cy="12" r="0.5" fill="#333333"/>
|
||||
</svg>
|
After Width: | Height: | Size: 512 B |
@ -0,0 +1,3 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M13.9914 7.65008C13.9385 8.74585 13.6333 9.61691 13.0758 10.2632C12.5231 10.9053 11.7782 11.2263 10.8411 11.2263C10.4277 11.2263 10.0697 11.1471 9.76692 10.9888C9.36107 10.7731 8.75554 10.7179 8.35072 10.9355C8.01401 11.1166 7.63301 11.2071 7.20774 11.2071C6.50606 11.2071 5.96299 10.9438 5.57851 10.4173C5.19403 9.89085 5.04986 9.19529 5.14597 8.33066C5.23248 7.6244 5.43193 6.99732 5.74432 6.44944C6.06151 5.89727 6.46041 5.47352 6.941 5.17817C7.4216 4.88283 7.94065 4.73515 8.49814 4.73515C9.18539 4.73515 9.77172 4.8764 10.2571 5.15891C10.5347 5.32765 10.6909 5.64063 10.6589 5.9639L10.3436 9.14607C10.2956 9.48422 10.3364 9.74318 10.4662 9.92295C10.6008 10.1027 10.8122 10.1926 11.1006 10.1926C11.5427 10.1926 11.9128 9.96362 12.2108 9.50562C12.5087 9.04334 12.6721 8.43981 12.701 7.69502C12.7827 6.20118 12.4438 5.05404 11.6845 4.25361C10.93 3.4489 9.81017 3.04655 8.32512 3.04655C7.39757 3.04655 6.57095 3.25629 5.84524 3.67576C5.11954 4.09524 4.54763 4.69235 4.12951 5.46709C3.71139 6.23756 3.4759 7.12146 3.42303 8.11878C3.34614 9.63403 3.68736 10.8047 4.44671 11.6308C5.20605 12.4612 6.34266 12.8764 7.85654 12.8764C8.25544 12.8764 8.67356 12.8357 9.1109 12.7544C9.30727 12.7198 9.49152 12.6796 9.66367 12.6338C9.97711 12.5504 10.3193 12.7162 10.4059 13.0288C10.4712 13.2644 10.3702 13.5177 10.1426 13.607C9.91108 13.6978 9.63927 13.7753 9.32717 13.8395C8.83215 13.9465 8.33233 14 7.82771 14C6.55893 14 5.47759 13.771 4.58368 13.313C3.68977 12.8593 3.02174 12.1873 2.57959 11.297C2.14224 10.4109 1.95241 9.35153 2.01008 8.11878C2.06775 6.9374 2.37053 5.87801 2.91841 4.94061C3.46629 4.00321 4.21121 3.27983 5.15318 2.77047C6.09996 2.25682 7.16689 2 8.35396 2C9.56026 2 10.5983 2.23114 11.4682 2.69342C12.3381 3.15142 12.9893 3.80845 13.4219 4.66453C13.8544 5.5206 14.0442 6.51578 13.9914 7.65008ZM6.74636 8.33066C6.6935 8.89567 6.74877 9.32584 6.91217 9.62119C7.07557 9.91225 7.3399 10.0578 7.70515 10.0578C7.94065 10.0578 8.16412 9.96576 8.37559 9.7817C8.49637 9.67658 8.60539 9.54492 8.70265 9.38673C8.85844 9.13336 8.91682 8.83533 8.94633 8.53936L9.1466 6.53052C9.18027 6.19282 8.96727 5.86517 8.6279 5.86517C8.07521 5.86517 7.64508 6.07491 7.3375 6.49438C7.03472 6.91386 6.83768 7.52595 6.74636 8.33066Z" fill="#333333"/>
|
||||
</svg>
|
After Width: | Height: | Size: 2.3 KiB |
@ -0,0 +1,3 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M2.201 6.4H3.001V12H2.081V7.384L0.953 7.704L0.729 6.92L2.201 6.4ZM3.91156 12V11.1L6.35156 8.61C6.9449 8.01667 7.24156 7.50333 7.24156 7.07C7.24156 6.73 7.13823 6.46667 6.93156 6.28C6.73156 6.08667 6.4749 5.99 6.16156 5.99C5.5749 5.99 5.14156 6.28 4.86156 6.86L3.89156 6.29C4.11156 5.82333 4.42156 5.47 4.82156 5.23C5.22156 4.99 5.6649 4.87 6.15156 4.87C6.7649 4.87 7.29156 5.06333 7.73156 5.45C8.17156 5.83667 8.39156 6.36333 8.39156 7.03C8.39156 7.74333 7.9949 8.50333 7.20156 9.31L5.62156 10.89H8.52156V12H3.91156ZM12.9025 7.032C13.5105 7.176 14.0025 7.46 14.3785 7.884C14.7625 8.3 14.9545 8.824 14.9545 9.456C14.9545 10.296 14.6705 10.956 14.1025 11.436C13.5345 11.916 12.8385 12.156 12.0145 12.156C11.3745 12.156 10.7985 12.008 10.2865 11.712C9.78253 11.416 9.41853 10.984 9.19453 10.416L10.3705 9.732C10.6185 10.452 11.1665 10.812 12.0145 10.812C12.4945 10.812 12.8745 10.692 13.1545 10.452C13.4345 10.204 13.5745 9.872 13.5745 9.456C13.5745 9.04 13.4345 8.712 13.1545 8.472C12.8745 8.232 12.4945 8.112 12.0145 8.112H11.7025L11.1505 7.284L12.9625 4.896H9.44653V3.6H14.6065V4.776L12.9025 7.032Z" fill="#333333"/>
|
||||
</svg>
|
After Width: | Height: | Size: 1.2 KiB |
@ -0,0 +1,4 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M12.4742 9.35161C12.8007 8.99566 13 8.52111 13 8C13 6.89543 12.1046 6 11 6C9.89543 6 9 6.89543 9 8C9 9.04413 9.80011 9.90137 10.8207 9.99207L10.0124 11.1682L10.8365 11.7346L12.4742 9.35161Z" fill="#333333"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M6.47395 7.35186C6.80061 6.99588 7 6.52123 7 6C7 4.89543 6.10457 4 5 4C3.89543 4 3 4.89543 3 6C3 7.04411 3.80008 7.90134 4.82061 7.99206L4.01231 9.16823L4.83645 9.73461L6.47395 7.35186Z" fill="#333333"/>
|
||||
</svg>
|
After Width: | Height: | Size: 613 B |
Before Width: | Height: | Size: 202 B After Width: | Height: | Size: 202 B |
@ -0,0 +1,6 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M10 5L13 8L10 11" stroke="#333333" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<rect x="3" y="5" width="4" height="1" rx="0.5" fill="#333333"/>
|
||||
<rect x="3" y="7.5" width="6" height="1" rx="0.5" fill="#333333"/>
|
||||
<rect x="3" y="10" width="4" height="1" rx="0.5" fill="#333333"/>
|
||||
</svg>
|
After Width: | Height: | Size: 394 B |
@ -0,0 +1,4 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M7.99994 3C6.78324 3 5.57768 3.89295 5.26683 5.05868C5.14348 5.52122 5.16418 6.01807 5.36988 6.5H6.53454C6.50039 6.45893 6.46931 6.41816 6.44111 6.37779C6.18329 6.00862 6.14534 5.64531 6.23306 5.31634C6.4222 4.60706 7.21665 4 7.99994 4C8.69325 4 9.21448 4.21587 9.55371 4.46532C9.7248 4.59113 9.84481 4.72187 9.91824 4.83203C9.98388 4.93049 9.99678 4.98806 9.99929 4.99927C9.99983 5.00168 9.99989 5.00194 9.99989 5H10.9999C10.9999 4.73903 10.8893 4.4858 10.7503 4.27735C10.605 4.05938 10.4 3.84637 10.1461 3.65968C9.63538 3.28413 8.90664 3 7.99994 3ZM10.63 9.5H9.4653C9.49948 9.54108 9.53057 9.58188 9.55877 9.62226C9.8166 9.99142 9.85455 10.3547 9.76683 10.6837C9.57769 11.393 8.78324 12 7.99994 12C7.30664 12 6.78541 11.7842 6.44617 11.5347C6.27508 11.4089 6.15508 11.2781 6.08165 11.168C6.01601 11.0695 6.00311 11.012 6.0006 11.0008C6.00006 10.9983 6 10.9981 6 11H5C5 11.261 5.11062 11.5142 5.24958 11.7227C5.39489 11.9406 5.59988 12.1537 5.85377 12.3403C6.36451 12.7159 7.09325 13 7.99994 13C9.21665 13 10.4222 12.1071 10.7331 10.9414C10.8564 10.4788 10.8357 9.98194 10.63 9.5Z" fill="#333333"/>
|
||||
<rect width="8" height="1" transform="matrix(1 0 0 -1 4 8.5)" fill="#333333"/>
|
||||
</svg>
|
After Width: | Height: | Size: 1.3 KiB |
4
frontend/appflowy_tauri/src/appflowy_app/assets/text.svg
Normal file
@ -0,0 +1,4 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M7.15625 11.8359L6.43768 9.85414H2.46662L1.74805 11.8359H0.5L3.7903 3H5.11399L8.4043 11.8359H7.15625ZM2.87003 8.75596H6.03427L4.44584 4.40112L2.87003 8.75596Z" fill="#333333"/>
|
||||
<path d="M14.4032 5.52454H15.5V11.8359H14.4032V10.7504C13.8569 11.5835 13.0627 12 12.0206 12C11.1381 12 10.386 11.6802 9.76403 11.0407C9.14211 10.3927 8.83114 9.60589 8.83114 8.68022C8.83114 7.75456 9.14211 6.97195 9.76403 6.3324C10.386 5.68443 11.1381 5.36045 12.0206 5.36045C13.0627 5.36045 13.8569 5.777 14.4032 6.6101V5.52454ZM12.1593 10.9397C12.798 10.9397 13.3317 10.7251 13.7603 10.2959C14.1889 9.85835 14.4032 9.31978 14.4032 8.68022C14.4032 8.04067 14.1889 7.50631 13.7603 7.07714C13.3317 6.63955 12.798 6.42076 12.1593 6.42076C11.5289 6.42076 10.9995 6.63955 10.5708 7.07714C10.1422 7.50631 9.92791 8.04067 9.92791 8.68022C9.92791 9.31978 10.1422 9.85835 10.5708 10.2959C10.9995 10.7251 11.5289 10.9397 12.1593 10.9397Z" fill="#333333"/>
|
||||
</svg>
|
After Width: | Height: | Size: 1.0 KiB |
@ -0,0 +1,4 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M6.5 8L8.11538 9.5L13.5 4.5" stroke="#333333" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M13 8.5V11.8889C13 12.1836 12.8829 12.4662 12.6746 12.6746C12.4662 12.8829 12.1836 13 11.8889 13H4.11111C3.81643 13 3.53381 12.8829 3.32544 12.6746C3.11706 12.4662 3 12.1836 3 11.8889V4.11111C3 3.81643 3.11706 3.53381 3.32544 3.32544C3.53381 3.11706 3.81643 3 4.11111 3H10.2222" stroke="#333333" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
After Width: | Height: | Size: 561 B |
@ -0,0 +1,4 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="4" y="12" width="8" height="1" fill="currentColor"/>
|
||||
<path d="M10.1623 10.2595C9.60377 10.7532 8.88302 11 8 11C7.11698 11 6.39623 10.7532 5.83774 10.2595C5.27925 9.7583 5 9.08883 5 8.25105V3H6.30189V8.17251C6.30189 8.65124 6.44151 9.03273 6.72075 9.31697C7.00755 9.60122 7.43396 9.74334 8 9.74334C8.56604 9.74334 8.98868 9.60122 9.26792 9.31697C9.55472 9.03273 9.69811 8.65124 9.69811 8.17251V3H11V8.25105C11 9.08883 10.7208 9.7583 10.1623 10.2595Z" fill="currentColor"/>
|
||||
</svg>
|
After Width: | Height: | Size: 584 B |
@ -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<HTMLDivElement>(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}
|
||||
<div
|
||||
ref={shadowRef}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: draggingPosition?.y,
|
||||
left: draggingPosition?.x,
|
||||
pointerEvents: 'none',
|
||||
opacity: dragShadowVisible ? 1 : 0,
|
||||
zIndex: 2000,
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default BlockDragDropContext;
|
@ -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,
|
||||
};
|
||||
}
|
@ -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<HTMLDivElement>,
|
||||
ref: React.Ref<HTMLDivElement>
|
||||
) {
|
||||
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 (
|
||||
<>
|
||||
<div
|
||||
ref={ref}
|
||||
data-draggable-id={id}
|
||||
data-draggable-type={type}
|
||||
onMouseDown={getAnchorEl ? undefined : onDragStart}
|
||||
className={`relative ${className || ''}`}
|
||||
{...props}
|
||||
>
|
||||
{
|
||||
<div
|
||||
style={{
|
||||
visibility: beforeDropping ? 'visible' : 'hidden',
|
||||
}}
|
||||
className={`${commonCls} left-0 top-[-2px] h-[4px]`}
|
||||
/>
|
||||
}
|
||||
|
||||
{children}
|
||||
{
|
||||
<div
|
||||
style={{
|
||||
visibility: childDropping ? 'visible' : 'hidden',
|
||||
}}
|
||||
className={`${commonCls} left-0 top-0 h-[100%] opacity-[0.3]`}
|
||||
/>
|
||||
}
|
||||
{
|
||||
<div
|
||||
style={{
|
||||
visibility: afterDropping ? 'visible' : 'hidden',
|
||||
}}
|
||||
className={`${commonCls} bottom-[-2px] left-0 h-[4px]`}
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(React.forwardRef(BlockDraggable));
|
@ -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'
|
||||
/>
|
||||
|
@ -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();
|
||||
|
@ -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<HTMLTextAreaElement>(null);
|
||||
|
||||
const onTitleChange: FormEventHandler<HTMLTextAreaElement> = (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 (
|
||||
<TextareaAutosize
|
||||
ref={textareaRef}
|
||||
placeholder={t('document.title.placeholder')}
|
||||
className='min-h-[40px] resize-none text-4xl font-bold leading-[50px] caret-text-title'
|
||||
autoCorrect='off'
|
||||
autoFocus
|
||||
value={value}
|
||||
onInput={onTitleChange}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleBreakLine();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(ViewTitleInput);
|
@ -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<HTMLTextAreaElement>(null);
|
||||
function ViewTitle({ view, onTitleChange, onUpdateIcon: onUpdateIconProp, onSplitTitle }: Props) {
|
||||
const [hover, setHover] = useState(false);
|
||||
const [icon, setIcon] = useState<PageIcon | undefined>(view.icon);
|
||||
|
||||
const defaultValue = useRef(view.name);
|
||||
const onTitleChange: FormEventHandler<HTMLTextAreaElement> = (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 (
|
||||
<div className={'flex flex-col'} onMouseEnter={() => setHover(true)} onMouseLeave={() => setHover(false)}>
|
||||
<div
|
||||
className={'flex flex-col justify-end'}
|
||||
onMouseEnter={() => setHover(true)}
|
||||
onMouseLeave={() => setHover(false)}
|
||||
>
|
||||
<ViewBanner icon={icon} hover={hover} onUpdateIcon={onUpdateIcon} />
|
||||
<div className='relative'>
|
||||
<TextareaAutosize
|
||||
ref={textareaRef}
|
||||
placeholder={t('document.title.placeholder')}
|
||||
className='min-h-[40px] resize-none text-4xl font-bold caret-text-title'
|
||||
autoCorrect='off'
|
||||
defaultValue={defaultValue.current}
|
||||
onInput={onTitleChange}
|
||||
/>
|
||||
<ViewTitleInput value={view.name} onChange={onTitleChange} onSplitTitle={onSplitTitle} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
@ -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<HTMLDivElement>) => {
|
||||
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<HTMLDivElement>) => {
|
||||
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<HTMLDivElement>) => {
|
||||
e.stopPropagation();
|
||||
setIsDraggingOver(false);
|
||||
setDropPosition(undefined);
|
||||
};
|
||||
|
||||
const onDragStart = (e: React.DragEvent<HTMLDivElement>) => {
|
||||
if (!dragId) return;
|
||||
e.stopPropagation();
|
||||
e.dataTransfer.setData('dragId', dragId);
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
setIsDragging(true);
|
||||
};
|
||||
|
||||
const onDragEnd = (e: React.DragEvent<HTMLDivElement>) => {
|
||||
e.stopPropagation();
|
||||
setIsDragging(false);
|
||||
setIsDraggingOver(false);
|
||||
setDropPosition(undefined);
|
||||
};
|
||||
|
||||
return {
|
||||
onDrop,
|
||||
onDragOver,
|
||||
onDragLeave,
|
||||
onDragStart,
|
||||
isDraggingOver,
|
||||
isDragging,
|
||||
onDragEnd,
|
||||
dropPosition,
|
||||
};
|
||||
}
|
@ -0,0 +1 @@
|
||||
export * from './drag.hooks';
|
@ -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,
|
||||
|
@ -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<HTMLDivElement, HTMLDivElement>;
|
||||
itemClassName?: string;
|
||||
renderItem: (index: number) => React.ReactNode;
|
||||
getItemStyle?: (index: number) => CSSProperties | undefined;
|
||||
}
|
||||
|
||||
export const VirtualizedList: FC<VirtualizedListProps> = ({
|
||||
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 (
|
||||
<div className={className} style={style}>
|
||||
{before > 0 && <div style={{ [sizeProp]: before }} />}
|
||||
{virtualItems.map((virtualItem) => {
|
||||
const { key, index, size } = virtualItem;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
ref={virtualizer.measureElement}
|
||||
className={itemClassName}
|
||||
style={{
|
||||
...getItemStyle?.(index),
|
||||
...(horizontal ? { [sizeProp]: size } : undefined),
|
||||
}}
|
||||
data-key={key}
|
||||
data-index={index}
|
||||
>
|
||||
{renderItem(index)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{after > 0 && <div style={{ [sizeProp]: after }} />}
|
||||
</div>
|
||||
);
|
||||
};
|
@ -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);
|
||||
|
@ -2,5 +2,4 @@ export * from './constants';
|
||||
|
||||
export * from './dnd';
|
||||
|
||||
export * from './VirtualizedList';
|
||||
export * from './CellText';
|
||||
|
@ -50,16 +50,13 @@ function EditRecord({ rowId }: Props) {
|
||||
void loadPage();
|
||||
}, [loadPage]);
|
||||
|
||||
const getDocumentTitle = useCallback(() => {
|
||||
return row ? <RecordHeader page={page} row={row} /> : null;
|
||||
}, [row, page]);
|
||||
|
||||
if (!id) return null;
|
||||
if (!id || !page) return null;
|
||||
|
||||
return (
|
||||
<div className={'h-full px-12 py-6'}>
|
||||
{page && <RecordDocument getDocumentTitle={getDocumentTitle} documentId={id} />}
|
||||
</div>
|
||||
<>
|
||||
<RecordHeader page={page} row={row} />
|
||||
<RecordDocument documentId={id} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -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',
|
||||
}}
|
||||
>
|
||||
<IconButton
|
||||
@ -34,7 +34,7 @@ function ExpandRecordModal({ open, onClose, rowId }: Props) {
|
||||
>
|
||||
<DetailsIcon />
|
||||
</IconButton>
|
||||
<DialogContent>
|
||||
<DialogContent className={'p-0'}>
|
||||
<EditRecord rowId={rowId} />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
@ -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 (
|
||||
<div className={'-ml-[72px] h-full min-h-[200px] w-[calc(100%+144px)]'}>
|
||||
<Document getDocumentTitle={getDocumentTitle} containerType={ContainerType.EditRecord} documentId={documentId} />
|
||||
</div>
|
||||
);
|
||||
function RecordDocument({ documentId }: Props) {
|
||||
return <Editor id={documentId} />;
|
||||
}
|
||||
|
||||
export default React.memo(RecordDocument);
|
||||
|
@ -28,7 +28,7 @@ function RecordHeader({ page, row }: Props) {
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div ref={ref} className={'pb-4'}>
|
||||
<div ref={ref} className={'px-16 pb-4'}>
|
||||
<RecordTitle page={page} row={row} />
|
||||
<RecordProperties documentId={page?.id} row={row} />
|
||||
<Divider />
|
||||
|
@ -13,7 +13,7 @@ interface Props extends HTMLAttributes<HTMLDivElement> {
|
||||
}
|
||||
|
||||
function PropertyList(
|
||||
{ documentId, properties, rowId, placeholderNode, openMenuPropertyId, setOpenMenuPropertyId, ...props }: Props,
|
||||
{ properties, rowId, placeholderNode, openMenuPropertyId, setOpenMenuPropertyId, ...props }: Props,
|
||||
ref: React.ForwardedRef<HTMLDivElement>
|
||||
) {
|
||||
const [hoverId, setHoverId] = useState<string | null>(null);
|
||||
@ -24,24 +24,11 @@ function PropertyList(
|
||||
return (
|
||||
<Draggable key={field.id} draggableId={field.id} index={index}>
|
||||
{(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 (
|
||||
<Property
|
||||
ref={provided.innerRef}
|
||||
{...provided.draggableProps}
|
||||
{...provided.dragHandleProps}
|
||||
style={{
|
||||
...provided.draggableProps.style,
|
||||
left: 'auto !important',
|
||||
top: top !== undefined ? top : undefined,
|
||||
}}
|
||||
onHover={setHoverId}
|
||||
ishovered={field.id === hoverId}
|
||||
field={field}
|
||||
|
@ -2,7 +2,7 @@ import React, { useMemo, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { MenuItem, Menu } from '@mui/material';
|
||||
import { ReactComponent as MoreSvg } from '$app/assets/more.svg';
|
||||
import { ReactComponent as SelectCheckSvg } from '$app/assets/database/select-check.svg';
|
||||
import { ReactComponent as SelectCheckSvg } from '$app/assets/select-check.svg';
|
||||
|
||||
import { DateFormatPB } from '@/services/backend';
|
||||
|
||||
|
@ -3,7 +3,7 @@ import { TimeFormatPB } from '@/services/backend';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Menu, MenuItem } from '@mui/material';
|
||||
import { ReactComponent as MoreSvg } from '$app/assets/more.svg';
|
||||
import { ReactComponent as SelectCheckSvg } from '$app/assets/database/select-check.svg';
|
||||
import { ReactComponent as SelectCheckSvg } from '$app/assets/select-check.svg';
|
||||
|
||||
interface Props {
|
||||
value: TimeFormatPB;
|
||||
|
@ -2,7 +2,7 @@ import React from 'react';
|
||||
import { NumberFormatPB } from '@/services/backend';
|
||||
import { Menu, MenuItem, MenuProps } from '@mui/material';
|
||||
import { formats } from '$app/components/database/components/field_types/number/const';
|
||||
import { ReactComponent as SelectCheckSvg } from '$app/assets/database/select-check.svg';
|
||||
import { ReactComponent as SelectCheckSvg } from '$app/assets/select-check.svg';
|
||||
|
||||
function NumberFormatMenu({
|
||||
value,
|
||||
|
@ -3,7 +3,7 @@ import { t } from 'i18next';
|
||||
import { Divider, ListSubheader, MenuItem, MenuList, MenuProps, OutlinedInput } from '@mui/material';
|
||||
import { SelectOptionColorPB } from '@/services/backend';
|
||||
import { ReactComponent as DeleteSvg } from '$app/assets/delete.svg';
|
||||
import { ReactComponent as SelectCheckSvg } from '$app/assets/database/select-check.svg';
|
||||
import { ReactComponent as SelectCheckSvg } from '$app/assets/select-check.svg';
|
||||
import { SelectOption } from '../../../application';
|
||||
import { SelectOptionColorMap, SelectOptionColorTextMap } from './constants';
|
||||
import Button from '@mui/material/Button';
|
||||
|
@ -4,7 +4,7 @@ import { ReactComponent as DetailsSvg } from '$app/assets/details.svg';
|
||||
import { SelectOption } from '../../../../application';
|
||||
import { SelectOptionMenu } from '../SelectOptionMenu';
|
||||
import { Tag } from '../Tag';
|
||||
import { ReactComponent as SelectCheckSvg } from '$app/assets/database/select-check.svg';
|
||||
import { ReactComponent as SelectCheckSvg } from '$app/assets/select-check.svg';
|
||||
|
||||
export interface SelectOptionItemProps {
|
||||
option: SelectOption;
|
||||
|
@ -7,7 +7,7 @@ import {
|
||||
} from '$app/components/database/application';
|
||||
import { MenuItem, MenuList } from '@mui/material';
|
||||
import { Tag } from '$app/components/database/components/field_types/select/Tag';
|
||||
import { ReactComponent as SelectCheckSvg } from '$app/assets/database/select-check.svg';
|
||||
import { ReactComponent as SelectCheckSvg } from '$app/assets/select-check.svg';
|
||||
import { SelectOptionConditionPB } from '@/services/backend';
|
||||
import { useTypeOption } from '$app/components/database';
|
||||
|
||||
|
@ -3,7 +3,7 @@ import { FC, useMemo } from 'react';
|
||||
import { FieldType } from '@/services/backend';
|
||||
import { PropertyTypeText, ProppertyTypeSvg } from '$app/components/database/components/property';
|
||||
import { Field } from '$app/components/database/application';
|
||||
import { ReactComponent as SelectCheckSvg } from '$app/assets/database/select-check.svg';
|
||||
import { ReactComponent as SelectCheckSvg } from '$app/assets/select-check.svg';
|
||||
|
||||
const FieldTypeGroup = [
|
||||
{
|
||||
|
@ -32,17 +32,13 @@ export const GridCell = memo(({ row, column, columnIndex, style, onEditRecord, g
|
||||
|
||||
switch (row.type) {
|
||||
case RenderRowType.Row: {
|
||||
const renderRowCell = <Cell rowId={row.data.meta.id} icon={row.data.meta.icon} field={field} />;
|
||||
const { id: rowId, icon: rowIcon } = row.data.meta;
|
||||
const renderRowCell = <Cell rowId={rowId} icon={rowIcon} field={field} />;
|
||||
|
||||
return (
|
||||
<div data-key={key} style={style} className={'grid-cell flex border-b border-r border-line-divider'}>
|
||||
{field.isPrimary ? (
|
||||
<PrimaryCell
|
||||
icon={row.data.meta.icon}
|
||||
onEditRecord={onEditRecord}
|
||||
getContainerRef={getContainerRef}
|
||||
rowId={row.data.meta.id}
|
||||
>
|
||||
<PrimaryCell icon={rowIcon} onEditRecord={onEditRecord} getContainerRef={getContainerRef} rowId={rowId}>
|
||||
{renderRowCell}
|
||||
</PrimaryCell>
|
||||
) : (
|
||||
|
@ -47,6 +47,7 @@ export const GridField: FC<GridFieldProps> = memo(
|
||||
scrollOnEdge: {
|
||||
direction: ScrollDirection.Horizontal,
|
||||
getScrollElement,
|
||||
edgeGap: 80,
|
||||
},
|
||||
});
|
||||
|
||||
|
@ -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) {
|
||||
|
@ -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;
|
@ -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;
|
||||
}
|
@ -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<number[]>([]);
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
@ -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 <div className='z-99 absolute bg-fill-default opacity-10' style={style} />;
|
||||
}
|
||||
|
||||
export default BlockRectSelection;
|
@ -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;
|
||||
}
|
@ -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]
|
||||
);
|
||||
}
|
@ -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 (
|
||||
<div className='appflowy-block-selection-overlay z-1 pointer-events-none fixed inset-0 overflow-hidden'>
|
||||
<BlockRectSelection getIntersectedBlockIds={getIntersectedBlockIds} container={container} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(BlockSelection);
|
@ -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,
|
||||
};
|
||||
}
|
@ -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<void>;
|
||||
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<BlockMenuOption | null>(null);
|
||||
const { t } = useTranslation();
|
||||
|
||||
useEffect(() => {
|
||||
if (hovered !== BlockMenuOption.TurnInto) {
|
||||
setSubMenuOpened(false);
|
||||
}
|
||||
}, [hovered]);
|
||||
|
||||
const handleClick = useCallback(
|
||||
async ({ operate }: { operate: () => Promise<void> }) => {
|
||||
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: <Delete />,
|
||||
key: BlockMenuOption.Delete,
|
||||
},
|
||||
{
|
||||
operate: () => {
|
||||
return handleClick({ operate: handleDuplicate });
|
||||
},
|
||||
title: t('document.plugins.optionAction.duplicate'),
|
||||
icon: <ContentCopy />,
|
||||
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 (
|
||||
<div
|
||||
tabIndex={1}
|
||||
onKeyDown={onKeyDown}
|
||||
onMouseDown={(e) => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<div className={'p-2'}>
|
||||
<TextField
|
||||
autoFocus
|
||||
label={t('search.label')}
|
||||
placeholder={t('search.placeholder.actions')}
|
||||
variant='standard'
|
||||
/>
|
||||
</div>
|
||||
{options.map((option) => {
|
||||
if (option.key === BlockMenuOption.TurnInto) {
|
||||
return (
|
||||
<BlockMenuTurnInto
|
||||
key={option.key}
|
||||
label={option.title}
|
||||
onHovered={() => {
|
||||
setHovered(BlockMenuOption.TurnInto);
|
||||
setSubMenuOpened(true);
|
||||
}}
|
||||
menuOpened={subMenuOpened}
|
||||
isHovered={hovered === BlockMenuOption.TurnInto}
|
||||
onClose={() => {
|
||||
setSubMenuOpened(false);
|
||||
onClose();
|
||||
}}
|
||||
id={id}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<MenuItem
|
||||
key={option.key}
|
||||
title={option.title}
|
||||
icon={option.icon}
|
||||
isHovered={hovered === option.key}
|
||||
onClick={option.operate}
|
||||
onHover={() => {
|
||||
setHovered(option.key);
|
||||
setSubMenuOpened(false);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default BlockMenu;
|
@ -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<HTMLDivElement | null>(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 (
|
||||
<>
|
||||
<MenuItem
|
||||
ref={ref}
|
||||
title={label}
|
||||
isHovered={isHovered}
|
||||
icon={<Transform />}
|
||||
extra={<ArrowRight />}
|
||||
onHover={(e) => {
|
||||
onHovered(e);
|
||||
}}
|
||||
/>
|
||||
<TurnIntoPopover
|
||||
id={id}
|
||||
open={open}
|
||||
disableRestoreFocus
|
||||
disableAutoFocus
|
||||
sx={{
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
PaperProps={{
|
||||
style: {
|
||||
pointerEvents: 'auto',
|
||||
},
|
||||
}}
|
||||
onOk={() => onClose()}
|
||||
onClose={() => {
|
||||
setAnchorPosition(undefined);
|
||||
}}
|
||||
anchorReference={'anchorPosition'}
|
||||
anchorPosition={anchorPosition}
|
||||
transformOrigin={{
|
||||
vertical: 'center',
|
||||
horizontal: 'left',
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default BlockMenuTurnInto;
|
@ -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<number, string> = {
|
||||
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<HTMLDivElement | null>(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<HTMLButtonElement>) => {
|
||||
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<HTMLDivElement>) => {
|
||||
e.stopPropagation();
|
||||
}, []);
|
||||
|
||||
return {
|
||||
anchorPosition,
|
||||
onClose,
|
||||
open,
|
||||
handleOpen,
|
||||
anchorReference: 'anchorPosition' as const,
|
||||
transformOrigin,
|
||||
onMouseDown,
|
||||
disableRestoreFocus: true,
|
||||
disableAutoFocus: true,
|
||||
disableEnforceFocus: true,
|
||||
};
|
||||
}
|
@ -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 (
|
||||
<>
|
||||
<div
|
||||
style={{
|
||||
opacity: show ? 1 : 0,
|
||||
top: topOffset,
|
||||
}}
|
||||
className='absolute left-[-50px] inline-flex'
|
||||
>
|
||||
{/** Add Block below */}
|
||||
<Tooltip disableInteractive={true} title={t('blockActions.addBelowTooltip')} placement={'top-start'}>
|
||||
<IconButton
|
||||
style={{
|
||||
pointerEvents: show ? 'auto' : 'none',
|
||||
}}
|
||||
onClick={(_: React.MouseEvent<HTMLButtonElement>) => {
|
||||
void dispatch(
|
||||
addBlockBelowClickThunk({
|
||||
id,
|
||||
controller,
|
||||
})
|
||||
);
|
||||
}}
|
||||
sx={{
|
||||
height: 24,
|
||||
width: 24,
|
||||
}}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<AddSharpIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
|
||||
{/** Open menu or drag */}
|
||||
<Tooltip
|
||||
disableInteractive={true}
|
||||
title={
|
||||
<div className={'flex flex-col items-center justify-center'}>
|
||||
<div>{t('blockActions.dragTooltip')}</div>
|
||||
<div>{t('blockActions.openMenuTooltip')}</div>
|
||||
</div>
|
||||
}
|
||||
placement={'top-start'}
|
||||
>
|
||||
<IconButton
|
||||
style={{
|
||||
pointerEvents: show ? 'auto' : 'none',
|
||||
}}
|
||||
data-draggable-anchor={id}
|
||||
onClick={async (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
handleOpen(e);
|
||||
await dispatch(
|
||||
setRectSelectionThunk({
|
||||
docId,
|
||||
selection: [id],
|
||||
})
|
||||
);
|
||||
}}
|
||||
sx={{
|
||||
height: 24,
|
||||
width: 24,
|
||||
}}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<DragIndicatorRoundedIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<Popover open={open} {...popoverProps}>
|
||||
<BlockMenu id={id} onClose={popoverProps.onClose} />
|
||||
</Popover>
|
||||
</>
|
||||
);
|
||||
}
|
@ -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: <TextFields />,
|
||||
group: SlashCommandGroup.BASIC,
|
||||
},
|
||||
{
|
||||
key: SlashCommandOptionKey.HEADING_1,
|
||||
type: BlockType.HeadingBlock,
|
||||
title: t('editor.heading1'),
|
||||
icon: <Title />,
|
||||
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;
|
@ -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,
|
||||
};
|
||||
}
|
@ -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;
|
@ -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;
|
@ -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,
|
||||
};
|
||||
}
|
@ -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>
|
||||
);
|
||||
}
|
@ -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;
|
@ -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>
|
||||
);
|
||||
});
|
@ -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;
|
||||
}
|
@ -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>
|
||||
);
|
||||
}
|
@ -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;
|
@ -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,
|
||||
};
|
||||
}
|
@ -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;
|
@ -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;
|
@ -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;
|
@ -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;
|
@ -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;
|
@ -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;
|
@ -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);
|
@ -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;
|
@ -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;
|
@ -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)];
|
||||
};
|
@ -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;
|
@ -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,
|
||||
};
|
||||
}
|
@ -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>
|
||||
);
|
||||
}
|