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
This commit is contained in:
Kilu.He 2023-12-18 17:44:47 +08:00 committed by GitHub
parent eef34caf27
commit 0783f94cd6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
437 changed files with 8277 additions and 16748 deletions

View File

@ -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']
};

View File

@ -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?)$",
};

View File

@ -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",

View File

@ -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:

View File

@ -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';

View File

@ -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();

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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);
}

View 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="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

View File

@ -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

View File

@ -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

View File

@ -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

View 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

View 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

View 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

View 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

View 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

View 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="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

View 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="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

View 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="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

View 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

View 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.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

View 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

View 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="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

View 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="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

View 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 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

View 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="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

View 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 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

View 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

View 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="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

View File

@ -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

View File

@ -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;

View File

@ -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,
};
}

View File

@ -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));

View File

@ -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'
/>

View File

@ -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();

View File

@ -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);

View File

@ -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>
);

View File

@ -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,
};
}

View File

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

View File

@ -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,

View File

@ -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>
);
};

View File

@ -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);

View File

@ -2,5 +2,4 @@ export * from './constants';
export * from './dnd';
export * from './VirtualizedList';
export * from './CellText';

View File

@ -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} />
</>
);
}

View File

@ -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>

View File

@ -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);

View File

@ -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 />

View File

@ -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}

View File

@ -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';

View File

@ -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;

View File

@ -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,

View File

@ -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';

View File

@ -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;

View File

@ -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';

View File

@ -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 = [
{

View File

@ -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>
) : (

View File

@ -47,6 +47,7 @@ export const GridField: FC<GridFieldProps> = memo(
scrollOnEdge: {
direction: ScrollDirection.Horizontal,
getScrollElement,
edgeGap: 80,
},
});

View File

@ -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) {

View File

@ -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;

View File

@ -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;
}

View File

@ -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,
};
}

View File

@ -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;

View File

@ -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;
}

View File

@ -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]
);
}

View File

@ -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);

View File

@ -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,
};
}

View File

@ -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;

View File

@ -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;

View File

@ -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,
};
}

View File

@ -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>
</>
);
}

View File

@ -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;

View File

@ -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,
};
}

View File

@ -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;

View File

@ -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;

View File

@ -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,
};
}

View File

@ -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>
);
}

View File

@ -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;

View File

@ -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>
);
});

View File

@ -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;
}

View File

@ -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>
);
}

View File

@ -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;

View File

@ -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,
};
}

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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);

View File

@ -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;

View File

@ -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;

View File

@ -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)];
};

View File

@ -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;

View File

@ -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,
};
}

View File

@ -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>
);
}

Some files were not shown because too many files have changed in this diff Show More