Refactor text block delta and across block selection (#2671)

* fix: add block menu comment

* refactor: separation of abstract delta and editor, and optimization across block selections
This commit is contained in:
Kilu.He 2023-06-02 10:05:38 +08:00 committed by GitHub
parent 33e0f8d26d
commit 8cee792b94
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
99 changed files with 3497 additions and 2636 deletions

View File

@ -22,6 +22,7 @@
"@mui/icons-material": "^5.11.11",
"@mui/material": "^5.11.12",
"@reduxjs/toolkit": "^1.9.2",
"@slate-yjs/core": "^1.0.0",
"@tanstack/react-virtual": "3.0.0-beta.54",
"@tauri-apps/api": "^1.2.0",
"dayjs": "^1.11.7",
@ -35,6 +36,8 @@
"nanoid": "^4.0.0",
"prismjs": "^1.29.0",
"protoc-gen-ts": "^0.8.5",
"quill": "^1.3.7",
"quill-delta": "^5.1.0",
"react": "^18.2.0",
"react-beautiful-dnd": "^13.1.1",
"react-calendar": "^4.1.0",
@ -46,8 +49,8 @@
"react18-input-otp": "^1.1.2",
"redux": "^4.2.1",
"rxjs": "^7.8.0",
"slate": "^0.91.4",
"slate-react": "^0.91.9",
"slate": "^0.94.1",
"slate-react": "^0.94.2",
"ts-results": "^3.3.0",
"utf8": "^3.0.0",
"y-indexeddb": "^9.0.9",
@ -59,6 +62,7 @@
"@types/is-hotkey": "^0.1.7",
"@types/node": "^18.7.10",
"@types/prismjs": "^1.26.0",
"@types/quill": "^2.0.10",
"@types/react": "^18.0.15",
"@types/react-beautiful-dnd": "^13.1.3",
"@types/react-dom": "^18.0.6",

View File

@ -22,6 +22,9 @@ dependencies:
'@reduxjs/toolkit':
specifier: ^1.9.2
version: 1.9.5(react-redux@8.0.5)(react@18.2.0)
'@slate-yjs/core':
specifier: ^1.0.0
version: 1.0.0(slate@0.94.1)(yjs@13.6.1)
'@tanstack/react-virtual':
specifier: 3.0.0-beta.54
version: 3.0.0-beta.54(react@18.2.0)
@ -61,6 +64,12 @@ dependencies:
protoc-gen-ts:
specifier: ^0.8.5
version: 0.8.6(google-protobuf@3.21.2)(typescript@4.9.5)
quill:
specifier: ^1.3.7
version: 1.3.7
quill-delta:
specifier: ^5.1.0
version: 5.1.0
react:
specifier: ^18.2.0
version: 18.2.0
@ -95,11 +104,11 @@ dependencies:
specifier: ^7.8.0
version: 7.8.1
slate:
specifier: ^0.91.4
version: 0.91.4
specifier: ^0.94.1
version: 0.94.1
slate-react:
specifier: ^0.91.9
version: 0.91.11(react-dom@18.2.0)(react@18.2.0)(slate@0.91.4)
specifier: ^0.94.2
version: 0.94.2(react-dom@18.2.0)(react@18.2.0)(slate@0.94.1)
ts-results:
specifier: ^3.3.0
version: 3.3.0
@ -129,6 +138,9 @@ devDependencies:
'@types/prismjs':
specifier: ^1.26.0
version: 1.26.0
'@types/quill':
specifier: ^2.0.10
version: 2.0.10
'@types/react':
specifier: ^18.0.15
version: 18.2.6
@ -1425,6 +1437,17 @@ packages:
'@sinonjs/commons': 3.0.0
dev: false
/@slate-yjs/core@1.0.0(slate@0.94.1)(yjs@13.6.1):
resolution: {integrity: sha512-G83+qvXtsMTP3kWu216GjhyeHlvKHX5kWaPf2JiG2uF5/YShUqjAVjDr/htKoKJsOl+IqK679lvLKeBYh7SYZQ==}
peerDependencies:
slate: '>=0.70.0'
yjs: ^13.5.29
dependencies:
slate: 0.94.1
y-protocols: 1.0.5
yjs: 13.6.1
dev: false
/@tanstack/react-virtual@3.0.0-beta.54(react@18.2.0):
resolution: {integrity: sha512-D1mDMf4UPbrtHRZZriCly5bXTBMhylslm4dhcHqTtDJ6brQcgGmk8YD9JdWBGWfGSWPKoh2x1H3e7eh+hgPXtQ==}
peerDependencies:
@ -1637,6 +1660,13 @@ packages:
/@types/prop-types@15.7.5:
resolution: {integrity: sha512-JCB8C6SnDoQf0cNycqd/35A7MjcnK+ZTqE7judS6o7utxUCg6imJg3QK2qzHKszlTjcj2cn+NwMB2i96ubpj7w==}
/@types/quill@2.0.10:
resolution: {integrity: sha512-L6OHONEj2v4NRbWQOsn7j1N0SyzhRR3M4g1M6j/uuIwIsIW2ShWHhwbqNvH8hSmVktzqu0lITfdnqVOQ4qkrhA==}
dependencies:
parchment: 1.1.4
quill-delta: 4.2.2
dev: true
/@types/react-beautiful-dnd@13.1.4:
resolution: {integrity: sha512-4bIBdzOr0aavN+88q3C7Pgz+xkb7tz3whORYrmSj77wfVEMfiWiooIwVWFR7KM2e+uGTe5BVrXqSfb0aHeflJA==}
dependencies:
@ -2133,7 +2163,6 @@ packages:
dependencies:
function-bind: 1.1.1
get-intrinsic: 1.2.1
dev: true
/callsites@3.1.0:
resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==}
@ -2210,6 +2239,11 @@ packages:
wrap-ansi: 7.0.0
dev: false
/clone@2.1.2:
resolution: {integrity: sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==}
engines: {node: '>=0.8'}
dev: false
/clsx@1.2.1:
resolution: {integrity: sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg==}
engines: {node: '>=6'}
@ -2313,6 +2347,17 @@ packages:
resolution: {integrity: sha512-Q6fKUPqnAHAyhiUgFU7BUzLiv0kd8saH9al7tnu5Q/okj6dnupxyTgFIBjVzJATdfIAm9NAsvXNzjaKa+bxVyA==}
dev: false
/deep-equal@1.1.1:
resolution: {integrity: sha512-yd9c5AdiqVcR+JjcwUQb9DkhJc8ngNr0MahEBGvDiJw8puWab2yZlh+nkasOnZP+EGTAP6rRp2JzJhJZzvNF8g==}
dependencies:
is-arguments: 1.1.1
is-date-object: 1.0.5
is-regex: 1.1.4
object-is: 1.1.5
object-keys: 1.1.1
regexp.prototype.flags: 1.5.0
dev: false
/deep-is@0.1.4:
resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==}
dev: true
@ -2328,7 +2373,6 @@ packages:
dependencies:
has-property-descriptors: 1.0.0
object-keys: 1.1.1
dev: true
/detect-newline@3.1.0:
resolution: {integrity: sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==}
@ -2661,6 +2705,10 @@ packages:
engines: {node: '>=0.10.0'}
dev: true
/eventemitter3@2.0.3:
resolution: {integrity: sha512-jLN68Dx5kyFHaePoXWPsCGW5qdyZQtLYHkxkg02/Mz6g0kYpDx4FyP6XfArhQdlOC4b8Mv+EMxPo/8La7Tzghg==}
dev: false
/events@3.3.0:
resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==}
engines: {node: '>=0.8.x'}
@ -2697,10 +2745,26 @@ packages:
jest-util: 29.5.0
dev: false
/extend@3.0.2:
resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==}
dev: false
/fast-deep-equal@3.1.3:
resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
dev: true
/fast-diff@1.1.2:
resolution: {integrity: sha512-KaJUt+M9t1qaIteSvjc6P3RbMdXsNhK61GRftR6SNxqmhthcd9MGIi4T+o0jD8LUSpSnSKXE20nLtJ3fOHxQig==}
dev: false
/fast-diff@1.2.0:
resolution: {integrity: sha512-xJuoT5+L99XlZ8twedaRf6Ax2TgQVxvgZOYoPKqZufmJib0tL2tegPBOZb1pVNgIhlqDlA0eO0c3wBvQcmzx4w==}
dev: true
/fast-diff@1.3.0:
resolution: {integrity: sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==}
dev: false
/fast-glob@3.2.12:
resolution: {integrity: sha512-DVj4CQIYYow0BlaelwK1pHl5n5cRSJfM60UA0zK891sVInoPri2Ekj7+e1CT3/3qxXenpI+nBBmQAcJPJgaj4w==}
engines: {node: '>=8.6.0'}
@ -2811,7 +2875,6 @@ packages:
/functions-have-names@1.2.3:
resolution: {integrity: sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==}
dev: true
/gensync@1.0.0-beta.2:
resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==}
@ -2829,7 +2892,6 @@ packages:
has: 1.0.3
has-proto: 1.0.1
has-symbols: 1.0.3
dev: true
/get-package-type@0.1.0:
resolution: {integrity: sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==}
@ -2955,24 +3017,20 @@ packages:
resolution: {integrity: sha512-62DVLZGoiEBDHQyqG4w9xCuZ7eJEwNmJRWw2VY84Oedb7WFcA27fiEVe8oUQx9hAUJ4ekurquucTGwsyO1XGdQ==}
dependencies:
get-intrinsic: 1.2.1
dev: true
/has-proto@1.0.1:
resolution: {integrity: sha512-7qE+iP+O+bgF9clE5+UoBFzE65mlBiVj3tKCrlNQ0Ogwm0BjpT/gK4SlLYDMybDh5I3TCTKnPPa0oMG7JDYrhg==}
engines: {node: '>= 0.4'}
dev: true
/has-symbols@1.0.3:
resolution: {integrity: sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==}
engines: {node: '>= 0.4'}
dev: true
/has-tostringtag@1.0.0:
resolution: {integrity: sha512-kFjcSNhnlGV1kyoGk7OXKSawH5JOb/LzUc5w9B02hOTO0dfFRjbHQKvg1d6cf3HbeUmtU9VbbV3qzZ2Teh97WQ==}
engines: {node: '>= 0.4'}
dependencies:
has-symbols: 1.0.3
dev: true
/has@1.0.3:
resolution: {integrity: sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==}
@ -3060,6 +3118,14 @@ packages:
side-channel: 1.0.4
dev: true
/is-arguments@1.1.1:
resolution: {integrity: sha512-8Q7EARjzEnKpt/PCD7e1cgUS0a6X8u5tdSiMqXhojOdoV9TsMsiO+9VLC5vAmO8N7/GmXn7yjR8qnA6bVAEzfA==}
engines: {node: '>= 0.4'}
dependencies:
call-bind: 1.0.2
has-tostringtag: 1.0.0
dev: false
/is-array-buffer@3.0.2:
resolution: {integrity: sha512-y+FyyR/w8vfIRq4eQcM1EYgSTnmHXPqaF+IgzgraytCFq5Xh8lllDVmAZolPJiZttZLeFSINPYMaEJ7/vWUa1w==}
dependencies:
@ -3108,7 +3174,6 @@ packages:
engines: {node: '>= 0.4'}
dependencies:
has-tostringtag: 1.0.0
dev: true
/is-extglob@2.1.1:
resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==}
@ -3172,7 +3237,6 @@ packages:
dependencies:
call-bind: 1.0.2
has-tostringtag: 1.0.0
dev: true
/is-shared-array-buffer@1.0.2:
resolution: {integrity: sha512-sqN2UDu1/0y6uvXyStCOzyhAjCSlHceFoMKJW8W9EU9cvic/QdsZ0kEU93HEy3IUEFZIiH/3w+AH/UQbPHNdhA==}
@ -3783,6 +3847,12 @@ packages:
p-locate: 5.0.0
dev: true
/lodash.clonedeep@4.5.0:
resolution: {integrity: sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==}
/lodash.isequal@4.5.0:
resolution: {integrity: sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==}
/lodash.memoize@4.1.2:
resolution: {integrity: sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==}
dev: false
@ -3928,10 +3998,17 @@ packages:
resolution: {integrity: sha512-geUvdk7c+eizMNUDkRpW1wJwgfOiOeHbxBR/hLXK1aT6zmVSO0jsQcs7fj6MGw89jC/cjGfLcNOrtMYtGqm81g==}
dev: true
/object-is@1.1.5:
resolution: {integrity: sha512-3cyDsyHgtmi7I7DfSSI2LDp6SK2lwvtbg0p0R1e0RvTqF5ceGx+K2dfSjm1bKDMVCFEDAQvy+o8c6a7VujOddw==}
engines: {node: '>= 0.4'}
dependencies:
call-bind: 1.0.2
define-properties: 1.2.0
dev: false
/object-keys@1.1.1:
resolution: {integrity: sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==}
engines: {node: '>= 0.4'}
dev: true
/object.assign@4.1.4:
resolution: {integrity: sha512-1mxKf0e58bvyjSCtKYY4sRe9itRk3PJpquJOjeIkz885CczcI4IvJJDLPS72oowuSh+pBxUFROpX+TU++hxhZQ==}
@ -4033,6 +4110,9 @@ packages:
engines: {node: '>=6'}
dev: false
/parchment@1.1.4:
resolution: {integrity: sha512-J5FBQt/pM2inLzg4hEWmzQx/8h8D0CiDxaG3vyp9rKrQRSDgBlhjdP5jQGgosEajXPSQouXGHOmVdgo7QmJuOg==}
/parent-module@1.0.1:
resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==}
engines: {node: '>=6'}
@ -4277,6 +4357,43 @@ packages:
resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
dev: true
/quill-delta@3.6.3:
resolution: {integrity: sha512-wdIGBlcX13tCHOXGMVnnTVFtGRLoP0imqxM696fIPwIf5ODIYUHIvHbZcyvGlZFiFhK5XzDC2lpjbxRhnM05Tg==}
engines: {node: '>=0.10'}
dependencies:
deep-equal: 1.1.1
extend: 3.0.2
fast-diff: 1.1.2
dev: false
/quill-delta@4.2.2:
resolution: {integrity: sha512-qjbn82b/yJzOjstBgkhtBjN2TNK+ZHP/BgUQO+j6bRhWQQdmj2lH6hXG7+nwwLF41Xgn//7/83lxs9n2BkTtTg==}
dependencies:
fast-diff: 1.2.0
lodash.clonedeep: 4.5.0
lodash.isequal: 4.5.0
dev: true
/quill-delta@5.1.0:
resolution: {integrity: sha512-X74oCeRI4/p0ucjb5Ma8adTXd9Scumz367kkMK5V/IatcX6A0vlgLgKbzXWy5nZmCGeNJm2oQX0d2Eqj+ZIlCA==}
engines: {node: '>= 12.0.0'}
dependencies:
fast-diff: 1.3.0
lodash.clonedeep: 4.5.0
lodash.isequal: 4.5.0
dev: false
/quill@1.3.7:
resolution: {integrity: sha512-hG/DVzh/TiknWtE6QmWAF/pxoZKYxfe3J/d/+ShUWkDvvkZQVTPeVmUJVu1uE6DDooC4fWTiCLh84ul89oNz5g==}
dependencies:
clone: 2.1.2
deep-equal: 1.1.1
eventemitter3: 2.0.3
extend: 3.0.2
parchment: 1.1.4
quill-delta: 3.6.3
dev: false
/raf-schd@4.0.3:
resolution: {integrity: sha512-tQkJl2GRWh83ui2DiPTJz9wEiMN20syf+5oKfB03yYP7ioZcJwsIK8FjrtLwH1m7C7e+Tt2yYBlrOpdT+dyeIQ==}
dev: false
@ -4519,7 +4636,6 @@ packages:
call-bind: 1.0.2
define-properties: 1.2.0
functions-have-names: 1.2.3
dev: true
/require-directory@2.1.1:
resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==}
@ -4661,8 +4777,8 @@ packages:
resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==}
engines: {node: '>=8'}
/slate-react@0.91.11(react-dom@18.2.0)(react@18.2.0)(slate@0.91.4):
resolution: {integrity: sha512-2nS29rc2kuTTJrEUOXGyTkFATmTEw/R9KuUXadUYiz+UVwuFOUMnBKuwJWyuIBOsFipS+06SkIayEf5CKdARRQ==}
/slate-react@0.94.2(react-dom@18.2.0)(react@18.2.0)(slate@0.94.1):
resolution: {integrity: sha512-4wDSuTuGBkdQ609CS55uc2Yhfa5but21usBgAtCVhPJQazL85kzN2vUUYTmGb7d/mpP9tdnJiVPopIyhqlRJ8Q==}
peerDependencies:
react: '>=16.8.0'
react-dom: '>=16.8.0'
@ -4678,12 +4794,12 @@ packages:
react: 18.2.0
react-dom: 18.2.0(react@18.2.0)
scroll-into-view-if-needed: 2.2.31
slate: 0.91.4
slate: 0.94.1
tiny-invariant: 1.0.6
dev: false
/slate@0.91.4:
resolution: {integrity: sha512-aUJ3rpjrdi5SbJ5G1Qjr3arytfRkEStTmHjBfWq2A2Q8MybacIzkScSvGJjQkdTk3djCK9C9SEOt39sSeZFwTw==}
/slate@0.94.1:
resolution: {integrity: sha512-GH/yizXr1ceBoZ9P9uebIaHe3dC/g6Plpf9nlUwnvoyf6V1UOYrRwkabtOCd3ZfIGxomY4P7lfgLr7FPH8/BKA==}
dependencies:
immer: 9.0.21
is-plain-object: 5.0.0
@ -5154,6 +5270,12 @@ packages:
yjs: 13.6.1
dev: false
/y-protocols@1.0.5:
resolution: {integrity: sha512-Wil92b7cGk712lRHDqS4T90IczF6RkcvCwAD0A2OPg+adKmOe+nOiT/N2hvpQIWS3zfjmtL4CPaH5sIW1Hkm/A==}
dependencies:
lib0: 0.2.74
dev: false
/y18n@5.0.8:
resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==}
engines: {node: '>=10'}

View File

@ -1,31 +1,93 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { getBlockIdByPoint } from '$app/utils/document/blocks/selection';
import { rangeSelectionActions } from '$app_reducers/document/slice';
import { useAppDispatch } from '$app/stores/store';
import { getNodesInRange } from '$app/utils/document/blocks/common';
import { setRangeSelectionThunk } from '$app_reducers/document/async-actions/range_selection';
import { rangeActions } from '$app_reducers/document/slice';
import { useAppDispatch, useAppSelector } from '$app/stores/store';
import {
getBlockIdByPoint,
getNodeTextBoxByBlockId,
isFocused,
setCursorAtEndOfNode,
setCursorAtStartOfNode,
} from '$app/utils/document/node';
import { useRangeKeyDown } from '$app/components/document/BlockSelection/RangeKeyDown.hooks';
export function useBlockRangeSelection(container: HTMLDivElement) {
const dispatch = useAppDispatch();
const onKeyDown = useRangeKeyDown();
const range = useAppSelector((state) => state.documentRange);
const isDragging = range.isDragging;
const anchorRef = useRef<{
id: string;
point: { x: number; y: number };
range?: Range;
} | null>(null);
const [isDragging, setDragging] = useState(false);
const [focus, setFocus] = useState<{
id: string;
point: { x: number; y: number };
} | null>(null);
const [isForward, setForward] = useState(true);
const reset = useCallback(() => {
dispatch(rangeSelectionActions.clearRange());
dispatch(rangeActions.clearRange());
}, [dispatch]);
// display caret color
useEffect(() => {
dispatch(rangeSelectionActions.setDragging(isDragging));
}, [dispatch, isDragging]);
const { anchor, focus } = range;
if (!anchor || !focus) {
container.classList.remove('caret-transparent');
return;
}
// if the focus block is different from the anchor block, we need to set the caret transparent
if (focus.id !== anchor.id) {
container.classList.add('caret-transparent');
} else {
container.classList.remove('caret-transparent');
}
}, [container.classList, range]);
useEffect(() => {
const anchor = anchorRef.current;
if (!anchor || !focus) return;
const selection = window.getSelection();
if (!selection) return;
// update focus point
dispatch(rangeActions.setFocusPoint(focus));
const focused = isFocused(focus.id);
// if the focus block is not focused, we need to set the cursor position
if (!focused) {
// if the focus block is the same as the anchor block, we just update the anchor's range
if (anchor.id === focus.id) {
const range = document.caretRangeFromPoint(
anchor.point.x - container.scrollLeft,
anchor.point.y - container.scrollTop
);
if (!range) return;
const selection = window.getSelection();
selection?.removeAllRanges();
selection?.addRange(range);
return;
}
const node = getNodeTextBoxByBlockId(focus.id);
if (!node) return;
// if the selection is forward, we set the cursor position to the start of the focus block
if (isForward) {
setCursorAtStartOfNode(node);
} else {
// if the selection is backward, we set the cursor position to the end of the focus block
setCursorAtEndOfNode(node);
}
}
}, [container, dispatch, focus, isForward]);
const handleDragStart = useCallback(
(e: MouseEvent) => {
// reset the range
reset();
// skip if the target is not a block
const blockId = getBlockIdByPoint(e.target as HTMLElement);
if (!blockId) {
return;
@ -33,72 +95,76 @@ export function useBlockRangeSelection(container: HTMLDivElement) {
const startX = e.clientX + container.scrollLeft;
const startY = e.clientY + container.scrollTop;
anchorRef.current = {
const anchor = {
id: blockId,
point: {
x: startX,
y: startY,
},
};
setDragging(true);
anchorRef.current = {
...anchor,
};
// set the anchor point and focus point
dispatch(rangeActions.setAnchorPoint({ ...anchor }));
dispatch(rangeActions.setFocusPoint({ ...anchor }));
dispatch(rangeActions.setDragging(true));
},
[container.scrollLeft, container.scrollTop, reset]
[container.scrollLeft, container.scrollTop, dispatch, reset]
);
const handleDraging = useCallback(
(e: MouseEvent) => {
if (!isDragging || !anchorRef.current) return;
// skip if the target is not a block
const blockId = getBlockIdByPoint(e.target as HTMLElement);
if (!blockId) {
return;
}
const endX = e.clientX + container.scrollLeft;
const endY = e.clientY + container.scrollTop;
// set the focus point
setFocus({
id: blockId,
point: {
x: endX,
y: endY,
},
});
// set forward
const anchorId = anchorRef.current.id;
if (anchorId === blockId) {
const endX = e.clientX + container.scrollTop;
const isForward = endX > anchorRef.current.point.x;
dispatch(rangeSelectionActions.setForward(isForward));
const startX = anchorRef.current.point.x;
setForward(startX < endX);
return;
}
const endY = e.clientY + container.scrollTop;
const isForward = endY > anchorRef.current.point.y;
dispatch(rangeSelectionActions.setForward(isForward));
const startY = anchorRef.current.point.y;
setForward(startY < endY);
},
[container.scrollTop, dispatch, isDragging]
[container.scrollLeft, container.scrollTop, isDragging]
);
const handleDragEnd = useCallback(() => {
if (!isDragging) return;
setDragging(false);
dispatch(setRangeSelectionThunk());
dispatch(rangeActions.setDragging(false));
}, [dispatch, isDragging]);
// TODO: This is a hack to fix the issue that the selection is lost when scrolling
const handleScroll = useCallback(() => {
if (isDragging || !anchorRef.current) return;
const selection = window.getSelection();
if (!selection?.rangeCount && anchorRef.current.range) {
selection?.addRange(anchorRef.current.range);
} else {
anchorRef.current.range = selection?.getRangeAt(0);
}
}, [isDragging]);
useEffect(() => {
document.addEventListener('mousedown', handleDragStart);
document.addEventListener('mousemove', handleDraging, true);
document.addEventListener('mousemove', handleDraging);
document.addEventListener('mouseup', handleDragEnd);
container.addEventListener('scroll', handleScroll);
container.addEventListener('keydown', onKeyDown, true);
return () => {
document.removeEventListener('mousedown', handleDragStart);
document.removeEventListener('mousemove', handleDraging, true);
document.removeEventListener('mousemove', handleDraging);
document.removeEventListener('mouseup', handleDragEnd);
container.removeEventListener('scroll', handleScroll);
container.removeEventListener('keydown', onKeyDown, true);
};
}, [handleDragStart, handleDragEnd, handleDraging, container, handleScroll]);
}, [handleDragStart, handleDragEnd, handleDraging, container, onKeyDown]);
return null;
}

View File

@ -1,18 +1,21 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useAppDispatch } from '$app/stores/store';
import { rectSelectionActions } from '@/appflowy_app/stores/reducers/document/slice';
import { useNodesRect } from '$app/components/document/BlockSelection/NodesRect.hooks';
import { setRectSelectionThunk } from '$app_reducers/document/async-actions/rect_selection';
import { isPointInBlock } from '$app/utils/document/blocks/selection';
export function useBlockRectSelection({ container }: { container: HTMLDivElement }) {
import { isPointInBlock } from '$app/utils/document/node';
export interface BlockRectSelectionProps {
container: HTMLDivElement;
getIntersectedBlockIds: (rect: { startX: number; startY: number; endX: number; endY: number }) => string[];
}
export function useBlockRectSelection({ container, getIntersectedBlockIds }: BlockRectSelectionProps) {
const dispatch = useAppDispatch();
const [isDragging, setDragging] = useState(false);
const startPointRef = useRef<number[]>([]);
const { getIntersectedBlockIds } = useNodesRect(container);
useEffect(() => {
dispatch(rectSelectionActions.setDragging(isDragging));
}, [dispatch, isDragging]);

View File

@ -1,10 +1,11 @@
import React from 'react';
import { useBlockRectSelection } from '$app/components/document/BlockSelection/BlockRectSelection.hooks';
import {
BlockRectSelectionProps,
useBlockRectSelection,
} from '$app/components/document/BlockSelection/BlockRectSelection.hooks';
function BlockRectSelection({ container }: { container: HTMLDivElement }) {
const { isDragging, style } = useBlockRectSelection({
container,
});
function BlockRectSelection(props: BlockRectSelectionProps) {
const { isDragging, style } = useBlockRectSelection(props);
if (!isDragging) return null;
return <div className='z-99 absolute bg-[#00d5ff] opacity-25' style={style} />;

View File

@ -0,0 +1,119 @@
import { useCallback, useContext, useMemo } from 'react';
import { Keyboard } from '$app/constants/document/keyboard';
import { useAppDispatch } from '$app/stores/store';
import { DocumentControllerContext } from '$app/stores/effects/document/document_controller';
import { arrowActionForRangeThunk, deleteRangeAndInsertThunk } from '$app_reducers/document/async-actions';
import Delta from 'quill-delta';
import isHotkey from 'is-hotkey';
import { deleteRangeAndInsertEnterThunk } from '$app_reducers/document/async-actions/range';
import { useRangeRef } from '$app/components/document/_shared/SubscribeSelection.hooks';
import { isPrintableKeyEvent } from '$app/utils/document/action';
export function useRangeKeyDown() {
const rangeRef = useRangeRef();
const dispatch = useAppDispatch();
const controller = useContext(DocumentControllerContext);
const interceptEvents = useMemo(
() => [
{
// handle backspace and delete
canHandle: (e: KeyboardEvent) => {
return isHotkey(Keyboard.keys.BACKSPACE, e) || isHotkey(Keyboard.keys.DELETE, e);
},
handler: (_: KeyboardEvent) => {
if (!controller) return;
dispatch(
deleteRangeAndInsertThunk({
controller,
})
);
},
},
{
// handle char input
canHandle: (e: KeyboardEvent) => {
return isPrintableKeyEvent(e);
},
handler: (e: KeyboardEvent) => {
if (!controller) return;
const insertDelta = new Delta().insert(e.key);
dispatch(
deleteRangeAndInsertThunk({
controller,
insertDelta,
})
);
},
},
{
// handle shift + enter
canHandle: (e: KeyboardEvent) => {
return isHotkey(Keyboard.keys.SHIFT_ENTER, e);
},
handler: (e: KeyboardEvent) => {
if (!controller) return;
dispatch(
deleteRangeAndInsertEnterThunk({
controller,
shiftKey: true,
})
);
},
},
{
// handle enter
canHandle: (e: KeyboardEvent) => {
return isHotkey(Keyboard.keys.ENTER, e);
},
handler: (e: KeyboardEvent) => {
if (!controller) return;
dispatch(
deleteRangeAndInsertEnterThunk({
controller,
shiftKey: false,
})
);
},
},
{
// handle arrows
canHandle: (e: KeyboardEvent) => {
return (
isHotkey(Keyboard.keys.LEFT, e) ||
isHotkey(Keyboard.keys.RIGHT, e) ||
isHotkey(Keyboard.keys.UP, e) ||
isHotkey(Keyboard.keys.DOWN, e)
);
},
handler: (e: KeyboardEvent) => {
dispatch(
arrowActionForRangeThunk({
key: e.key,
})
);
},
},
],
[controller, dispatch]
);
const onKeyDown = useCallback(
(e: KeyboardEvent) => {
if (!rangeRef.current) {
return;
}
const { anchor, focus } = rangeRef.current;
if (anchor?.id === focus?.id) {
return;
}
e.stopPropagation();
e.preventDefault();
const filteredEvents = interceptEvents.filter((event) => event.canHandle(e));
filteredEvents.forEach((event) => event.handler(e));
},
[interceptEvents, rangeRef]
);
return onKeyDown;
}

View File

@ -1,12 +1,15 @@
import React from 'react';
import BlockRectSelection from '$app/components/document/BlockSelection/BlockRectSelection';
import { useBlockRangeSelection } from '$app/components/document/BlockSelection/BlockRangeSelection.hooks';
import { useNodesRect } from '$app/components/document/BlockSelection/NodesRect.hooks';
function BlockSelection({ container }: { container: HTMLDivElement }) {
const { getIntersectedBlockIds } = useNodesRect(container);
useBlockRangeSelection(container);
return (
<div className='appflowy-block-selection-overlay z-1 pointer-events-none fixed inset-0 overflow-hidden'>
<BlockRectSelection container={container} />
<BlockRectSelection getIntersectedBlockIds={getIntersectedBlockIds} container={container} />
</div>
);
}

View File

@ -1,4 +1,4 @@
import React, { useCallback } from 'react';
import React, { useCallback, useState } from 'react';
import { List } from '@mui/material';
import { ContentCopy, Delete } from '@mui/icons-material';
import MenuItem from './MenuItem';
@ -8,7 +8,7 @@ import BlockMenuTurnInto from '$app/components/document/BlockSideToolbar/BlockMe
function BlockMenu({ id, onClose }: { id: string; onClose: () => void }) {
const { handleDelete, handleDuplicate } = useBlockMenu(id);
const [turnIntoPup, setTurnIntoPup] = React.useState<boolean>(false);
const [turnIntoOptionHovered, setTurnIntoOptionHorvered] = useState<boolean>(false);
const handleClick = useCallback(
async ({ operate }: { operate: () => Promise<void> }) => {
await operate();
@ -20,10 +20,12 @@ function BlockMenu({ id, onClose }: { id: string; onClose: () => void }) {
return (
<List
onMouseDown={(e) => {
// Prevent the block from being selected.
e.preventDefault();
e.stopPropagation();
}}
>
{/** Delete option in the BlockMenu. */}
<MenuItem
title='Delete'
icon={<Delete />}
@ -34,10 +36,11 @@ function BlockMenu({ id, onClose }: { id: string; onClose: () => void }) {
}
onHover={(isHovered) => {
if (isHovered) {
setTurnIntoPup(false);
setTurnIntoOptionHorvered(false);
}
}}
/>
{/** Duplicate option in the BlockMenu. */}
<MenuItem
title='Duplicate'
icon={<ContentCopy />}
@ -48,11 +51,17 @@ function BlockMenu({ id, onClose }: { id: string; onClose: () => void }) {
}
onHover={(isHovered) => {
if (isHovered) {
setTurnIntoPup(false);
setTurnIntoOptionHorvered(false);
}
}}
/>
<BlockMenuTurnInto onHovered={() => setTurnIntoPup(true)} isHovered={turnIntoPup} onClose={onClose} id={id} />
{/** Turn Into option in the BlockMenu. */}
<BlockMenuTurnInto
onHovered={() => setTurnIntoOptionHorvered(true)}
isHovered={turnIntoOptionHovered}
onClose={onClose}
id={id}
/>
</List>
);
}

View File

@ -2,6 +2,7 @@ import React, { useState } from 'react';
import { ArrowRight, Transform } from '@mui/icons-material';
import MenuItem from '$app/components/document/BlockSideToolbar/MenuItem';
import TurnIntoPopover from '$app/components/document/_shared/TurnInto';
function BlockMenuTurnInto({
id,
onClose,

View File

@ -1,13 +1,13 @@
import { BlockType, HeadingBlockData, NestedBlock } from '@/appflowy_app/interfaces/document';
import { BlockType, HeadingBlockData } from '@/appflowy_app/interfaces/document';
import { useAppDispatch } from '@/appflowy_app/stores/store';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { getBlockByIdThunk } from '$app_reducers/document/async-actions';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { PopoverOrigin } from '@mui/material/Popover/Popover';
import { getBlock } from '$app/components/document/_shared/SubscribeNode.hooks';
const headingBlockTopOffset: Record<number, number> = {
1: 7,
2: 6,
3: 3,
2: 5,
3: 4,
};
export function useBlockSideToolbar({ container }: { container: HTMLDivElement }) {
const [nodeId, setHoverNodeId] = useState<string | null>(null);
@ -19,9 +19,7 @@ export function useBlockSideToolbar({ container }: { container: HTMLDivElement }
const el = ref.current;
if (!el || !nodeId) return;
void (async () => {
const { payload: node } = (await dispatch(getBlockByIdThunk(nodeId))) as {
payload: NestedBlock;
};
const node = getBlock(nodeId);
if (!node) {
setStyle({
opacity: '0',
@ -29,7 +27,7 @@ export function useBlockSideToolbar({ container }: { container: HTMLDivElement }
});
return;
} else {
let top = 1;
let top = 2;
if (node.type === BlockType.HeadingBlock) {
const nodeData = node.data as HeadingBlockData;

View File

@ -1,4 +1,4 @@
import React, { useCallback, useContext, useState } from 'react';
import React, { useContext } from 'react';
import { useBlockSideToolbar, usePopover } from './BlockSideToolbar.hooks';
import Portal from '../BlockPortal';
import { useAppDispatch, useAppSelector } from '$app/stores/store';
@ -15,9 +15,7 @@ export default function BlockSideToolbar({ container }: { container: HTMLDivElem
const dispatch = useAppDispatch();
const controller = useContext(DocumentControllerContext);
const { nodeId, style, ref } = useBlockSideToolbar({ container });
const isDragging = useAppSelector(
(state) => state.documentRangeSelection.isDragging || state.documentRectSelection.isDragging
);
const isDragging = useAppSelector((state) => state.documentRange.isDragging || state.documentRectSelection.isDragging);
const { handleOpen, ...popoverProps } = usePopover();
// prevent popover from showing when anchorEl is not in DOM

View File

@ -10,6 +10,7 @@ import {
Lightbulb,
TextFields,
Title,
SafetyDivider,
} from '@mui/icons-material';
import { List } from '@mui/material';
import { BlockData, BlockType } from '$app/interfaces/document';
@ -107,6 +108,11 @@ function BlockSlashMenu({ id, onClose, searchText }: { id: string; onClose?: ()
title: 'Callout',
icon: <Lightbulb />,
},
{
type: BlockType.DividerBlock,
title: 'Divider',
icon: <SafetyDivider />,
},
],
],
[]

View File

@ -2,7 +2,7 @@ import { useAppDispatch, useAppSelector } from '$app/stores/store';
import React, { useCallback, useEffect, useMemo } from 'react';
import { slashCommandActions } from '$app_reducers/document/slice';
import { useSubscribeNode } from '$app/components/document/_shared/SubscribeNode.hooks';
import { TextDelta } from '$app/interfaces/document';
import { Op } from 'quill-delta';
export function useBlockSlash() {
const dispatch = useAppDispatch();
@ -54,7 +54,7 @@ export function useSubscribeSlash() {
if (!node) return '';
const delta = node.data.delta || [];
return delta
.map((op: TextDelta) => {
.map((op: Op) => {
if (typeof op.insert === 'string') {
return op.insert;
} else {

View File

@ -1,86 +0,0 @@
import { useTextInput } from '$app/components/document/_shared/Text/TextInput.hooks';
import isHotkey from 'is-hotkey';
import { useCallback, useContext, useMemo } from 'react';
import { Editor } from 'slate';
import { BlockType, NestedBlock, TextBlockKeyEventHandlerParams } from '$app/interfaces/document';
import { keyBoardEventKeyMap } from '$app/constants/document/text_block';
import { useAppDispatch } from '$app/stores/store';
import { DocumentControllerContext } from '$app/stores/effects/document/document_controller';
import { splitNodeThunk } from '$app_reducers/document/async-actions';
import { useDefaultTextInputEvents } from '$app/components/document/_shared/Text/TextEvents.hooks';
import { indent, outdent } from '$app/utils/document/blocks/code';
export function useCodeBlock(node: NestedBlock<BlockType.CodeBlock>) {
const id = node.id;
const dispatch = useAppDispatch();
const controller = useContext(DocumentControllerContext);
const { editor, ...rest } = useTextInput(id);
const defaultTextInputEvents = useDefaultTextInputEvents(id);
const customEvents = useMemo(() => {
return [
{
// Here custom tab key event for TextBlock to insert 2 spaces
triggerEventKey: keyBoardEventKeyMap.Tab,
canHandle: (...args: TextBlockKeyEventHandlerParams) => isHotkey('tab', args[0]),
handler: (...args: TextBlockKeyEventHandlerParams) => {
const [e, editor] = args;
e.preventDefault();
indent(editor, 2);
},
},
{
// Here custom shift+tab key event for TextBlock to delete 2 spaces
triggerEventKey: keyBoardEventKeyMap.Tab,
canHandle: (...args: TextBlockKeyEventHandlerParams) => isHotkey('shift+tab', args[0]),
handler: (...args: TextBlockKeyEventHandlerParams) => {
const [e, editor] = args;
e.preventDefault();
outdent(editor, 2);
},
},
{
// Here custom enter key event for TextBlock
triggerEventKey: keyBoardEventKeyMap.Enter,
canHandle: (...args: TextBlockKeyEventHandlerParams) => isHotkey('enter', args[0]),
handler: (...args: TextBlockKeyEventHandlerParams) => {
const [e, editor] = args;
e.preventDefault();
Editor.insertText(editor, '\n');
},
},
{
// Here custom shift+enter key event for TextBlock
triggerEventKey: keyBoardEventKeyMap.Enter,
canHandle: (...args: TextBlockKeyEventHandlerParams) => isHotkey('shift+enter', args[0]),
handler: (...args: TextBlockKeyEventHandlerParams) => {
const [e, editor] = args;
e.preventDefault();
void (async () => {
if (!controller) return;
await dispatch(splitNodeThunk({ id, controller, editor }));
})();
},
},
];
}, [controller, dispatch, id]);
const onKeyDown = useCallback<React.KeyboardEventHandler<HTMLDivElement>>(
(e) => {
const keyEvents = [...defaultTextInputEvents, ...customEvents];
keyEvents.forEach((keyEvent) => {
// Here we check if the key event can be handled by the current key event
if (keyEvent.canHandle(e, editor)) {
keyEvent.handler(e, editor);
}
});
},
[defaultTextInputEvents, customEvents, editor]
);
return {
editor,
onKeyDown,
...rest
};
}

View File

@ -30,7 +30,12 @@ function SelectLanguage({ id, language }: { id: string; language: string }) {
return (
<FormControl variant='standard'>
<Select className={'h-[28px] w-[150px]'} value={language} onChange={onLanguageSelect} label='Language'>
<Select
className={'h-[28px] w-[150px]'}
value={language || 'javascript'}
onChange={onLanguageSelect}
label='Language'
>
{supportLanguage.map((item) => (
<MenuItem key={item.id} value={item.id}>
{item.title}

View File

@ -1,39 +1,37 @@
import { BlockType, NestedBlock } from '$app/interfaces/document';
import { useCodeBlock } from './CodeBlock.hooks';
import { Editable, Slate } from 'slate-react';
import React from 'react';
import { CodeLeaf, CodeBlockElement } from './elements';
import SelectLanguage from './SelectLanguage';
import { decorateCodeFunc } from '$app/utils/document/blocks/code/decorate';
import { useChange } from '$app/components/document/_shared/EditorHooks/useChange';
import { useKeyDown } from './useKeyDown';
import CodeEditor from '$app/components/document/_shared/SlateEditor/CodeEditor';
import { useSelection } from '$app/components/document/_shared/EditorHooks/useSelection';
export default function CodeBlock({
node,
placeholder,
...props
}: { node: NestedBlock<BlockType.CodeBlock>; placeholder?: string } & React.HTMLAttributes<HTMLDivElement>) {
const { editor, value, onChange, ...rest } = useCodeBlock(node);
const className = props.className ? ` ${props.className}` : '';
const id = node.id;
const language = node.data.language;
const onKeyDown = useKeyDown(id);
const className = props.className ? ` ${props.className}` : '';
const { value, onChange } = useChange(node);
const { onSelectionChange, selection, lastSelection } = useSelection(id);
return (
<div {...props} className={`rounded bg-shade-6 p-6 ${className}`}>
<div className={'mb-2 w-[100%]'}>
<SelectLanguage id={id} language={language} />
</div>
<Slate editor={editor} onChange={onChange} value={value}>
<Editable
{...rest}
decorate={(entry) => {
const codeRange = decorateCodeFunc(entry, language);
const range = rest.decorate(entry);
return [...range, ...codeRange];
}}
renderLeaf={CodeLeaf}
renderElement={CodeBlockElement}
placeholder={placeholder || 'Please enter some text...'}
/>
</Slate>
<CodeEditor
value={value}
onChange={onChange}
placeholder={placeholder}
language={language}
onKeyDown={onKeyDown}
onSelectionChange={onSelectionChange}
selection={selection}
lastSelection={lastSelection}
/>
</div>
);
}

View File

@ -0,0 +1,51 @@
import isHotkey from 'is-hotkey';
import { useCallback, useContext, useMemo } from 'react';
import { useAppDispatch } from '$app/stores/store';
import { DocumentControllerContext } from '$app/stores/effects/document/document_controller';
import { Keyboard } from '$app/constants/document/keyboard';
import { useCommonKeyEvents } from '$app/components/document/_shared/EditorHooks/useCommonKeyEvents';
import { enterActionForBlockThunk } from '$app_reducers/document/async-actions';
export function useKeyDown(id: string) {
const dispatch = useAppDispatch();
const controller = useContext(DocumentControllerContext);
const commonKeyEvents = useCommonKeyEvents(id);
const customEvents = useMemo(() => {
return [
...commonKeyEvents,
{
canHandle: (e: React.KeyboardEvent<HTMLDivElement>) => {
return isHotkey(Keyboard.keys.SHIFT_ENTER, e);
},
handler: (e: React.KeyboardEvent<HTMLDivElement>) => {
e.preventDefault();
if (!controller) return;
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,4 +1,4 @@
import TextBlock from '../TextBlock';
import TextBlock from '$app/components/document/TextBlock';
import { BlockType, NestedBlock } from '@/appflowy_app/interfaces/document';
const fontSize: Record<string, string> = {

View File

@ -14,6 +14,7 @@ import NumberedListBlock from '$app/components/document/NumberedListBlock';
import ToggleListBlock from '$app/components/document/ToggleListBlock';
import DividerBlock from '$app/components/document/DividerBlock';
import CalloutBlock from '$app/components/document/CalloutBlock';
import BlockOverlay from '$app/components/document/Overlay/BlockOverlay';
import CodeBlock from '$app/components/document/CodeBlock';
function NodeComponent({ id, ...props }: { id: string } & React.HTMLAttributes<HTMLDivElement>) {
@ -55,12 +56,13 @@ function NodeComponent({ id, ...props }: { id: string } & React.HTMLAttributes<H
}
}, [node, childIds]);
const className = props.className ? ` ${props.className}` : '';
if (!node) return null;
return (
<div {...props} ref={ref} data-block-id={node.id} className={`relative ${props.className}`}>
<div {...props} ref={ref} data-block-id={node.id} className={`relative ${className}`}>
{renderBlock()}
<div className='block-overlay' />
<BlockOverlay id={id} />
{isSelected ? (
<div className='pointer-events-none absolute inset-0 z-[-1] m-[1px] rounded-[4px] bg-[#E0F8FF]' />
) : null}

View File

@ -0,0 +1,7 @@
import React from 'react';
function BlockOverlay({ id }: { id: string }) {
return <div className='block-overlay' />;
}
export default BlockOverlay;

View File

@ -20,7 +20,7 @@ function Root({ documentData }: { documentData: DocumentData }) {
return (
<>
<div id='appflowy-block-doc' className='h-[100%] overflow-hidden'>
<div id='appflowy-block-doc' className='h-[100%] overflow-hidden caret-custom-caret'>
<VirtualizedList node={node} childIds={childIds} renderNode={renderNode} />
</div>
</>

View File

@ -1,20 +1,21 @@
import { useEffect, useRef, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { calcToolbarPosition } from '$app/utils/document/toolbar';
import { useAppSelector } from '$app/stores/store';
import { getNode } from '$app/utils/document/node';
import { debounce } from '$app/utils/tool';
export function useMenuStyle(container: HTMLDivElement) {
const ref = useRef<HTMLDivElement | null>(null);
const range = useAppSelector((state) => state.documentRangeSelection);
const id = useAppSelector((state) => state.documentRange.focus?.id);
const [isScrolling, setIsScrolling] = useState(false);
const [scrollTop, setScrollTop] = useState(container.scrollTop);
useEffect(() => {
const reCalculatePosition = useCallback(() => {
const el = ref.current;
if (!el) return;
if (!el || !id) return;
const id = range.focus?.id;
if (!id) return;
const position = calcToolbarPosition(el);
const node = getNode(id);
if (!node) return;
const position = calcToolbarPosition(el, node, container);
if (!position) {
el.style.opacity = '0';
@ -22,22 +23,38 @@ export function useMenuStyle(container: HTMLDivElement) {
} else {
el.style.opacity = '1';
el.style.pointerEvents = 'auto';
el.style.top = position.top;
el.style.left = position.left;
el.style.top = position.top + 'px';
el.style.left = position.left + 'px';
}
});
}, [container, id]);
useEffect(() => {
// recalculating toolbar position when scrolling is finished
if (isScrolling) return;
reCalculatePosition();
}, [container, id, isScrolling, reCalculatePosition]);
const debounceScrollEnd = useMemo(() => {
return debounce(() => {
// set isScrolling to false after 20ms
setIsScrolling(false);
}, 20);
}, []);
useEffect(() => {
const handleScroll = () => {
setScrollTop(container.scrollTop);
setIsScrolling(true);
debounceScrollEnd();
};
container.addEventListener('scroll', handleScroll);
return () => {
debounceScrollEnd.cancel();
container.removeEventListener('scroll', handleScroll);
};
}, [container]);
}, [container, debounceScrollEnd]);
return {
ref,
id,
};
}

View File

@ -1,46 +1,45 @@
import { useMenuStyle } from './index.hooks';
import { useAppSelector } from '$app/stores/store';
import { isEqual } from '$app/utils/tool';
import TextActionMenuList from '$app/components/document/TextActionMenu/menu';
import BlockPortal from '$app/components/document/BlockPortal';
const TextActionComponent = ({ container }: { container: HTMLDivElement }) => {
const { ref } = useMenuStyle(container);
const { ref, id } = useMenuStyle(container);
if (!id) return null;
return (
<div
ref={ref}
style={{
opacity: 0,
}}
className='absolute mt-[-6px] inline-flex h-[32px] min-w-[100px] items-stretch overflow-hidden rounded-[8px] bg-[#333] leading-tight shadow-lg transition-opacity duration-200'
onMouseDown={(e) => {
// prevent toolbar from taking focus away from editor
e.preventDefault();
e.stopPropagation();
}}
>
<TextActionMenuList />
</div>
<BlockPortal blockId={id}>
<div
ref={ref}
style={{
opacity: 0,
}}
className='absolute mt-[-6px] inline-flex h-[32px] min-w-[100px] items-stretch overflow-hidden rounded-[8px] bg-[#333] leading-tight shadow-lg transition-opacity duration-100'
onMouseDown={(e) => {
// prevent toolbar from taking focus away from editor
e.preventDefault();
e.stopPropagation();
}}
>
<TextActionMenuList />
</div>
</BlockPortal>
);
};
const TextActionMenu = ({ container }: { container: HTMLDivElement }) => {
const canShow = useAppSelector((state) => {
const range = state.documentRangeSelection;
if (range.isDragging) return false;
const anchorNode = range.anchor;
const focusNode = range.focus;
if (!anchorNode || !focusNode) return false;
const isSameLine = anchorNode.id === focusNode.id;
const isCollapsed = isEqual(anchorNode.selection.anchor, anchorNode.selection.focus);
return !(isSameLine && isCollapsed);
const { isDragging, focus, anchor, ranges } = state.documentRange;
if (isDragging) return false;
if (!focus || !anchor) return false;
const isSameLine = anchor.id === focus.id;
const anchorRange = ranges[anchor.id];
if (!anchorRange) return false;
const isCollapsed = isSameLine && anchorRange.length === 0;
return !isCollapsed;
});
if (!canShow) return null;
return (
<div className='appflowy-block-toolbar-overlay pointer-events-none fixed inset-0 overflow-hidden'>
<TextActionComponent container={container} />
</div>
);
return <TextActionComponent container={container} />;
};
export default TextActionMenu;

View File

@ -12,7 +12,7 @@ const FormatButton = ({ format, icon }: { format: TextAction; icon: string }) =>
const dispatch = useAppDispatch();
const controller = useContext(DocumentControllerContext);
const focusId = useAppSelector((state) => state.documentRangeSelection.focus?.id || '');
const focusId = useAppSelector((state) => state.documentRange.focus?.id || '');
const { node: focusNode } = useSubscribeNode(focusId);
const [isActive, setIsActive] = React.useState(false);

View File

@ -1,18 +1,19 @@
import React from 'react';
import { FormatBold, FormatUnderlined, FormatItalic, CodeOutlined, StrikethroughSOutlined } from '@mui/icons-material';
import { TextAction } from '$app/interfaces/document';
export const iconSize = { width: 18, height: 18 };
export default function FormatIcon({ icon }: { icon: string }) {
switch (icon) {
case 'bold':
case TextAction.Bold:
return <FormatBold sx={iconSize} />;
case 'underlined':
case TextAction.Underline:
return <FormatUnderlined sx={iconSize} />;
case 'italic':
case TextAction.Italic:
return <FormatItalic sx={iconSize} />;
case 'code':
case TextAction.Code:
return <CodeOutlined sx={iconSize} />;
case 'strikethrough':
case TextAction.Strikethrough:
return <StrikethroughSOutlined sx={iconSize} />;
default:
return null;

View File

@ -11,17 +11,17 @@ import { useSubscribeNode } from '$app/components/document/_shared/SubscribeNode
import { TextAction } from '$app/interfaces/document';
export function useTextActionMenu() {
const range = useAppSelector((state) => state.documentRangeSelection);
const id = useMemo(() => {
return range.anchor?.id === range.focus?.id ? range.anchor?.id : undefined;
const range = useAppSelector((state) => state.documentRange);
const isSingleLine = useMemo(() => {
return range.focus?.id === range.anchor?.id;
}, [range]);
const focusId = range.focus?.id;
const { node } = useSubscribeNode(id || '');
const { node } = useSubscribeNode(focusId || '');
const items = useMemo(() => {
if (node) {
const config = blockConfig[node.type];
if (isSingleLine) {
const config = blockConfig[node?.type];
const { customItems, excludeItems } = {
...defaultTextActionProps,
...config.textActionMenuProps,
@ -30,7 +30,7 @@ export function useTextActionMenu() {
} else {
return multiLineTextActionProps.customItems || [];
}
}, [node]);
}, [isSingleLine, node?.type]);
// the groups have default items, so we need to filter the items if this node has excluded items
const groupItems: TextAction[][] = useMemo(() => {
@ -42,6 +42,7 @@ export function useTextActionMenu() {
return {
groupItems,
id,
isSingleLine,
focusId,
};
}

View File

@ -5,33 +5,39 @@ import FormatButton from '$app/components/document/TextActionMenu/menu/FormatBut
import { useTextActionMenu } from '$app/components/document/TextActionMenu/menu/index.hooks';
function TextActionMenuList() {
const { groupItems, id } = useTextActionMenu();
const renderNode = useCallback((action: TextAction, id?: string) => {
switch (action) {
case TextAction.Turn:
return id ? <TurnIntoSelect id={id} /> : null;
case TextAction.Bold:
case TextAction.Italic:
case TextAction.Underline:
case TextAction.Strikethrough:
case TextAction.Code:
return <FormatButton format={action} icon={action} />;
default:
return null;
}
}, []);
const { groupItems, isSingleLine, focusId } = useTextActionMenu();
const renderNode = useCallback(
(action: TextAction) => {
switch (action) {
case TextAction.Turn:
return isSingleLine && focusId ? <TurnIntoSelect id={focusId} /> : null;
case TextAction.Bold:
case TextAction.Italic:
case TextAction.Underline:
case TextAction.Strikethrough:
case TextAction.Code:
return <FormatButton format={action} icon={action} />;
default:
return null;
}
},
[isSingleLine, focusId]
);
return (
<div className={'flex px-1'}>
{groupItems.map((group, i: number) => (
<div className={'flex border-r border-solid border-shade-2 px-1 last:border-r-0'} key={i}>
{group.map((item) => (
<div key={item} className={'flex items-center'}>
{renderNode(item, id)}
{groupItems.map(
(group, i: number) =>
group.length > 0 && (
<div className={'flex border-r border-solid border-shade-2 px-1 last:border-r-0'} key={i}>
{group.map((item) => (
<div key={item} className={'flex items-center'}>
{renderNode(item)}
</div>
))}
</div>
))}
</div>
))}
)
)}
</div>
);
}

View File

@ -1,40 +0,0 @@
import { BaseText } from 'slate';
import { RenderLeafProps } from 'slate-react';
interface LeafProps extends RenderLeafProps {
leaf: BaseText & {
bold?: boolean;
code?: boolean;
italic?: boolean;
underlined?: boolean;
strikethrough?: boolean;
selectionHighlighted?: boolean;
};
}
const Leaf = ({ attributes, children, leaf }: LeafProps) => {
let newChildren = children;
if (leaf.bold) {
newChildren = <strong>{children}</strong>;
}
if (leaf.italic) {
newChildren = <em>{newChildren}</em>;
}
if (leaf.underlined) {
newChildren = <u>{newChildren}</u>;
}
const className = [
leaf.strikethrough && 'line-through',
leaf.selectionHighlighted && 'bg-main-secondary',
leaf.code && 'bg-main-selector',
].filter(Boolean);
return (
<span {...attributes} className={className.join(' ')}>
{newChildren}
</span>
);
};
export default Leaf;

View File

@ -1,14 +0,0 @@
import { useTextInput } from '../_shared/Text/TextInput.hooks';
import { useTextBlockKeyEvent } from '$app/components/document/TextBlock/events/Events.hooks';
export function useTextBlock(id: string) {
const { editor, ...props } = useTextInput(id);
const { onKeyDown } = useTextBlockKeyEvent(id, editor);
return {
onKeyDown,
editor,
...props,
};
}

View File

@ -1,134 +0,0 @@
import { Editor } from 'slate';
import { useTurnIntoBlock } from './TurnIntoEvents.hooks';
import { useCallback, useContext, useMemo } from 'react';
import { keyBoardEventKeyMap } from '$app/constants/document/text_block';
import { BlockType, TextBlockKeyEventHandlerParams } from '$app/interfaces/document';
import isHotkey from 'is-hotkey';
import { indentNodeThunk, outdentNodeThunk, splitNodeThunk } from '$app_reducers/document/async-actions';
import { DocumentControllerContext } from '$app/stores/effects/document/document_controller';
import { useAppDispatch, useAppSelector } from '$app/stores/store';
import { useDefaultTextInputEvents } from '$app/components/document/_shared/Text/TextEvents.hooks';
import { ReactEditor } from 'slate-react';
import { getBeforeRangeAt } from '$app/utils/document/blocks/text/delta';
import { slashCommandActions } from '$app_reducers/document/slice';
import { useSubscribeNode } from '$app/components/document/_shared/SubscribeNode.hooks';
export function useTextBlockKeyEvent(id: string, editor: ReactEditor) {
const controller = useContext(DocumentControllerContext);
const dispatch = useAppDispatch();
const defaultTextInputEvents = useDefaultTextInputEvents(id);
const isFocusCurrentNode = useAppSelector((state) => {
const { anchor, focus } = state.documentRangeSelection;
if (!anchor || !focus) return false;
return anchor.id === id && focus.id === id;
});
const { node } = useSubscribeNode(id);
const nodeType = node?.type;
const { turnIntoBlockEvents } = useTurnIntoBlock(id);
// Here custom key events for TextBlock
const events = useMemo(
() => [
...defaultTextInputEvents,
{
// Here custom enter key event for TextBlock
triggerEventKey: keyBoardEventKeyMap.Enter,
canHandle: (...args: TextBlockKeyEventHandlerParams) => isHotkey('enter', args[0]),
handler: (...args: TextBlockKeyEventHandlerParams) => {
const [e, editor] = args;
e.preventDefault();
void (async () => {
if (!controller) return;
await dispatch(splitNodeThunk({ id, controller, editor }));
})();
},
},
{
// Here custom shift+enter key event for TextBlock
triggerEventKey: keyBoardEventKeyMap.Enter,
canHandle: (...args: TextBlockKeyEventHandlerParams) => isHotkey('shift+enter', args[0]),
handler: (...args: TextBlockKeyEventHandlerParams) => {
const [e, editor] = args;
e.preventDefault();
Editor.insertText(editor, '\n');
},
},
{
// Here custom tab key event for TextBlock
triggerEventKey: keyBoardEventKeyMap.Tab,
canHandle: (...args: TextBlockKeyEventHandlerParams) => isHotkey('tab', args[0]),
handler: (...args: TextBlockKeyEventHandlerParams) => {
const [e, _] = args;
e.preventDefault();
if (!controller) return;
dispatch(
indentNodeThunk({
id,
controller,
})
);
},
},
{
// Here custom shift+tab key event for TextBlock
triggerEventKey: keyBoardEventKeyMap.Tab,
canHandle: (...args: TextBlockKeyEventHandlerParams) => isHotkey('shift+tab', args[0]),
handler: (...args: TextBlockKeyEventHandlerParams) => {
const [e, _] = args;
e.preventDefault();
if (!controller) return;
dispatch(
outdentNodeThunk({
id,
controller,
})
);
},
},
{
// Here custom slash key event for TextBlock
triggerEventKey: keyBoardEventKeyMap.Slash,
canHandle: (...args: TextBlockKeyEventHandlerParams) => {
const [e, editor] = args;
if (!editor.selection) return false;
return isHotkey('/', e) && Editor.string(editor, getBeforeRangeAt(editor, editor.selection)) === '';
},
handler: (...args: TextBlockKeyEventHandlerParams) => {
const [e, _] = args;
if (!controller) return;
dispatch(
slashCommandActions.openSlashCommand({
blockId: id,
})
);
},
},
],
[defaultTextInputEvents, controller, dispatch, id, nodeType]
);
const onKeyDown = useCallback(
(event: React.KeyboardEvent<HTMLDivElement>) => {
if (!isFocusCurrentNode) {
event.preventDefault();
return;
}
event.stopPropagation();
// This is list of key events that can be handled by TextBlock
const keyEvents = [...events, ...turnIntoBlockEvents];
const matchKeys = keyEvents.filter((keyEvent) => keyEvent.canHandle(event, editor));
matchKeys.forEach((matchKey) => matchKey.handler(event, editor));
},
[editor, events, turnIntoBlockEvents, isFocusCurrentNode]
);
return {
onKeyDown,
};
}

View File

@ -1,102 +0,0 @@
import { useContext, useMemo } from 'react';
import { BlockType, TextBlockKeyEventHandlerParams } from '$app/interfaces/document';
import { keyBoardEventKeyMap } from '$app/constants/document/text_block';
import { useAppDispatch } from '$app/stores/store';
import { DocumentControllerContext } from '$app/stores/effects/document/document_controller';
import { turnToBlockThunk, turnToDividerBlockThunk } from '$app_reducers/document/async-actions';
import { blockConfig } from '$app/constants/document/config';
import { Editor } from 'slate';
import { getBeforeRangeAt, getDeltaAfterSelection } from '$app/utils/document/blocks/text/delta';
import {
getHeadingDataFromEditor,
getQuoteDataFromEditor,
getTodoListDataFromEditor,
getBulletedDataFromEditor,
getNumberedListDataFromEditor,
getToggleListDataFromEditor,
getCalloutDataFromEditor,
getCodeBlockDataFromEditor,
} from '$app/utils/document/blocks';
export function useTurnIntoBlock(id: string) {
const controller = useContext(DocumentControllerContext);
const dispatch = useAppDispatch();
const turnIntoBlockEvents = useMemo(() => {
const spaceTriggerEvents = Object.entries({
[BlockType.HeadingBlock]: getHeadingDataFromEditor,
[BlockType.TodoListBlock]: getTodoListDataFromEditor,
[BlockType.QuoteBlock]: getQuoteDataFromEditor,
[BlockType.BulletedListBlock]: getBulletedDataFromEditor,
[BlockType.NumberedListBlock]: getNumberedListDataFromEditor,
[BlockType.ToggleListBlock]: getToggleListDataFromEditor,
[BlockType.CalloutBlock]: getCalloutDataFromEditor,
}).map(([type, getData]) => {
const blockType = type as BlockType;
const triggerKey = keyBoardEventKeyMap.Space;
return {
triggerEventKey: keyBoardEventKeyMap.Space,
canHandle: canHandle(blockType, triggerKey),
handler: (...args: TextBlockKeyEventHandlerParams) => {
if (!controller) return;
const [_event, editor] = args;
const data = getData(editor);
if (!data) return;
dispatch(turnToBlockThunk({ id, data, type: blockType, controller }));
},
};
});
return [
...spaceTriggerEvents,
{
triggerEventKey: keyBoardEventKeyMap.Reduce,
canHandle: canHandle(BlockType.DividerBlock, keyBoardEventKeyMap.Reduce),
handler: (...args: TextBlockKeyEventHandlerParams) => {
if (!controller) return;
const [_event, editor] = args;
const delta = getDeltaAfterSelection(editor) || [];
dispatch(turnToDividerBlockThunk({ id, controller, delta }));
},
},
{
triggerEventKey: keyBoardEventKeyMap.Backquote,
canHandle: canHandle(BlockType.CodeBlock, keyBoardEventKeyMap.Backquote),
handler: (...args: TextBlockKeyEventHandlerParams) => {
if (!controller) return;
const [_event, editor] = args;
const data = getCodeBlockDataFromEditor(editor);
dispatch(turnToBlockThunk({ id, data, type: BlockType.CodeBlock, controller }));
},
},
];
}, [controller, dispatch, id]);
return {
turnIntoBlockEvents,
};
}
function canHandle(type: BlockType, triggerKey: string) {
const config = blockConfig[type];
const regex = config.markdownRegexps;
// This error will be thrown if the block type is not in the config, and it will happen in development environment
if (!regex) {
throw new Error(`canHandle: block type ${type} is not supported`);
}
return (...args: TextBlockKeyEventHandlerParams) => {
const [event, editor] = args;
const isTrigger = event.key === triggerKey;
const selection = editor.selection;
if (!isTrigger || !selection) {
return false;
}
const flag = Editor.string(editor, getBeforeRangeAt(editor, selection)).trim();
if (flag === null) return false;
return regex.some((r) => r.test(`${flag}${triggerKey}`));
};
}

View File

@ -1,34 +1,32 @@
import { Slate, Editable } from 'slate-react';
import Leaf from './Leaf';
import { useTextBlock } from './TextBlock.hooks';
import React from 'react';
import { NestedBlock } from '$app/interfaces/document';
import Editor from '../_shared/SlateEditor/TextEditor';
import { useChange } from '$app/components/document/_shared/EditorHooks/useChange';
import NodeChildren from '$app/components/document/Node/NodeChildren';
import { useKeyDown } from '$app/components/document/TextBlock/useKeyDown';
import { useSelection } from '$app/components/document/_shared/EditorHooks/useSelection';
function TextBlock({
node,
childIds,
placeholder,
className = '',
}: {
interface Props {
node: NestedBlock;
childIds?: string[];
placeholder?: string;
className?: string;
}) {
const { editor, value, onChange, ...rest } = useTextBlock(node.id);
}
function TextBlock({ node, childIds, placeholder }: Props) {
const { value, onChange } = useChange(node);
const { onSelectionChange, selection, lastSelection } = useSelection(node.id);
const { onKeyDown } = useKeyDown(node.id);
return (
<>
<div className={`px-1 py-[2px] ${className}`}>
<Slate editor={editor} onChange={onChange} value={value}>
<Editable
{...rest}
renderLeaf={(leafProps) => <Leaf {...leafProps} />}
placeholder={placeholder || 'Please enter some text...'}
/>
</Slate>
</div>
<Editor
value={value}
onChange={onChange}
onSelectionChange={onSelectionChange}
selection={selection}
lastSelection={lastSelection}
onKeyDown={onKeyDown}
placeholder={placeholder}
/>
<NodeChildren className='pl-[1.5em]' childIds={childIds} />
</>
);

View File

@ -0,0 +1,105 @@
import { useCallback, useContext, useMemo } from 'react';
import { Keyboard } from '$app/constants/document/keyboard';
import isHotkey from 'is-hotkey';
import { useAppDispatch } from '@/appflowy_app/stores/store';
import { DocumentControllerContext } from '$app/stores/effects/document/document_controller';
import {
enterActionForBlockThunk,
tabActionForBlockThunk,
shiftTabActionForBlockThunk,
} from '$app_reducers/document/async-actions';
import { useTurnIntoBlockEvents } from './useTurnIntoBlockEvents';
import { useCommonKeyEvents } from '../_shared/EditorHooks/useCommonKeyEvents';
export function useKeyDown(id: string) {
const controller = useContext(DocumentControllerContext);
const dispatch = useAppDispatch();
const turnIntoEvents = useTurnIntoBlockEvents(id);
const commonKeyEvents = useCommonKeyEvents(id);
const interceptEvents = useMemo(() => {
return [
...commonKeyEvents,
{
// Prevent all enter key
canHandle: (e: React.KeyboardEvent<HTMLDivElement>) => {
return e.key === Keyboard.keys.ENTER;
},
handler: (e: React.KeyboardEvent<HTMLDivElement>) => {
e.preventDefault();
},
},
{
// handle enter key and no other key is pressed
canHandle: (e: React.KeyboardEvent<HTMLDivElement>) => {
return isHotkey(Keyboard.keys.ENTER, e);
},
handler: (e: React.KeyboardEvent<HTMLDivElement>) => {
if (!controller) return;
dispatch(
enterActionForBlockThunk({
id,
controller,
})
);
},
},
{
// Prevent tab key from indenting
canHandle: (e: React.KeyboardEvent<HTMLDivElement>) => {
return e.key === Keyboard.keys.TAB;
},
handler: (e: React.KeyboardEvent<HTMLDivElement>) => {
e.preventDefault();
},
},
{
// handle tab key and no other key is pressed
canHandle: (e: React.KeyboardEvent<HTMLDivElement>) => {
return isHotkey(Keyboard.keys.TAB, e);
},
handler: (e: React.KeyboardEvent<HTMLDivElement>) => {
if (!controller) return;
dispatch(
tabActionForBlockThunk({
id,
controller,
})
);
},
},
{
// handle shift + tab key and no other key is pressed
canHandle: (e: React.KeyboardEvent<HTMLDivElement>) => {
return isHotkey(Keyboard.keys.SHIFT_TAB, e);
},
handler: (e: React.KeyboardEvent<HTMLDivElement>) => {
if (!controller) return;
dispatch(
shiftTabActionForBlockThunk({
id,
controller,
})
);
},
},
...turnIntoEvents,
];
}, [commonKeyEvents, controller, dispatch, id, turnIntoEvents]);
const onKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLDivElement>) => {
e.stopPropagation();
const filteredEvents = interceptEvents.filter((event) => event.canHandle(e));
filteredEvents.forEach((event) => event.handler(e));
},
[interceptEvents]
);
return {
onKeyDown,
};
}

View File

@ -0,0 +1,185 @@
import { useCallback, useContext, useMemo } from 'react';
import { BlockType } from '$app/interfaces/document';
import { useAppDispatch } from '$app/stores/store';
import { DocumentControllerContext } from '$app/stores/effects/document/document_controller';
import { turnToBlockThunk } from '$app_reducers/document/async-actions';
import { blockConfig } from '$app/constants/document/config';
import Delta, { Op } from 'quill-delta';
import { useRangeRef } from '$app/components/document/_shared/SubscribeSelection.hooks';
import { getBlock } from '$app/components/document/_shared/SubscribeNode.hooks';
import isHotkey from 'is-hotkey';
import { slashCommandActions } from '$app_reducers/document/slice';
import { Keyboard } from '$app/constants/document/keyboard';
import { getDeltaText } from '$app/utils/document/delta';
export function useTurnIntoBlockEvents(id: string) {
const controller = useContext(DocumentControllerContext);
const dispatch = useAppDispatch();
const rangeRef = useRangeRef();
const getFlag = useCallback(() => {
const range = rangeRef.current?.caret;
if (!range || range.id !== id) return;
const node = getBlock(id);
const delta = new Delta(node.data.delta || []);
const flag = getDeltaText(delta.slice(0, range.index));
return flag;
}, [id, rangeRef]);
const getDeltaContent = useCallback(() => {
const range = rangeRef.current?.caret;
if (!range || range.id !== id) return;
const node = getBlock(id);
const delta = new Delta(node.data.delta || []);
const content = delta.slice(range.index);
return new Delta(content);
}, [id, rangeRef]);
const canHandle = useCallback(
(event: React.KeyboardEvent<HTMLDivElement>, type: BlockType, triggerKey: string) => {
{
const config = blockConfig[type];
const regex = config.markdownRegexps;
// This error will be thrown if the block type is not in the config, and it will happen in development environment
if (!regex) {
throw new Error(`canHandle: block type ${type} is not supported`);
}
const isTrigger = event.key === triggerKey;
if (!isTrigger) {
return false;
}
const flag = getFlag();
if (!flag) return false;
return regex.some((r) => r.test(`${flag}${triggerKey}`));
}
},
[getFlag]
);
const getTurnIntoBlockDelta = useCallback(() => {
const content = getDeltaContent();
if (!content) return;
return {
delta: content.ops,
};
}, [getDeltaContent]);
const spaceTriggerMap = useMemo(() => {
return {
[BlockType.HeadingBlock]: () => {
const flag = getFlag();
if (!flag) return;
return {
level: flag.match(/#/g)?.length,
...getTurnIntoBlockDelta(),
};
},
[BlockType.TodoListBlock]: () => {
const flag = getFlag();
if (!flag) return;
return {
checked: flag.includes('[x]'),
...getTurnIntoBlockDelta(),
};
},
[BlockType.QuoteBlock]: getTurnIntoBlockDelta,
[BlockType.BulletedListBlock]: getTurnIntoBlockDelta,
[BlockType.NumberedListBlock]: getTurnIntoBlockDelta,
[BlockType.ToggleListBlock]: getTurnIntoBlockDelta,
[BlockType.CalloutBlock]: () => {
const flag = getFlag();
if (!flag) return;
const tag = flag.match(/(TIP|INFO|WARNING|DANGER)/g)?.[0];
if (!tag) return;
const iconMap: Record<string, string> = {
TIP: '💡',
INFO: '❗',
WARNING: '⚠️',
DANGER: '‼️',
};
return {
icon: iconMap[tag],
...getTurnIntoBlockDelta(),
};
},
};
}, [getFlag, getTurnIntoBlockDelta]);
const turnIntoBlockEvents = useMemo(() => {
const spaceTriggerEvents = Object.entries(spaceTriggerMap).map(([type, getData]) => {
const blockType = type as BlockType;
const triggerKey = Keyboard.keys.Space;
return {
canHandle: (e: React.KeyboardEvent<HTMLDivElement>) => canHandle(e, blockType, triggerKey),
handler: (e: React.KeyboardEvent<HTMLDivElement>) => {
e.preventDefault();
if (!controller) return;
const data = getData();
if (!data) return;
dispatch(turnToBlockThunk({ id, data, type: blockType, controller }));
},
};
});
return [
...spaceTriggerEvents,
{
canHandle: (e: React.KeyboardEvent<HTMLDivElement>) =>
canHandle(e, BlockType.DividerBlock, Keyboard.keys.Reduce),
handler: (e: React.KeyboardEvent<HTMLDivElement>) => {
e.preventDefault();
if (!controller) return;
const delta = getDeltaContent();
dispatch(
turnToBlockThunk({
id,
controller,
type: BlockType.DividerBlock,
data: {
delta: delta?.ops as Op[],
},
})
);
},
},
{
canHandle: (e: React.KeyboardEvent<HTMLDivElement>) =>
canHandle(e, BlockType.CodeBlock, Keyboard.keys.BackQuote),
handler: (e: React.KeyboardEvent<HTMLDivElement>) => {
e.preventDefault();
if (!controller) return;
const defaultData = blockConfig[BlockType.CodeBlock].defaultData;
const data = {
...defaultData,
delta: getDeltaContent()?.ops as Op[],
};
dispatch(turnToBlockThunk({ id, data, type: BlockType.CodeBlock, controller }));
},
},
{
// Here custom slash key event for TextBlock
canHandle: (e: React.KeyboardEvent<HTMLDivElement>) => {
const flag = getFlag();
return isHotkey('/', e) && flag === '';
},
handler: (e: React.KeyboardEvent<HTMLDivElement>) => {
if (!controller) return;
dispatch(
slashCommandActions.openSlashCommand({
blockId: id,
})
);
},
},
];
}, [canHandle, controller, dispatch, getDeltaContent, getFlag, id, spaceTriggerMap]);
return turnIntoBlockEvents;
}

View File

@ -1,7 +1,7 @@
import { useAppDispatch } from '$app/stores/store';
import { useCallback, useContext } from 'react';
import { DocumentControllerContext } from '$app/stores/effects/document/document_controller';
import { updateNodeDataThunk } from '$app_reducers/document/async-actions/blocks/text/update';
import { updateNodeDataThunk } from '$app_reducers/document/async-actions/blocks/update';
import { BlockData, BlockType } from '$app/interfaces/document';
import isHotkey from 'is-hotkey';

View File

@ -1,7 +1,7 @@
import { useAppDispatch } from '$app/stores/store';
import { useCallback, useContext } from 'react';
import { DocumentControllerContext } from '$app/stores/effects/document/document_controller';
import { updateNodeDataThunk } from '$app_reducers/document/async-actions/blocks/text/update';
import { updateNodeDataThunk } from '$app_reducers/document/async-actions/blocks/update';
import { BlockData, BlockType } from '$app/interfaces/document';
import isHotkey from 'is-hotkey';

View File

@ -1,7 +1,7 @@
import { useVirtualizer } from '@tanstack/react-virtual';
import { useRef } from 'react';
const defaultSize = 60;
const defaultSize = 30;
export function useVirtualizedList(count: number) {
const parentRef = useRef<HTMLDivElement>(null);
@ -9,6 +9,7 @@ export function useVirtualizedList(count: number) {
const virtualize = useVirtualizer({
count,
getScrollElement: () => parentRef.current,
overscan: 5,
estimateSize: () => {
return defaultSize;
},

View File

@ -43,7 +43,12 @@ export default function VirtualizedList({
{virtualItems.map((virtualRow) => {
const id = childIds[virtualRow.index];
return (
<div className='pt-0.5' key={id} data-index={virtualRow.index} ref={virtualize.measureElement}>
<div
className='mt-[-0.5px] pt-[0.5px]'
key={id}
data-index={virtualRow.index}
ref={virtualize.measureElement}
>
{virtualRow.index === 0 ? <DocumentTitle id={node.id} /> : null}
{renderNode(id)}
</div>

View File

@ -0,0 +1,33 @@
import { BlockType, NestedBlock } from '$app/interfaces/document';
import { useCallback, useEffect, useState } from 'react';
import Delta from 'quill-delta';
import { useDelta } from '$app/components/document/_shared/EditorHooks/useDelta';
export function useChange(node: NestedBlock<BlockType.TextBlock | BlockType.CodeBlock>) {
const { update, delta } = useDelta({ id: node.id });
const [value, setValue] = useState<Delta>(() => {
return delta;
});
useEffect(() => {
setValue(delta);
}, [delta]);
const onChange = useCallback(
(newContents: Delta, oldContents: Delta, _source?: string) => {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
const isSame = newContents.diff(oldContents).ops.length === 0;
if (isSame) return;
setValue(newContents);
update(newContents);
},
[update]
);
return {
value,
onChange,
};
}

View File

@ -0,0 +1,79 @@
import isHotkey from 'is-hotkey';
import { Keyboard } from '$app/constants/document/keyboard';
import {
backspaceDeleteActionForBlockThunk,
leftActionForBlockThunk,
rightActionForBlockThunk,
upDownActionForBlockThunk,
} from '$app_reducers/document/async-actions';
import { useContext, useMemo } from 'react';
import { useFocused } from '$app/components/document/_shared/SubscribeSelection.hooks';
import { DocumentControllerContext } from '$app/stores/effects/document/document_controller';
import { useAppDispatch } from '$app/stores/store';
export function useCommonKeyEvents(id: string) {
const { focused, caretRef } = useFocused(id);
const controller = useContext(DocumentControllerContext);
const dispatch = useAppDispatch();
const commonKeyEvents = useMemo(() => {
return [
{
// handle backspace and delete key and the caret is at the beginning of the block
canHandle: (e: React.KeyboardEvent<HTMLDivElement>) => {
return (
(isHotkey(Keyboard.keys.BACKSPACE, e) || isHotkey(Keyboard.keys.DELETE, e)) &&
focused &&
caretRef.current?.index === 0 &&
caretRef.current?.length === 0
);
},
handler: (e: React.KeyboardEvent<HTMLDivElement>) => {
e.preventDefault();
if (!controller) return;
dispatch(backspaceDeleteActionForBlockThunk({ id, controller }));
},
},
{
// handle up arrow key and no other key is pressed
canHandle: (e: React.KeyboardEvent<HTMLDivElement>) => {
return isHotkey(Keyboard.keys.UP, e);
},
handler: (e: React.KeyboardEvent<HTMLDivElement>) => {
e.preventDefault();
dispatch(upDownActionForBlockThunk({ id }));
},
},
{
// handle down arrow key and no other key is pressed
canHandle: (e: React.KeyboardEvent<HTMLDivElement>) => {
return isHotkey(Keyboard.keys.DOWN, e);
},
handler: (e: React.KeyboardEvent<HTMLDivElement>) => {
e.preventDefault();
dispatch(upDownActionForBlockThunk({ id, down: true }));
},
},
{
// handle left arrow key and no other key is pressed
canHandle: (e: React.KeyboardEvent<HTMLDivElement>) => {
return isHotkey(Keyboard.keys.LEFT, e);
},
handler: (e: React.KeyboardEvent<HTMLDivElement>) => {
e.preventDefault();
dispatch(leftActionForBlockThunk({ id }));
},
},
{
// handle right arrow key and no other key is pressed
canHandle: (e: React.KeyboardEvent<HTMLDivElement>) => {
return isHotkey(Keyboard.keys.RIGHT, e);
},
handler: (e: React.KeyboardEvent<HTMLDivElement>) => {
e.preventDefault();
dispatch(rightActionForBlockThunk({ id }));
},
},
];
}, [caretRef, controller, dispatch, focused, id]);
return commonKeyEvents;
}

View File

@ -0,0 +1,43 @@
import { useSubscribeNode } from '$app/components/document/_shared/SubscribeNode.hooks';
import { useCallback, useContext, useEffect, useMemo, useRef } from 'react';
import { DocumentControllerContext } from '$app/stores/effects/document/document_controller';
import { useAppDispatch } from '$app/stores/store';
import { updateNodeDeltaThunk } from '$app_reducers/document/async-actions';
import Delta from 'quill-delta';
export function useDelta({ id, onDeltaChange }: { id: string; onDeltaChange?: (delta: Delta) => void }) {
const controller = useContext(DocumentControllerContext);
const dispatch = useAppDispatch();
const penddingRef = useRef(false);
const { node } = useSubscribeNode(id);
const delta = useMemo(() => {
if (!node || !node.data.delta) return new Delta();
return new Delta(node.data.delta);
}, [node]);
useEffect(() => {
onDeltaChange?.(delta);
}, [delta, onDeltaChange]);
const update = useCallback(
async (delta: Delta) => {
if (!controller) return;
await dispatch(
updateNodeDeltaThunk({
id,
delta: delta.ops,
controller,
})
);
// reset pendding flag
penddingRef.current = false;
},
[controller, dispatch, id]
);
return {
update,
delta,
};
}

View File

@ -0,0 +1,55 @@
import { useCallback, useEffect, useState } from 'react';
import { RangeStatic } from 'quill';
import { useAppDispatch } from '$app/stores/store';
import { rangeActions } from '$app_reducers/document/slice';
import { useFocused, useRangeRef } from '$app/components/document/_shared/SubscribeSelection.hooks';
import { storeRangeThunk } from '$app_reducers/document/async-actions/range';
export function useSelection(id: string) {
const rangeRef = useRangeRef();
const { focusCaret, lastSelection } = useFocused(id);
const [selection, setSelection] = useState<RangeStatic | undefined>(undefined);
const dispatch = useAppDispatch();
const storeRange = useCallback(
(range: RangeStatic) => {
dispatch(storeRangeThunk({ id, range }));
},
[id, dispatch]
);
const onSelectionChange = useCallback(
(range: RangeStatic | null, _oldRange: RangeStatic | null, _source?: string) => {
if (!range) return;
dispatch(
rangeActions.setCaret({
id,
index: range.index,
length: range.length,
})
);
storeRange(range);
},
[id, dispatch, storeRange]
);
useEffect(() => {
if (rangeRef.current && rangeRef.current?.isDragging) return;
const caret = focusCaret;
if (!caret) {
return;
}
setSelection({
index: caret.index,
length: caret.length,
});
}, [rangeRef, focusCaret]);
return {
onSelectionChange,
selection,
lastSelection,
};
}

View File

@ -0,0 +1,23 @@
.ql-container.ql-snow {
border: none;
font-family: 'Poppins', sans-serif;
font-size: inherit;
line-height: inherit;
}
.ql-editor {
outline: none;
max-width: 100%;
white-space: pre-wrap;
word-break: break-word;
padding: 4px 2px;
text-align: left;
flex-grow: 1;
}
.ql-editor.ql-blank::before {
left: 2px;
right: 2px;
font-style: normal;
}

View File

@ -0,0 +1,30 @@
import React from 'react';
import { useEditor } from '$app/components/document/_shared/QuillEditor/useEditor';
import 'quill/dist/quill.snow.css';
import './Editor.css';
import { EditorProps } from '$app/interfaces/document';
function Editor({
value,
onChange,
onSelectionChange,
selection,
placeholder = "Type '/' for commands",
...props
}: EditorProps) {
const { ref, editor } = useEditor({
value,
onChange,
onSelectionChange,
selection,
placeholder,
});
return (
<div className={'min-h-[30px]'}>
<div ref={ref} {...props} />
{!editor && <div className={'px-0.5 py-1 text-shade-4'}>{placeholder}</div>}
</div>
);
}
export default React.memo(Editor);

View File

@ -0,0 +1,100 @@
import { useEffect, useRef, useState } from 'react';
import Quill, { Sources } from 'quill';
import Delta from 'quill-delta';
import { adaptDeltaForQuill } from '$app/utils/document/quill_editor';
import { EditorProps } from '$app/interfaces/document';
/**
* Here we can use ts-ignore because the quill-delta's version of quill is not uploaded to DefinitelyTyped
*/
export function useEditor({ placeholder, value, onChange, onSelectionChange, selection }: EditorProps) {
const ref = useRef<HTMLDivElement>(null);
const [editor, setEditor] = useState<Quill>();
useEffect(() => {
if (!ref.current) return;
const editor = new Quill(ref.current, {
modules: {
toolbar: false, // Snow includes toolbar by default
},
theme: 'snow',
formats: ['bold', 'italic', 'underline', 'strike', 'code'],
placeholder: placeholder || 'Please enter some text...',
});
const keyboard = editor.getModule('keyboard');
// clear all keyboard bindings
keyboard.bindings = {};
const initialDelta = new Delta(adaptDeltaForQuill(value?.ops || []));
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
editor.setContents(initialDelta);
setEditor(editor);
}, []);
// listen to text-change event
useEffect(() => {
if (!editor) return;
const onTextChange = (delta: Delta, oldContents: Delta, source: Sources) => {
const newContents = oldContents.compose(delta);
const newOps = adaptDeltaForQuill(newContents.ops, true);
const newDelta = new Delta(newOps);
onChange?.(newDelta, oldContents, source);
if (source === 'user') {
const selection = editor.getSelection(false);
onSelectionChange?.(selection, null, source);
}
};
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
editor.on('text-change', onTextChange);
return () => {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
editor.off('text-change', onTextChange);
};
}, [editor, onChange, onSelectionChange]);
// listen to selection-change event
useEffect(() => {
const handleSelectionChange = () => {
if (!editor) return;
const selection = editor.getSelection(false);
onSelectionChange?.(selection, null, 'user');
};
document.addEventListener('selectionchange', handleSelectionChange);
return () => {
document.removeEventListener('selectionchange', handleSelectionChange);
};
}, [editor, onSelectionChange]);
// set value
useEffect(() => {
if (!editor) return;
const content = editor.getContents();
const newOps = adaptDeltaForQuill(value?.ops || []);
const newDelta = new Delta(newOps);
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
const diffDelta = content.diff(newDelta);
const isSame = diffDelta.ops.length === 0;
if (isSame) return;
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
editor.updateContents(diffDelta, 'api');
}, [editor, value]);
// set Selection
useEffect(() => {
if (!editor || !selection) return;
if (JSON.stringify(selection) === JSON.stringify(editor.getSelection())) return;
editor.setSelection(selection);
}, [selection, editor]);
return {
ref,
editor,
};
}

View File

@ -0,0 +1,34 @@
import React from 'react';
import { CodeEditorProps } from '$app/interfaces/document';
import { Editable, Slate } from 'slate-react';
import { useEditor } from '$app/components/document/_shared/SlateEditor/useEditor';
import { decorateCode } from '$app/components/document/_shared/SlateEditor/decorateCode';
import { CodeLeaf, CodeBlockElement } from '$app/components/document/_shared/SlateEditor/CodeElements';
function CodeEditor({ language, ...props }: CodeEditorProps) {
const { editor, onChange, value, onDOMBeforeInput, decorate, ref, onKeyDown, onBlur } = useEditor({
...props,
isCodeBlock: true,
});
return (
<div ref={ref}>
<Slate editor={editor} onChange={onChange} value={value}>
<Editable
decorate={(entry) => {
const codeRange = decorateCode(entry, language);
const range = decorate?.(entry) || [];
return [...range, ...codeRange];
}}
renderLeaf={CodeLeaf}
renderElement={CodeBlockElement}
onKeyDown={onKeyDown}
onDOMBeforeInput={onDOMBeforeInput}
onBlur={onBlur}
/>
</Slate>
</div>
);
}
export default React.memo(CodeEditor);

View File

@ -5,10 +5,10 @@ interface CodeLeafProps extends RenderLeafProps {
leaf: BaseText & {
bold?: boolean;
italic?: boolean;
underlined?: boolean;
underline?: boolean;
strikethrough?: boolean;
prism_token?: string;
selectionHighlighted?: boolean;
selection_high_lighted?: boolean;
};
}
@ -24,7 +24,7 @@ export const CodeLeaf = (props: CodeLeafProps) => {
newChildren = <em>{newChildren}</em>;
}
if (leaf.underlined) {
if (leaf.underline) {
newChildren = <u>{newChildren}</u>;
}
@ -32,7 +32,7 @@ export const CodeLeaf = (props: CodeLeafProps) => {
'token',
leaf.prism_token && leaf.prism_token,
leaf.strikethrough && 'line-through',
leaf.selectionHighlighted && 'bg-main-secondary',
leaf.selection_high_lighted && 'bg-main-secondary',
].filter(Boolean);
return (

View File

@ -0,0 +1,28 @@
import React from 'react';
import { EditorProps } from '$app/interfaces/document';
import { Editable, Slate } from 'slate-react';
import { useEditor } from '$app/components/document/_shared/SlateEditor/useEditor';
import TextLeaf from '$app/components/document/_shared/SlateEditor/TextLeaf';
import { TextElement } from '$app/components/document/_shared/SlateEditor/TextElement';
function TextEditor({ placeholder = "Type '/' for commands", ...props }: EditorProps) {
const { editor, onChange, value, onDOMBeforeInput, decorate, ref, onKeyDown, onBlur } = useEditor(props);
return (
<div ref={ref} className={'py-0.5'}>
<Slate editor={editor} onChange={onChange} value={value}>
<Editable
onKeyDown={onKeyDown}
onDOMBeforeInput={onDOMBeforeInput}
decorate={decorate}
renderLeaf={TextLeaf}
placeholder={placeholder}
onBlur={onBlur}
renderElement={TextElement}
/>
</Slate>
</div>
);
}
export default React.memo(TextEditor);

View File

@ -0,0 +1,65 @@
import { RenderElementProps } from 'slate-react';
import React, { useEffect, useRef } from 'react';
export function TextElement(props: RenderElementProps) {
const ref = useRef<HTMLDivElement | null>(null);
useEffect(() => {
if (!ref.current) return;
amendCodeLeafs(ref.current);
});
return (
<div
{...props.attributes}
ref={(e) => {
ref.current = e;
props.attributes.ref(e);
}}
>
{props.children}
</div>
);
}
function amendCodeLeafs(textElement: Element) {
const leafNodes = textElement.querySelectorAll(`[data-slate-leaf="true"]`);
let codeLeafNodes: Element[] = [];
leafNodes?.forEach((leafNode, index) => {
const isCodeLeaf = leafNode.classList.contains('inline-code');
if (isCodeLeaf) {
codeLeafNodes.push(leafNode);
} else {
if (codeLeafNodes.length > 0) {
addStyleToCodeLeafs(codeLeafNodes);
codeLeafNodes = [];
}
}
if (codeLeafNodes.length > 0 && index === leafNodes.length - 1) {
addStyleToCodeLeafs(codeLeafNodes);
codeLeafNodes = [];
}
});
}
function addStyleToCodeLeafs(codeLeafs: Element[]) {
if (codeLeafs.length === 0) return;
if (codeLeafs.length === 1) {
const codeNode = codeLeafs[0].firstChild as Element;
codeNode.classList.add('rounded', 'px-1.5');
return;
}
codeLeafs.forEach((codeLeaf, index) => {
const codeNode = codeLeaf.firstChild as Element;
codeNode.classList.remove('rounded', 'px-1.5');
codeNode.classList.remove('rounded-l', 'pl-1.5');
codeNode.classList.remove('rounded-r', 'pr-1.5');
if (index === 0) {
codeNode.classList.add('rounded-l', 'pl-1.5');
return;
}
if (index === codeLeafs.length - 1) {
codeNode.classList.add('rounded-r', 'pr-1.5');
return;
}
});
}

View File

@ -0,0 +1,61 @@
import { RenderLeafProps } from 'slate-react';
import { BaseText } from 'slate';
import { useRef } from 'react';
interface TextLeafProps extends RenderLeafProps {
leaf: BaseText & {
bold?: boolean;
italic?: boolean;
underline?: boolean;
strikethrough?: boolean;
code?: string;
selection_high_lighted?: boolean;
};
}
const TextLeaf = (props: TextLeafProps) => {
const { attributes, children, leaf } = props;
const ref = useRef<HTMLSpanElement>(null);
let newChildren = children;
if (leaf.bold) {
newChildren = <strong>{children}</strong>;
}
if (leaf.italic) {
newChildren = <em>{newChildren}</em>;
}
if (leaf.underline) {
newChildren = <u>{newChildren}</u>;
}
if (leaf.code) {
newChildren = (
<span
className={`bg-custom-code text-main-hovered`}
style={{
fontSize: '85%',
lineHeight: 'normal',
}}
>
{newChildren}
</span>
);
}
const className = [
leaf.strikethrough && 'line-through',
leaf.selection_high_lighted && 'bg-main-secondary',
leaf.code && 'inline-code',
].filter(Boolean);
return (
<span ref={ref} {...attributes} className={className.join(' ')}>
{newChildren}
</span>
);
};
export default TextLeaf;

View File

@ -1,34 +1,11 @@
import Prism from 'prismjs';
import 'prismjs/themes/prism.css';
import 'prismjs/components/prism-bash';
import 'prismjs/components/prism-c';
import 'prismjs/components/prism-cpp';
import 'prismjs/components/prism-csharp';
import 'prismjs/components/prism-css';
import 'prismjs/components/prism-dart';
import 'prismjs/components/prism-docker';
import 'prismjs/components/prism-go';
import 'prismjs/components/prism-graphql';
import 'prismjs/components/prism-groovy';
import 'prismjs/components/prism-http';
import 'prismjs/components/prism-java';
import 'prismjs/components/prism-javascript';
import 'prismjs/components/prism-json';
import 'prismjs/components/prism-less';
import 'prismjs/components/prism-typescript';
import 'prismjs/components/prism-markdown';
import 'prismjs/components/prism-python';
import 'prismjs/components/prism-yaml';
import 'prismjs/components/prism-regex';
import 'prismjs/components/prism-ruby';
import 'prismjs/components/prism-rust';
import 'prismjs/components/prism-sass';
import 'prismjs/components/prism-swift';
import 'prismjs/components/prism-php';
import 'prismjs/components/prism-sql';
import 'prismjs/components/prism-visual-basic';
import { BaseRange, NodeEntry, Text, Path, Range, Editor } from 'slate';
import { BaseRange, NodeEntry, Text, Path } from 'slate';
const push_string = (
token: string | Prism.Token,
@ -75,7 +52,7 @@ const recurseTokenize = (
}
};
export const decorateCodeFunc = ([node, path]: NodeEntry, language: string) => {
export const decorateCode = ([node, path]: NodeEntry, language: string) => {
const ranges: BaseRange[] = [];
if (!Text.isText(node)) {
return ranges;

View File

@ -0,0 +1,142 @@
import { EditorProps } from "$app/interfaces/document";
import { useCallback, useEffect, useMemo, useRef } from "react";
import { ReactEditor } from "slate-react";
import { BaseRange, Descendant, Editor, NodeEntry, Range, Selection } from "slate";
import {
converToIndexLength,
convertToDelta,
convertToSlateSelection,
indent,
outdent
} from "$app/utils/document/slate_editor";
import { focusNodeByIndex } from "$app/utils/document/node";
import { Keyboard } from "$app/constants/document/keyboard";
import Delta from "quill-delta";
import isHotkey from "is-hotkey";
import { useSlateYjs } from "$app/components/document/_shared/SlateEditor/useSlateYjs";
export function useEditor({
onChange,
onSelectionChange,
selection,
value: delta,
lastSelection,
onKeyDown,
isCodeBlock,
}: EditorProps) {
const editor = useSlateYjs({ delta });
const ref = useRef<HTMLDivElement | null>(null);
const newValue = useMemo(() => [], []);
const onSelectionChangeHandler = useCallback(
(slateSelection: Selection) => {
const rangeStatic = converToIndexLength(editor, slateSelection);
onSelectionChange?.(rangeStatic, null);
},
[editor, onSelectionChange]
);
const onChangeHandler = useCallback(
(slateValue: Descendant[]) => {
const oldContents = delta || new Delta();
onChange?.(convertToDelta(slateValue), oldContents);
onSelectionChangeHandler(editor.selection);
},
[delta, editor.selection, onChange, onSelectionChangeHandler]
);
const onDOMBeforeInput = useCallback((e: InputEvent) => {
// COMPAT: in Apple, `compositionend` is dispatched after the `beforeinput` for "insertFromComposition".
// It will cause repeated characters when inputting Chinese.
// Here, prevent the beforeInput event and wait for the compositionend event to take effect.
if (e.inputType === 'insertFromComposition') {
e.preventDefault();
}
}, []);
const decorate = useCallback(
(entry: NodeEntry) => {
const [node, path] = entry;
if (!lastSelection) return [];
const slateSelection = convertToSlateSelection(lastSelection.index, lastSelection.length, editor.children);
if (slateSelection && !Range.isCollapsed(slateSelection as BaseRange)) {
const intersection = Range.intersection(slateSelection, Editor.range(editor, path));
if (!intersection) {
return [];
}
const range = {
selection_high_lighted: true,
...intersection,
};
return [range];
}
return [];
},
[editor, lastSelection]
);
const onKeyDownRewrite = useCallback(
(event: React.KeyboardEvent<HTMLDivElement>) => {
onKeyDown?.(event);
const insertBreak = () => {
event.preventDefault();
editor.insertText('\n');
};
// There is different behavior for code block and normal text
// In code block, we press enter to insert a new line
// In normal text, we press shift + enter to insert a new line
if (isCodeBlock) {
if (isHotkey(Keyboard.keys.ENTER, event)) {
insertBreak();
return;
}
if (isHotkey(Keyboard.keys.TAB, event)) {
event.preventDefault();
indent(editor, 2);
return;
}
if (isHotkey(Keyboard.keys.SHIFT_TAB, event)) {
event.preventDefault();
outdent(editor, 2);
return;
}
} else if (isHotkey(Keyboard.keys.SHIFT_ENTER, event)) {
insertBreak();
}
},
[editor, onKeyDown, isCodeBlock]
);
const onBlur = useCallback(
(_event: React.FocusEvent<HTMLDivElement>) => {
editor.deselect();
},
[editor]
);
useEffect(() => {
if (!selection || !ref.current) return;
const slateSelection = convertToSlateSelection(selection.index, selection.length, editor.children);
if (!slateSelection) return;
const isFocused = ReactEditor.isFocused(editor);
if (isFocused && JSON.stringify(slateSelection) === JSON.stringify(editor.selection)) return;
focusNodeByIndex(ref.current, selection.index, selection.length);
}, [editor, selection]);
return {
editor,
value: newValue,
onChange: onChangeHandler,
onDOMBeforeInput,
decorate,
ref,
onKeyDown: onKeyDownRewrite,
onBlur,
};
}

View File

@ -0,0 +1,43 @@
import Delta from "quill-delta";
import { useEffect, useMemo, useRef } from "react";
import * as Y from "yjs";
import { convertToSlateValue } from "$app/utils/document/slate_editor";
import { slateNodesToInsertDelta, withYjs, YjsEditor } from "@slate-yjs/core";
import { withReact } from "slate-react";
import { createEditor } from "slate";
export function useSlateYjs({ delta }: { delta?: Delta }) {
const yTextRef = useRef<Y.Text>();
const sharedType = useMemo(() => {
const yDoc = new Y.Doc();
const sharedType = yDoc.get("content", Y.XmlText) as Y.XmlText;
const value = convertToSlateValue(delta || new Delta());
const insertDelta = slateNodesToInsertDelta(value);
sharedType.applyDelta(insertDelta);
yTextRef.current = insertDelta[0].insert as Y.Text;
return sharedType;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const editor = useMemo(() => withYjs(withReact(createEditor()), sharedType), []);
// Connect editor in useEffect to comply with concurrent mode requirements.
useEffect(() => {
YjsEditor.connect(editor);
return () => {
yTextRef.current = undefined;
YjsEditor.disconnect(editor);
};
}, [editor]);
useEffect(() => {
const yText = yTextRef.current;
if (!yText) return;
const oldContents = new Delta(yText.toDelta());
const diffDelta = oldContents.diff(delta || new Delta());
if (diffDelta.ops.length === 0) return;
yText.applyDelta(diffDelta.ops);
}, [delta, editor]);
return editor;
}

View File

@ -1,8 +1,6 @@
import { useAppSelector } from '@/appflowy_app/stores/store';
import { useMemo, useRef } from 'react';
import { DocumentState, Node, RangeSelectionState } from '$app/interfaces/document';
import { nodeInRange } from '$app/utils/document/blocks/common';
import { getNodeEndSelection } from '$app/utils/document/blocks/text/delta';
import { store, useAppSelector } from '@/appflowy_app/stores/store';
import { useEffect, useMemo, useRef } from 'react';
import { Node } from '$app/interfaces/document';
/**
* Subscribe node information
@ -34,55 +32,6 @@ export function useSubscribeNode(id: string) {
};
}
/**
* Subscribe selection information
* @param id
*/
export function useSubscribeRangeSelection(id: string) {
const rangeRef = useRef<RangeSelectionState>();
const currentSelection = useAppSelector((state) => {
const range = state.documentRangeSelection;
rangeRef.current = range;
if (range.anchor?.id === id) {
return range.anchor.selection;
}
if (range.focus?.id === id) {
return range.focus.selection;
}
return getAmendInRangeNodeSelection(id, range, state.document);
});
return {
rangeRef,
currentSelection,
};
}
function getAmendInRangeNodeSelection(id: string, range: RangeSelectionState, document: DocumentState) {
if (!range.anchor || !range.focus || range.anchor.id === range.focus.id || range.isForward === undefined) {
return null;
}
const isNodeInRange = nodeInRange(
id,
{
startId: range.anchor.id,
endId: range.focus.id,
},
range.isForward,
document
);
if (isNodeInRange) {
const delta = document.nodes[id].data.delta;
return {
anchor: {
path: [0, 0],
offset: 0,
},
focus: getNodeEndSelection(delta).anchor,
};
}
export function getBlock(id: string) {
return store.getState().document.nodes[id];
}

View File

@ -0,0 +1,43 @@
import { useAppSelector } from '$app/stores/store';
import { RangeState, RangeStatic } from '$app/interfaces/document';
import { useMemo, useRef } from 'react';
export function useFocused(id: string) {
const caretRef = useRef<RangeStatic>();
const focusCaret = useAppSelector((state) => {
const currentCaret = state.documentRange.caret;
caretRef.current = currentCaret;
if (currentCaret?.id === id) {
return currentCaret;
}
return null;
});
const lastSelection = useAppSelector((state) => {
return state.documentRange.ranges[id];
});
const focused = useMemo(() => {
return focusCaret && focusCaret?.id === id;
}, [focusCaret, id]);
const memoizedLastSelection = useMemo(() => {
return lastSelection;
}, [JSON.stringify(lastSelection)]);
return {
focused,
caretRef,
focusCaret,
lastSelection: memoizedLastSelection,
};
}
export function useRangeRef() {
const rangeRef = useRef<RangeState>();
useAppSelector((state) => {
const currentRange = state.documentRange;
rangeRef.current = currentRange;
});
return rangeRef;
}

View File

@ -1,111 +0,0 @@
import { useAppDispatch } from '$app/stores/store';
import { useCallback, useContext } from 'react';
import { DocumentControllerContext } from '$app/stores/effects/document/document_controller';
import { backspaceNodeThunk, setCursorNextLineThunk, setCursorPreLineThunk } from '$app_reducers/document/async-actions';
import { keyBoardEventKeyMap } from '$app/constants/document/text_block';
import {
canHandleBackspaceKey,
canHandleDownKey,
canHandleLeftKey,
canHandleRightKey,
canHandleUpKey,
} from '$app/utils/document/blocks/text/hotkey';
import { TextBlockKeyEventHandlerParams } from '$app/interfaces/document';
import { ReactEditor } from 'slate-react';
export function useDefaultTextInputEvents(id: string) {
const dispatch = useAppDispatch();
const controller = useContext(DocumentControllerContext);
const focusPreLineAction = useCallback(
async (params: { editor: ReactEditor; focusEnd?: boolean }) => {
await dispatch(setCursorPreLineThunk({ id, ...params }));
},
[dispatch, id]
);
const focusNextLineAction = useCallback(
async (params: { editor: ReactEditor; focusStart?: boolean }) => {
await dispatch(setCursorNextLineThunk({ id, ...params }));
},
[dispatch, id]
);
return [
{
triggerEventKey: keyBoardEventKeyMap.Up,
canHandle: canHandleUpKey,
handler: (...args: TextBlockKeyEventHandlerParams) => {
const [e, _] = args;
e.preventDefault();
void focusPreLineAction({
editor: args[1],
});
},
},
{
triggerEventKey: keyBoardEventKeyMap.Down,
canHandle: canHandleDownKey,
handler: (...args: TextBlockKeyEventHandlerParams) => {
const [e, _] = args;
e.preventDefault();
void focusNextLineAction({
editor: args[1],
});
},
},
{
triggerEventKey: keyBoardEventKeyMap.Left,
canHandle: canHandleLeftKey,
handler: (...args: TextBlockKeyEventHandlerParams) => {
const [e, _] = args;
e.preventDefault();
void focusPreLineAction({
editor: args[1],
focusEnd: true,
});
},
},
{
triggerEventKey: keyBoardEventKeyMap.Right,
canHandle: canHandleRightKey,
handler: (...args: TextBlockKeyEventHandlerParams) => {
const [e, _] = args;
e.preventDefault();
void focusNextLineAction({
editor: args[1],
focusStart: true,
});
},
},
{
triggerEventKey: keyBoardEventKeyMap.Backspace,
canHandle: canHandleBackspaceKey,
handler: (...args: TextBlockKeyEventHandlerParams) => {
const [e, editor] = args;
e.preventDefault();
void (async () => {
if (!controller) return;
await dispatch(backspaceNodeThunk({ id, controller, editor }));
})();
},
},
// Here prevent the default behavior of the enter key
{
triggerEventKey: keyBoardEventKeyMap.Enter,
canHandle: (...args: TextBlockKeyEventHandlerParams) => args[0].key === 'Enter',
handler: (...args: TextBlockKeyEventHandlerParams) => {
const [e] = args;
e.preventDefault();
},
},
// Here prevent the default behavior of the tab key
{
triggerEventKey: keyBoardEventKeyMap.Tab,
canHandle: (...args: TextBlockKeyEventHandlerParams) => args[0].key === 'Tab',
handler: (...args: TextBlockKeyEventHandlerParams) => {
const [e] = args;
e.preventDefault();
},
},
];
}

View File

@ -1,134 +0,0 @@
import { createEditor, Descendant, Editor, Transforms } from 'slate';
import { withReact } from 'slate-react';
import { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
import { DocumentControllerContext } from '$app/stores/effects/document/document_controller';
import { TextDelta } from '$app/interfaces/document';
import { useAppDispatch } from '$app/stores/store';
import { updateNodeDeltaThunk } from '$app_reducers/document/async-actions/blocks/text/update';
import { deltaToSlateValue, slateValueToDelta } from '$app/utils/document/blocks/common';
import { isSameDelta } from '$app/utils/document/blocks/text/delta';
import { useSubscribeNode } from '$app/components/document/_shared/SubscribeNode.hooks';
import { useTextSelections } from '$app/components/document/_shared/Text/TextSelection.hooks';
export function useTextInput(id: string) {
const { node } = useSubscribeNode(id);
const [editor] = useState(() => withReact(createEditor()));
const isComposition = useRef(false);
const { setLastActiveSelection, ...selectionProps } = useTextSelections(id, editor);
const delta = useMemo(() => {
if (!node || !('delta' in node.data)) {
return [];
}
return node.data.delta;
}, [node]);
const [value, setValue] = useState<Descendant[]>(deltaToSlateValue(delta));
const { sync, receive } = useUpdateDelta(id, editor);
// Update the editor's value when the node's delta changes.
useEffect(() => {
// If composition is in progress, do nothing.
if (isComposition.current) return;
receive(delta, setValue);
}, [delta, receive]);
// Update the node's delta when the editor's value changes.
const onChange = useCallback(
(e: Descendant[]) => {
// Update the editor's value and selection.
setValue(e);
// If the selection is not null, update the last active selection.
if (editor.selection !== null) setLastActiveSelection(editor.selection);
// If composition is in progress, do nothing.
if (isComposition.current) return;
sync();
},
[editor.selection, setLastActiveSelection, sync]
);
const onDOMBeforeInput = useCallback((e: InputEvent) => {
// COMPAT: in Apple, `compositionend` is dispatched after the `beforeinput` for "insertFromComposition".
// It will cause repeated characters when inputting Chinese.
// Here, prevent the beforeInput event and wait for the compositionend event to take effect.
if (e.inputType === 'insertFromComposition') {
e.preventDefault();
}
}, []);
const onCompositionStart = useCallback(() => {
isComposition.current = true;
}, []);
const onCompositionUpdate = useCallback(() => {
isComposition.current = true;
}, []);
const onCompositionEnd = useCallback(() => {
isComposition.current = false;
}, []);
return {
editor,
onChange,
value,
...selectionProps,
onDOMBeforeInput,
onCompositionStart,
onCompositionUpdate,
onCompositionEnd,
};
}
function useUpdateDelta(id: string, editor: Editor) {
const controller = useContext(DocumentControllerContext);
const dispatch = useAppDispatch();
const penddingRef = useRef(false);
const update = useCallback(() => {
if (!controller) return;
const delta = slateValueToDelta(editor.children);
void (async () => {
await dispatch(
updateNodeDeltaThunk({
id,
delta,
controller,
})
);
// reset pendding flag
penddingRef.current = false;
})();
}, [controller, dispatch, editor, id]);
const sync = useCallback(() => {
// set pendding flag
penddingRef.current = true;
update();
}, [update]);
const receive = useCallback(
(delta: TextDelta[], setValue: (children: Descendant[]) => void) => {
// if pendding, do nothing
if (penddingRef.current) return;
// If the delta is the same as the editor's value, do nothing.
const localDelta = slateValueToDelta(editor.children);
const isSame = isSameDelta(delta, localDelta);
if (isSame) return;
Transforms.deselect(editor);
const slateValue = deltaToSlateValue(delta);
editor.children = slateValue;
setValue(slateValue);
},
[editor]
);
return {
sync,
receive,
};
}

View File

@ -1,98 +0,0 @@
import { MouseEvent, useCallback, useEffect, useRef } from 'react';
import { BaseRange, Editor, Node, Path, Range, Transforms } from 'slate';
import { EditableProps } from 'slate-react/dist/components/editable';
import { useSubscribeRangeSelection } from '$app/components/document/_shared/SubscribeNode.hooks';
import { useAppDispatch } from '$app/stores/store';
import { TextSelection } from '$app/interfaces/document';
import { ReactEditor } from 'slate-react';
import { syncRangeSelectionThunk } from '$app_reducers/document/async-actions/range_selection';
import { getNodeEndSelection } from '$app/utils/document/blocks/text/delta';
import { slateValueToDelta } from '$app/utils/document/blocks/common';
import { isEqual } from '$app/utils/tool';
export function useTextSelections(id: string, editor: ReactEditor) {
const { rangeRef, currentSelection } = useSubscribeRangeSelection(id);
const dispatch = useAppDispatch();
useEffect(() => {
if (!rangeRef.current) return;
if (!currentSelection) {
ReactEditor.deselect(editor);
ReactEditor.blur(editor);
return;
}
const { isDragging, focus } = rangeRef.current;
if (isDragging || focus?.id !== id) return;
if (!ReactEditor.isFocused(editor)) {
ReactEditor.focus(editor);
}
if (!isEqual(editor.selection, currentSelection)) {
Transforms.select(editor, currentSelection);
}
}, [currentSelection, editor, id, rangeRef]);
const decorate: EditableProps['decorate'] = useCallback(
(entry: [Node, Path]) => {
const [node, path] = entry;
if (currentSelection && !Range.isCollapsed(currentSelection as BaseRange)) {
const intersection = Range.intersection(currentSelection, Editor.range(editor, path));
if (!intersection) {
return [];
}
const range = {
selectionHighlighted: true,
...intersection,
};
return [range];
}
return [];
},
[editor, currentSelection]
);
const setLastActiveSelection = useCallback(
(lastActiveSelection: Range) => {
const selection = lastActiveSelection as TextSelection;
dispatch(syncRangeSelectionThunk({ id, selection }));
},
[dispatch, id]
);
const onBlur = useCallback(() => {
ReactEditor.deselect(editor);
}, [editor]);
const onMouseMove = useCallback(
(e: MouseEvent) => {
if (!rangeRef.current) return;
const { isDragging, isForward, anchor } = rangeRef.current;
if (!isDragging || !anchor) return;
if (ReactEditor.isFocused(editor)) {
return;
}
if (anchor.id === id) {
Transforms.select(editor, anchor.selection);
} else if (!isForward) {
const endSelection = getNodeEndSelection(slateValueToDelta(editor.children));
Transforms.select(editor, {
anchor: endSelection.anchor,
focus: editor.selection?.focus || endSelection.focus,
});
}
ReactEditor.focus(editor);
},
[editor, id, rangeRef]
);
return {
decorate,
onBlur,
onMouseMove,
setLastActiveSelection,
};
}

View File

@ -1,12 +1,4 @@
export const supportLanguage = [
{
id: 'css',
title: 'CSS',
},
{
id: 'html',
title: 'HTML',
},
{
id: 'javascript',
title: 'JavaScript',
@ -15,78 +7,13 @@ export const supportLanguage = [
id: 'json',
title: 'JSON',
},
{
id: 'markdown',
title: 'Markdown',
},
{
id: 'python',
title: 'Python',
},
{
id: 'typescript',
title: 'TypeScript',
},
{
id: 'xml',
title: 'XML',
},
{
id: 'yaml',
title: 'YAML',
},
{
id: 'bash',
title: 'Bash',
},
{
id: 'c',
title: 'C',
},
{
id: 'cpp',
title: 'C++',
},
{
id: 'csharp',
title: 'C#',
},
{
id: 'go',
title: 'Go',
},
{
id: 'java',
title: 'Java',
},
{
id: 'php',
title: 'PHP',
},
{
id: 'ruby',
title: 'Ruby',
},
{
id: 'rust',
title: 'Rust',
},
{
id: 'swift',
title: 'Swift',
},
{
id: 'sql',
title: 'SQL',
},
{
id: 'vb',
title: 'Visual Basic',
},
{
id: 'dart',
title: 'Dart',
},
];

View File

@ -0,0 +1,32 @@
export const Keyboard = {
codes: {
BACKSPACE: 8,
TAB: 9,
ENTER: 13,
ESCAPE: 27,
SPACE: 32,
LEFT: 37,
UP: 38,
RIGHT: 39,
DOWN: 40,
DELETE: 46,
},
keys: {
BACKSPACE: 'Backspace',
TAB: 'Tab',
ENTER: 'Enter',
ESCAPE: 'Escape',
SPACE: ' ',
LEFT: 'ArrowLeft',
UP: 'ArrowUp',
RIGHT: 'ArrowRight',
DOWN: 'ArrowDown',
DELETE: 'Delete',
SHIFT_ENTER: 'Shift+Enter',
SHIFT_TAB: 'Shift+Tab',
Slash: '/',
Space: ' ',
Reduce: '-',
BackQuote: '`',
},
};

View File

@ -1,13 +0,0 @@
export const keyBoardEventKeyMap = {
Enter: 'Enter',
Backspace: 'Backspace',
Tab: 'Tab',
Up: 'ArrowUp',
Down: 'ArrowDown',
Left: 'ArrowLeft',
Right: 'ArrowRight',
Space: ' ',
Reduce: '-',
Backquote: '`',
Slash: '/',
};

View File

@ -1,6 +1,13 @@
import { Editor } from 'slate';
import { RegionGrid } from '$app/utils/region_grid';
import { ReactEditor } from 'slate-react';
import Delta, { Op } from 'quill-delta';
import { BlockActionTypePB } from '@/services/backend';
import { Sources } from 'quill';
import React from 'react';
export interface RangeStatic {
id: string;
length: number;
index: number;
}
export enum BlockType {
PageBlock = 'page',
@ -50,7 +57,7 @@ export interface CalloutBlockData extends TextBlockData {
}
export interface TextBlockData {
delta: TextDelta[];
delta: Op[];
}
export interface DividerBlockData {}
@ -86,38 +93,9 @@ export interface NestedBlock<Type = any> {
parent: string | null;
children: string;
}
export interface TextDelta {
insert: string;
attributes?: Record<string, string | boolean | undefined>;
}
export enum BlockActionType {
Insert = 0,
Update = 1,
Delete = 2,
Move = 3,
}
export interface DeltaItem {
action: 'inserted' | 'removed' | 'updated';
payload: {
id: string;
value?: NestedBlock | string[];
};
}
export type Node = NestedBlock;
export interface SelectionPoint {
path: [number, number];
offset: number;
}
export interface TextSelection {
anchor: SelectionPoint;
focus: SelectionPoint;
}
export interface DocumentData {
rootId: string;
// map of block id to block
@ -140,17 +118,35 @@ export interface RectSelectionState {
selection: string[];
isDragging: boolean;
}
export interface RangeSelectionState {
anchor?: PointState;
focus?: PointState;
isForward?: boolean;
isDragging: boolean;
selection: string[];
}
export interface PointState {
id: string;
selection: TextSelection;
export interface RangeState {
anchor?: {
id: string;
point: {
x: number;
y: number;
index?: number;
length?: number;
};
};
focus?: {
id: string;
point: {
x: number;
y: number;
};
};
ranges: Partial<
Record<
string,
{
index: number;
length: number;
}
>
>;
isDragging: boolean;
caret?: RangeStatic;
}
export enum ChangeType {
@ -170,8 +166,6 @@ export interface BlockPBValue {
data: string;
}
export type TextBlockKeyEventHandlerParams = [React.KeyboardEvent<HTMLDivElement>, ReactEditor & Editor];
export enum SplitRelationship {
NextSibling,
FirstChild,
@ -180,7 +174,7 @@ export enum TextAction {
Turn = 'turn',
Bold = 'bold',
Italic = 'italic',
Underline = 'underlined',
Underline = 'underline',
Strikethrough = 'strikethrough',
Code = 'code',
Equation = 'equation',
@ -230,3 +224,31 @@ export interface BlockConfig {
*/
textActionMenuProps?: TextActionMenuProps;
}
export interface ControllerAction {
action: BlockActionTypePB;
payload: {
block: { id: string; parent_id: string; children_id: string; data: string; ty: BlockType };
parent_id: string;
prev_id: string;
};
}
export interface RangeStaticNoId {
index: number;
length: number;
}
export interface CodeEditorProps extends EditorProps {
language: string;
}
export interface EditorProps {
isCodeBlock?: boolean;
placeholder?: string;
value?: Delta;
selection?: RangeStaticNoId;
lastSelection?: RangeStaticNoId;
onSelectionChange?: (range: RangeStaticNoId | null, oldRange: RangeStaticNoId | null, source?: Sources) => void;
onChange?: (delta: Delta, oldDelta: Delta, source?: Sources) => void;
onKeyDown?: (event: React.KeyboardEvent<HTMLDivElement>) => void;
}

View File

@ -14,7 +14,7 @@ import { DocumentObserver } from './document_observer';
import * as Y from 'yjs';
import { BLOCK_MAP_NAME, CHILDREN_MAP_NAME, META_NAME } from '$app/constants/document/block';
import { get } from '@/appflowy_app/utils/tool';
import { blockPB2Node } from '$app/utils/document/blocks/common';
import { blockPB2Node } from '$app/utils/document/block';
import { Log } from '$app/utils/log';
export const DocumentControllerContext = createContext<DocumentController | null>(null);

View File

@ -1,22 +1,23 @@
import { DocumentState } from '$app/interfaces/document';
import { DocumentController } from '$app/stores/effects/document/document_controller';
import { createAsyncThunk } from '@reduxjs/toolkit';
import { newBlock } from '$app/utils/document/blocks/common';
import { newBlock } from '$app/utils/document/block';
import { rectSelectionActions } from '$app_reducers/document/slice';
import { getDuplicateActions } from '$app/utils/document/action';
export const duplicateBelowNodeThunk = createAsyncThunk(
'document/duplicateBelowNode',
async (payload: { id: string; controller: DocumentController }, thunkAPI) => {
const { id, controller } = payload;
const { getState } = thunkAPI;
const { getState, dispatch } = thunkAPI;
const state = getState() as { document: DocumentState };
const node = state.document.nodes[id];
if (!node) return;
const parentId = node.parent;
if (!parentId) return;
// duplicate new node
const newNode = newBlock<any>(node.type, parentId, node.data);
await controller.applyActions([controller.getInsertAction(newNode, node.id)]);
if (!node || !node.parent) return;
const duplicateActions = getDuplicateActions(id, node.parent, state.document, controller);
return newNode.id;
if (!duplicateActions) return;
await controller.applyActions(duplicateActions.actions);
dispatch(rectSelectionActions.updateSelections([duplicateActions.newNodeId]));
}
);

View File

@ -2,7 +2,7 @@ import { DocumentState } from '$app/interfaces/document';
import { DocumentController } from '$app/stores/effects/document/document_controller';
import { createAsyncThunk } from '@reduxjs/toolkit';
import { blockConfig } from '$app/constants/document/config';
import { getPrevNodeId } from "$app/utils/document/blocks/common";
import { getPrevNodeId } from '$app/utils/document/block';
/**
* indent node
@ -33,7 +33,7 @@ export const indentNodeThunk = createAsyncThunk(
const newPrevId = newParentChildren[newParentChildren.length - 1];
const moveAction = controller.getMoveAction(node, newParentNode.id, newPrevId);
const childrenNodes = state.children[node.children].map(id => state.nodes[id]);
const childrenNodes = state.children[node.children].map((id) => state.nodes[id]);
const moveChildrenActions = controller.getMoveChildrenAction(childrenNodes, newParentNode.id, node.id);
await controller.applyActions([moveAction, ...moveChildrenActions]);

View File

@ -1,4 +1,7 @@
export * from './text';
export * from './delete';
export * from './duplicate';
export * from './insert';
export * from './merge';
export * from './update';
export * from './indent';
export * from './outdent';

View File

@ -1,7 +1,7 @@
import { BlockData, BlockType, DocumentState } from '$app/interfaces/document';
import { DocumentController } from '$app/stores/effects/document/document_controller';
import { createAsyncThunk } from '@reduxjs/toolkit';
import { newBlock } from '$app/utils/document/blocks/common';
import { newBlock } from '$app/utils/document/block';
export const insertAfterNodeThunk = createAsyncThunk(
'document/insertAfterNode',
@ -21,8 +21,17 @@ export const insertAfterNodeThunk = createAsyncThunk(
if (!parentId) return;
// create new node
const newNode = newBlock<any>(type, parentId, data);
await controller.applyActions([controller.getInsertAction(newNode, node.id)]);
let nodeId = newNode.id;
const actions = [controller.getInsertAction(newNode, node.id)];
if (type === BlockType.DividerBlock) {
const newTextNode = newBlock<any>(BlockType.TextBlock, parentId, {
delta: [],
});
nodeId = newTextNode.id;
actions.push(controller.getInsertAction(newTextNode, newNode.id));
}
await controller.applyActions(actions);
return newNode.id;
return nodeId;
}
);

View File

@ -0,0 +1,49 @@
import { createAsyncThunk } from '@reduxjs/toolkit';
import { DocumentController } from '$app/stores/effects/document/document_controller';
import { DocumentState } from '$app/interfaces/document';
import Delta from 'quill-delta';
import { blockConfig } from '$app/constants/document/config';
/**
* Merge two blocks
* 1. merge delta
* 2. move children
* 3. delete current block
*/
export const mergeDeltaThunk = createAsyncThunk(
'document/mergeDelta',
async (payload: { sourceId: string; targetId: string; controller: DocumentController }, thunkAPI) => {
const { sourceId, targetId, controller } = payload;
const { getState } = thunkAPI;
const state = (getState() as { document: DocumentState }).document;
const target = state.nodes[targetId];
const source = state.nodes[sourceId];
if (!target || !source) return;
const targetDelta = new Delta(target.data.delta);
const sourceDelta = new Delta(source.data.delta);
const mergeDelta = targetDelta.concat(sourceDelta);
const ops = mergeDelta.ops;
const updateAction = controller.getUpdateAction({
...target,
data: {
...target.data,
delta: ops,
},
});
const actions = [updateAction];
// move children
const config = blockConfig[target.type];
const children = state.children[source.children].map((id) => state.nodes[id]);
const targetParentId = config.canAddChild ? target.id : target.parent;
if (!targetParentId) return;
const targetPrevId = targetParentId === target.id ? '' : target.id;
const moveActions = controller.getMoveChildrenAction(children, targetParentId, targetPrevId);
actions.push(...moveActions);
// delete current block
const deleteAction = controller.getDeleteAction(source);
actions.push(deleteAction);
await controller.applyActions(actions);
}
);

View File

@ -1,44 +0,0 @@
import { BlockType, DocumentState } from '$app/interfaces/document';
import { DocumentController } from '$app/stores/effects/document/document_controller';
import { createAsyncThunk } from '@reduxjs/toolkit';
import { outdentNodeThunk } from './outdent';
import { turnToTextBlockThunk } from '$app_reducers/document/async-actions/blocks/text/turn_to';
import { mergeToPrevLineThunk } from '$app_reducers/document/async-actions/blocks/text/merge';
import { ReactEditor } from 'slate-react';
/**
* 1. If current node is not text block, turn it to text block
* 2. If current node is text block
* 2.1 If the current node has next node, merge it to the previous line
* 2.2 If the parent is root, merge it to the previous line
* 2.3 If the parent is not root and has no next node, outdent it
*/
export const backspaceNodeThunk = createAsyncThunk(
'document/backspaceNode',
async (payload: { id: string; controller: DocumentController; editor: ReactEditor }, thunkAPI) => {
const { id, controller, editor } = payload;
const { dispatch, getState } = thunkAPI;
const state = (getState() as { document: DocumentState }).document;
const node = state.nodes[id];
if (!node.parent) return;
const parent = state.nodes[node.parent];
const children = state.children[parent.children];
const index = children.indexOf(id);
const nextNodeId = children[index + 1];
// turn to text block
if (node.type !== BlockType.TextBlock) {
await dispatch(turnToTextBlockThunk({ id, controller }));
return;
}
const parentIsRoot = !parent.parent;
// merge to previous line when parent is root
if (parentIsRoot || nextNodeId) {
// merge to previous line
ReactEditor.deselect(editor);
await dispatch(mergeToPrevLineThunk({ id, controller, deleteCurrentNode: true }));
return;
}
// outdent
await dispatch(outdentNodeThunk({ id, controller }));
}
);

View File

@ -1,6 +0,0 @@
export * from './indent';
export * from './backspace';
export * from './outdent';
export * from './split';
export * from './turn_to';
export * from './update';

View File

@ -1,82 +0,0 @@
import { createAsyncThunk } from '@reduxjs/toolkit';
import { DocumentController } from '$app/stores/effects/document/document_controller';
import { DocumentState } from '$app/interfaces/document';
import { getCollapsedRange, getPrevLineId } from "$app/utils/document/blocks/common";
import { rangeSelectionActions } from "$app_reducers/document/slice";
import { blockConfig } from '$app/constants/document/config';
import { getNodeEndSelection } from '$app/utils/document/blocks/text/delta';
/**
* It will merge delta to the prev line
* 1. find the prev line and has delta
* 1.1 Set cursor after the prev line
* 1.2 merge delta
* 2. If deleteCurrentNode is true, delete the current node and move children
* 2.2.1 if the prev line can add children, move children to the prev line.
* 2.2.2 Otherwise, move children to the parent and below the prev line
* 3. If deleteCurrentNode is false, clear the current node delta
*/
export const mergeToPrevLineThunk = createAsyncThunk(
'document/codeBlockBackspace',
async (payload: { id: string; controller: DocumentController; deleteCurrentNode?: boolean }, thunkAPI) => {
const { id, controller, deleteCurrentNode = false } = payload;
const { dispatch, getState } = thunkAPI;
const state = (getState() as { document: DocumentState }).document;
const node = state.nodes[id];
const prevLineId = getPrevLineId(state, id);
if (!prevLineId) return;
let prevLine = state.nodes[prevLineId];
// Find the prev line that has delta
while (prevLine && !prevLine.data.delta) {
const id = getPrevLineId(state, prevLine.id);
if (!id) return;
prevLine = state.nodes[id];
}
if (!prevLine) return;
const prevLineDelta = prevLine.data.delta;
const selection = getNodeEndSelection(prevLineDelta);
const mergeDelta = [...prevLineDelta, ...node.data.delta];
const updateAction = controller.getUpdateAction({
...prevLine,
data: {
...prevLine.data,
delta: mergeDelta,
},
});
const actions = [updateAction];
if (deleteCurrentNode) {
// move children
const config = blockConfig[prevLine.type];
const children = state.children[node.children].map((id) => state.nodes[id]);
const targetParentId = config.canAddChild ? prevLine.id : prevLine.parent;
if (!targetParentId) return;
const targetPrevId = targetParentId === prevLine.id ? '' : prevLine.id;
const moveActions = controller.getMoveChildrenAction(children, targetParentId, targetPrevId);
actions.push(...moveActions);
// delete current block
const deleteAction = controller.getDeleteAction(node);
actions.push(deleteAction);
} else {
// clear current block delta
const updateAction = controller.getUpdateAction({
...node,
data: {
...node.data,
delta: [],
},
});
actions.push(updateAction);
}
await controller.applyActions(actions);
// set cursor after the prev line
const range = getCollapsedRange(prevLine.id, selection);
dispatch(rangeSelectionActions.setRange(range));
}
);

View File

@ -1,74 +0,0 @@
import { DocumentState, SplitRelationship } from '$app/interfaces/document';
import { DocumentController } from '$app/stores/effects/document/document_controller';
import { createAsyncThunk } from '@reduxjs/toolkit';
import { setCursorBeforeThunk } from '../../cursor';
import { newBlock } from '$app/utils/document/blocks/common';
import { blockConfig } from '$app/constants/document/config';
import { getSplitDelta } from '@/appflowy_app/utils/document/blocks/text/delta';
import { ReactEditor } from 'slate-react';
export const splitNodeThunk = createAsyncThunk(
'document/splitNode',
async (payload: { id: string; editor: ReactEditor; controller: DocumentController }, thunkAPI) => {
const { id, controller, editor } = payload;
// get the split content
const { retain, insert } = getSplitDelta(editor);
const { dispatch, getState } = thunkAPI;
const state = (getState() as { document: DocumentState }).document;
const node = state.nodes[id];
if (!node.parent) return;
const children = state.children[node.children];
const parent = state.nodes[node.parent];
const config = blockConfig[node.type].splitProps;
// Here we are using the splitProps property of the blockConfig object to determine the type of the new node.
// if the splitProps property is not defined for the block type, we throw an error.
if (!config) {
throw new Error(`Cannot split node of type ${node.type}`);
}
const newNodeType = config.nextLineBlockType;
const relationShip = config.nextLineRelationShip;
const defaultData = blockConfig[newNodeType].defaultData;
// if the defaultData property is not defined for the new block type, we throw an error.
if (!defaultData) {
throw new Error(`Cannot split node of type ${node.type} to ${newNodeType}`);
}
// if the next line is a sibling, parent is the same as the current node, and prev is the current node.
// otherwise, parent is the current node, and prev is empty.
const newParentId = relationShip === SplitRelationship.NextSibling ? parent.id : node.id;
const newPrevId = relationShip === SplitRelationship.NextSibling ? node.id : '';
const newNode = newBlock<any>(newNodeType, newParentId, {
...defaultData,
delta: insert,
});
const retainNode = {
...node,
data: {
...node.data,
delta: retain,
},
};
const insertAction = controller.getInsertAction(newNode, newPrevId);
const updateAction = controller.getUpdateAction(retainNode);
// if the next line is a sibling, we need to move the children of the current node to the new node.
// otherwise, we don't need to do anything.
const moveChildrenAction =
relationShip === SplitRelationship.NextSibling
? controller.getMoveChildrenAction(
children.map((id) => state.nodes[id]),
newNode.id,
''
)
: [];
await controller.applyActions([insertAction, ...moveChildrenAction, updateAction]);
ReactEditor.deselect(editor);
// set cursor
await dispatch(setCursorBeforeThunk({ id: newNode.id }));
}
);

View File

@ -1,32 +0,0 @@
import { createAsyncThunk } from '@reduxjs/toolkit';
import { DocumentController } from '$app/stores/effects/document/document_controller';
import { BlockType, DocumentState } from '$app/interfaces/document';
import { turnToBlockThunk } from '$app_reducers/document/async-actions/turn_to';
/**
* transform to text block
* 1. insert text block after current block
* 2. move children to text block
* 3. delete current block
*/
export const turnToTextBlockThunk = createAsyncThunk(
'document/turnToTextBlock',
async (payload: { id: string; controller: DocumentController }, thunkAPI) => {
const { id, controller } = payload;
const { dispatch, getState } = thunkAPI;
const state = (getState() as { document: DocumentState }).document;
const node = state.nodes[id];
const data = {
delta: node.data.delta,
};
await dispatch(
turnToBlockThunk({
id,
controller,
type: BlockType.TextBlock,
data,
})
);
}
);

View File

@ -1,18 +1,18 @@
import { TextDelta, DocumentState, BlockData } from '$app/interfaces/document';
import { DocumentState, BlockData } from '$app/interfaces/document';
import { DocumentController } from '$app/stores/effects/document/document_controller';
import { createAsyncThunk } from '@reduxjs/toolkit';
import { isSameDelta } from '$app/utils/document/blocks/text/delta';
import Delta, { Op } from 'quill-delta';
export const updateNodeDeltaThunk = createAsyncThunk(
'document/updateNodeDelta',
async (payload: { id: string; delta: TextDelta[]; controller: DocumentController }, thunkAPI) => {
async (payload: { id: string; delta: Op[]; controller: DocumentController }, thunkAPI) => {
const { id, delta, controller } = payload;
const { getState } = thunkAPI;
const state = (getState() as { document: DocumentState }).document;
const node = state.nodes[id];
const isSame = isSameDelta(delta, node.data.delta || []);
const diffDelta = new Delta(delta).diff(new Delta(node.data.delta || []));
if (diffDelta.ops.length === 0) return;
if (isSame) return;
const newData = { ...node.data, delta };
await controller.applyActions([

View File

@ -1,109 +0,0 @@
import { createAsyncThunk } from '@reduxjs/toolkit';
import { rangeSelectionActions } from "../slice";
import { DocumentState, TextSelection } from '$app/interfaces/document';
import { Editor } from 'slate';
import {
getBeforeRangeAt,
getEndLineSelectionByOffset,
getLastLineOffsetByDelta,
getNodeBeginSelection,
getNodeEndSelection,
getStartLineSelectionByOffset,
} from '$app/utils/document/blocks/text/delta';
import { getCollapsedRange, getNextLineId, getPrevLineId } from "$app/utils/document/blocks/common";
import { ReactEditor } from "slate-react";
export const setCursorBeforeThunk = createAsyncThunk(
'document/setCursorBefore',
async (payload: { id: string }, thunkAPI) => {
const { id } = payload;
const { dispatch } = thunkAPI;
const selection = getNodeBeginSelection();
const range = getCollapsedRange(id, selection);
dispatch(rangeSelectionActions.setRange(range));
}
);
export const setCursorAfterThunk = createAsyncThunk(
'document/setCursorAfter',
async (payload: { id: string }, thunkAPI) => {
const { id } = payload;
const { dispatch, getState } = thunkAPI;
const state = (getState() as { document: DocumentState }).document;
const node = state.nodes[id];
const selection = getNodeEndSelection(node.data.delta);
const range = getCollapsedRange(id, selection);
dispatch(rangeSelectionActions.setRange(range));
}
);
export const setCursorPreLineThunk = createAsyncThunk(
'document/setCursorPreLine',
async (payload: { id: string; editor: ReactEditor; focusEnd?: boolean }, thunkAPI) => {
const { id, editor, focusEnd } = payload;
const selection = editor.selection as TextSelection;
const { dispatch, getState } = thunkAPI;
const state = (getState() as { document: DocumentState }).document;
const prevId = getPrevLineId(state, id);
if (!prevId) return;
let prevLineNode = state.nodes[prevId];
// Find the prev line that has delta
while (prevLineNode && !prevLineNode.data.delta) {
const id = getPrevLineId(state, prevLineNode.id);
if (!id) return;
prevLineNode = state.nodes[id];
}
if (!prevLineNode) return;
// whatever the selection is, set cursor to the end of prev line when focusEnd is true
if (focusEnd) {
await dispatch(setCursorAfterThunk({ id: prevLineNode.id }));
return;
}
const range = getBeforeRangeAt(editor, selection);
const textOffset = Editor.string(editor, range).length;
// set the cursor to prev line with the relative offset
const newSelection = getEndLineSelectionByOffset(prevLineNode.data.delta, textOffset);
dispatch(rangeSelectionActions.setRange(getCollapsedRange(prevLineNode.id, newSelection)));
}
);
export const setCursorNextLineThunk = createAsyncThunk(
'document/setCursorNextLine',
async (payload: { id: string; editor: ReactEditor; focusStart?: boolean }, thunkAPI) => {
const { id, editor, focusStart } = payload;
const selection = editor.selection as TextSelection;
const { dispatch, getState } = thunkAPI;
const state = (getState() as { document: DocumentState }).document;
const node = state.nodes[id];
const nextId = getNextLineId(state, id);
if (!nextId) return;
let nextLineNode = state.nodes[nextId];
// Find the next line that has delta
while (nextLineNode && !nextLineNode.data.delta) {
const id = getNextLineId(state, nextLineNode.id);
if (!id) return;
nextLineNode = state.nodes[id];
}
if (!nextLineNode) return;
const delta = nextLineNode.data.delta;
// whatever the selection is, set cursor to the start of next line when focusStart is true
if (focusStart) {
await dispatch(setCursorBeforeThunk({ id: nextLineNode.id }));
return;
}
const range = getBeforeRangeAt(editor, selection);
const textOffset = Editor.string(editor, range).length - getLastLineOffsetByDelta(node.data.delta);
// set the cursor to next line with the relative offset
const newSelection = getStartLineSelectionByOffset(delta, textOffset);
dispatch(rangeSelectionActions.setRange(getCollapsedRange(nextLineNode.id, newSelection)));
}
);

View File

@ -1,31 +1,28 @@
import { createAsyncThunk } from '@reduxjs/toolkit';
import { RootState } from '$app/stores/store';
import { TextAction, TextDelta, TextSelection } from '$app/interfaces/document';
import { getAfterRangeDelta, getBeforeRangeDelta, getRangeDelta } from '$app/utils/document/blocks/text/delta';
import { TextAction } from '$app/interfaces/document';
import { DocumentController } from '$app/stores/effects/document/document_controller';
import Delta from 'quill-delta';
import { rangeActions } from '$app_reducers/document/slice';
export const getFormatActiveThunk = createAsyncThunk<boolean, TextAction>(
'document/getFormatActive',
async (format, thunkAPI) => {
const { getState } = thunkAPI;
const state = getState() as RootState;
const { document } = state;
const { selection, anchor, focus } = state.documentRangeSelection;
const match = (delta: TextDelta[], format: TextAction) => {
return delta.every((op) => op.attributes?.[format] === true);
const { document, documentRange } = state;
const { ranges } = documentRange;
const match = (delta: Delta, format: TextAction) => {
return delta.ops.every((op) => op.attributes?.[format] === true);
};
return selection.every((id) => {
return Object.entries(ranges).every(([id, range]) => {
const node = document.nodes[id];
let delta = node.data?.delta as TextDelta[];
if (!delta) return false;
const delta = new Delta(node.data?.delta);
const index = range?.index || 0;
const length = range?.length || 0;
const rangeDelta = delta.slice(index, index + length);
if (id === anchor?.id) {
delta = getRangeDelta(delta, anchor.selection);
} else if (id === focus?.id) {
delta = getRangeDelta(delta, focus.selection);
}
return match(delta, format);
return match(rangeDelta, format);
});
}
);
@ -33,15 +30,14 @@ export const getFormatActiveThunk = createAsyncThunk<boolean, TextAction>(
export const toggleFormatThunk = createAsyncThunk(
'document/toggleFormat',
async (payload: { format: TextAction; controller: DocumentController; isActive: boolean }, thunkAPI) => {
const { getState } = thunkAPI;
const { getState, dispatch } = thunkAPI;
const { format, controller, isActive } = payload;
const state = getState() as RootState;
const { document } = state;
const { selection, anchor, focus } = state.documentRangeSelection;
const ids = Array.from(new Set(selection));
const { ranges, caret } = state.documentRange;
const toggle = (delta: TextDelta[], format: TextAction) => {
return delta.map((op) => {
const toggle = (delta: Delta, format: TextAction) => {
const newOps = delta.ops.map((op) => {
const attributes = {
...op.attributes,
[format]: isActive ? undefined : true,
@ -51,36 +47,25 @@ export const toggleFormatThunk = createAsyncThunk(
attributes: attributes,
};
});
return new Delta(newOps);
};
const splitDelta = (delta: TextDelta[], selection: TextSelection) => {
const before = getBeforeRangeDelta(delta, selection);
const after = getAfterRangeDelta(delta, selection);
let middle = getRangeDelta(delta, selection);
middle = toggle(middle, format);
return [...before, ...middle, ...after];
};
const actions = ids.map((id) => {
const actions = Object.entries(ranges).map(([id, range]) => {
const node = document.nodes[id];
let delta = node.data?.delta as TextDelta[];
if (!delta) return controller.getUpdateAction(node);
if (id === anchor?.id) {
delta = splitDelta(delta, anchor.selection);
} else if (id === focus?.id) {
delta = splitDelta(delta, focus.selection);
} else {
delta = toggle(delta, format);
}
const delta = new Delta(node.data?.delta);
const index = range?.index || 0;
const length = range?.length || 0;
const beforeDelta = delta.slice(0, index);
const afterDelta = delta.slice(index + length);
const rangeDelta = delta.slice(index, index + length);
const toggleFormatDelta = toggle(rangeDelta, format);
const newDelta = beforeDelta.concat(toggleFormatDelta).concat(afterDelta);
return controller.getUpdateAction({
...node,
data: {
...node.data,
delta,
delta: newDelta.ops,
},
});
});

View File

@ -1,15 +1,4 @@
import { createAsyncThunk } from "@reduxjs/toolkit";
import { DocumentState, NestedBlock } from "$app/interfaces/document";
export * from './cursor';
export * from './blocks';
export * from './turn_to';
export const getBlockByIdThunk = createAsyncThunk<NestedBlock, string>(
'document/getBlockById',
async (id, thunkAPI) => {
const { getState } = thunkAPI;
const state = getState() as { document: DocumentState };
const node = state.document.nodes[id] as NestedBlock;
return node;
});
export * from './keydown';
export * from './range';

View File

@ -0,0 +1,288 @@
import { createAsyncThunk } from '@reduxjs/toolkit';
import { DocumentController } from '$app/stores/effects/document/document_controller';
import { BlockType, DocumentState, SplitRelationship } from '$app/interfaces/document';
import { turnToTextBlockThunk } from '$app_reducers/document/async-actions/turn_to';
import {
findNextHasDeltaNode,
findPrevHasDeltaNode,
getInsertEnterNodeAction,
getLeftCaretByRange,
getRightCaretByRange,
transformToNextLineCaret,
transformToPrevLineCaret,
} from '$app/utils/document/action';
import Delta from 'quill-delta';
import { indentNodeThunk, mergeDeltaThunk, outdentNodeThunk } from '$app_reducers/document/async-actions/blocks';
import { rangeActions } from '$app_reducers/document/slice';
import { RootState } from '$app/stores/store';
import { blockConfig } from '$app/constants/document/config';
import { Keyboard } from '$app/constants/document/keyboard';
/**
* Delete a block by backspace or delete key
* 1. If the block is not a text block, turn it to a text block
* 2. If the block is a text block
* 2.1 If the block has next node or is top level, merge it to the previous line
* 2.2 If the block has no next node and is not top level, outdent it
*/
export const backspaceDeleteActionForBlockThunk = createAsyncThunk(
'document/backspaceDeleteActionForBlock',
async (payload: { id: string; controller: DocumentController }, thunkAPI) => {
const { id, controller } = payload;
const { dispatch, getState } = thunkAPI;
const state = (getState() as { document: DocumentState }).document;
const node = state.nodes[id];
if (!node.parent) return;
const parent = state.nodes[node.parent];
const children = state.children[parent.children];
const index = children.indexOf(id);
const nextNodeId = children[index + 1];
// turn to text block
if (node.type !== BlockType.TextBlock) {
await dispatch(turnToTextBlockThunk({ id, controller }));
return;
}
const isTopLevel = parent.type === BlockType.PageBlock;
if (isTopLevel || nextNodeId) {
// merge to previous line
const prevLine = findPrevHasDeltaNode(state, id);
if (!prevLine) return;
const caretIndex = new Delta(prevLine.data.delta).length();
const caret = {
id: prevLine.id,
index: caretIndex,
length: 0,
};
await dispatch(
mergeDeltaThunk({
sourceId: id,
targetId: prevLine.id,
controller,
})
);
dispatch(rangeActions.setCaret(caret));
return;
}
// outdent
await dispatch(outdentNodeThunk({ id, controller }));
}
);
/**
* Insert a new node after the current node by pressing enter.
* 1. Split the current node into two nodes.
* 2. Insert a new node after the current node.
* 3. Move the children of the current node to the new node if needed.
*/
export const enterActionForBlockThunk = createAsyncThunk(
'document/insertNodeByEnter',
async (payload: { id: string; controller: DocumentController }, thunkAPI) => {
const { id, controller } = payload;
const { getState, dispatch } = thunkAPI;
const state = getState() as RootState;
const node = state.document.nodes[id];
const caret = state.documentRange.caret;
if (!node || !caret || caret.id !== id) return;
const nodeDelta = new Delta(node.data.delta).slice(0, caret.index);
const insertNodeDelta = new Delta(node.data.delta).slice(caret.index + caret.length);
const insertNodeAction = getInsertEnterNodeAction(node, insertNodeDelta, controller);
if (!insertNodeAction) return;
const updateNode = {
...node,
data: {
...node.data,
delta: nodeDelta.ops,
},
};
const children = state.document.children[node.children];
const needMoveChildren = blockConfig[node.type].splitProps?.nextLineRelationShip === SplitRelationship.NextSibling;
console.log('needMoveChildren', needMoveChildren);
const moveChildrenAction = needMoveChildren
? controller.getMoveChildrenAction(
children.map((id) => state.document.nodes[id]),
insertNodeAction.id,
''
)
: [];
const actions = [insertNodeAction.action, controller.getUpdateAction(updateNode), ...moveChildrenAction];
await controller.applyActions(actions);
dispatch(rangeActions.clearRange());
dispatch(
rangeActions.setCaret({
id: insertNodeAction.id,
index: 0,
length: 0,
})
);
}
);
export const tabActionForBlockThunk = createAsyncThunk(
'document/tabActionForBlock',
async (payload: { id: string; controller: DocumentController }, thunkAPI) => {
const { dispatch } = thunkAPI;
return dispatch(indentNodeThunk(payload));
}
);
export const upDownActionForBlockThunk = createAsyncThunk(
'document/upActionForBlock',
async (payload: { id: string; down?: boolean }, thunkAPI) => {
const { id, down } = payload;
const { dispatch, getState } = thunkAPI;
const state = getState() as RootState;
const rangeState = state.documentRange;
const caret = rangeState.caret;
const node = state.document.nodes[id];
if (!node || !caret || id !== caret.id) return;
let newCaret;
if (down) {
newCaret = transformToNextLineCaret(state.document, caret);
} else {
newCaret = transformToPrevLineCaret(state.document, caret);
}
if (!newCaret) {
return;
}
dispatch(rangeActions.setCaret(newCaret));
}
);
export const leftActionForBlockThunk = createAsyncThunk(
'document/leftActionForBlock',
async (payload: { id: string }, thunkAPI) => {
const { id } = payload;
const { dispatch, getState } = thunkAPI;
const state = getState() as RootState;
const rangeState = state.documentRange;
const caret = rangeState.caret;
const node = state.document.nodes[id];
if (!node || !caret || id !== caret.id) return;
let newCaret;
if (caret.length > 0) {
newCaret = {
id,
index: caret.index,
length: 0,
};
} else {
if (caret.index > 0) {
newCaret = {
id,
index: caret.index - 1,
length: 0,
};
} else {
const prevNode = findPrevHasDeltaNode(state.document, id);
if (!prevNode) return;
const prevDelta = new Delta(prevNode.data.delta);
newCaret = {
id: prevNode.id,
index: prevDelta.length(),
length: 0,
};
}
}
if (!newCaret) {
return;
}
dispatch(rangeActions.setCaret(newCaret));
}
);
export const rightActionForBlockThunk = createAsyncThunk(
'document/rightActionForBlock',
async (payload: { id: string }, thunkAPI) => {
const { id } = payload;
const { dispatch, getState } = thunkAPI;
const state = getState() as RootState;
const rangeState = state.documentRange;
const caret = rangeState.caret;
const node = state.document.nodes[id];
if (!node || !caret || id !== caret.id) return;
let newCaret;
const delta = new Delta(node.data.delta);
const deltaLength = delta.length();
if (caret.length > 0) {
newCaret = {
id,
index: caret.index + caret.length,
length: 0,
};
} else {
if (caret.index < deltaLength) {
const newIndex = caret.index + caret.length + 1;
newCaret = {
id,
index: newIndex > deltaLength ? deltaLength : newIndex,
length: 0,
};
} else {
const nextNode = findNextHasDeltaNode(state.document, id);
if (!nextNode) return;
newCaret = {
id: nextNode.id,
index: 0,
length: 0,
};
}
}
if (!newCaret) {
return;
}
dispatch(rangeActions.setCaret(newCaret));
}
);
export const shiftTabActionForBlockThunk = createAsyncThunk(
'document/shiftTabActionForBlock',
async (payload: { id: string; controller: DocumentController }, thunkAPI) => {
const { dispatch } = thunkAPI;
return dispatch(outdentNodeThunk(payload));
}
);
export const arrowActionForRangeThunk = createAsyncThunk(
'document/arrowLeftActionForRange',
async (
payload: {
key: string;
},
thunkAPI
) => {
const { dispatch, getState } = thunkAPI;
const state = getState() as RootState;
const rangeState = state.documentRange;
let caret;
const leftCaret = getLeftCaretByRange(rangeState);
const rightCaret = getRightCaretByRange(rangeState);
if (!leftCaret || !rightCaret) return;
switch (payload.key) {
case Keyboard.keys.LEFT:
caret = leftCaret;
break;
case Keyboard.keys.RIGHT:
caret = rightCaret;
break;
case Keyboard.keys.UP:
caret = transformToPrevLineCaret(state.document, leftCaret);
break;
case Keyboard.keys.DOWN:
caret = transformToNextLineCaret(state.document, rightCaret);
break;
}
if (!caret) return;
dispatch(rangeActions.clearRange());
dispatch(rangeActions.setCaret(caret));
}
);

View File

@ -1,11 +1,13 @@
import { createAsyncThunk } from '@reduxjs/toolkit';
import { BlockData, BlockType, DocumentState, TextDelta } from '$app/interfaces/document';
import { BlockData, BlockType, DocumentState } from '$app/interfaces/document';
import { insertAfterNodeThunk } from '$app_reducers/document/async-actions/blocks';
import { DocumentController } from '$app/stores/effects/document/document_controller';
import { slashCommandActions } from '$app_reducers/document/slice';
import { setCursorBeforeThunk } from '$app_reducers/document/async-actions/cursor';
import { rangeActions, slashCommandActions } from '$app_reducers/document/slice';
import { turnToBlockThunk } from '$app_reducers/document/async-actions/turn_to';
import { blockConfig } from '$app/constants/document/config';
import Delta, { Op } from 'quill-delta';
import { getDeltaText } from '$app/utils/document/delta';
import { RootState } from '$app/stores/store';
/**
* add block below click
@ -20,7 +22,7 @@ export const addBlockBelowClickThunk = createAsyncThunk(
const state = (getState() as { document: DocumentState }).document;
const node = state.nodes[id];
if (!node) return;
const delta = (node.data.delta as TextDelta[]) || [];
const delta = (node.data.delta as Op[]) || [];
const text = delta.map((d) => d.insert).join('');
// if current block is not empty, insert a new block after current block
@ -29,13 +31,14 @@ export const addBlockBelowClickThunk = createAsyncThunk(
insertAfterNodeThunk({ id: id, type: BlockType.TextBlock, controller, data: { delta: [] } })
);
if (newBlockId) {
await dispatch(setCursorBeforeThunk({ id: newBlockId as string }));
dispatch(rangeActions.setCaret({ id: newBlockId as string, index: 0, length: 0 }));
dispatch(slashCommandActions.openSlashCommand({ blockId: newBlockId as string }));
}
return;
}
// if current block is empty, open slash command
await dispatch(setCursorBeforeThunk({ id }));
dispatch(rangeActions.setCaret({ id, index: 0, length: 0 }));
dispatch(slashCommandActions.openSlashCommand({ blockId: id }));
}
);
@ -60,12 +63,14 @@ export const triggerSlashCommandActionThunk = createAsyncThunk(
) => {
const { id, controller, props } = payload;
const { dispatch, getState } = thunkAPI;
const state = (getState() as { document: DocumentState }).document;
const node = state.nodes[id];
const state = getState() as RootState;
const { document } = state;
const node = document.nodes[id];
if (!node) return;
const delta = (node.data.delta as TextDelta[]) || [];
const text = delta.map((d) => d.insert).join('');
const delta = new Delta(node.data.delta);
const text = getDeltaText(delta);
const defaultData = blockConfig[props.type].defaultData;
if (node.type === BlockType.TextBlock && (text === '' || text === '/')) {
dispatch(
turnToBlockThunk({
@ -80,7 +85,20 @@ export const triggerSlashCommandActionThunk = createAsyncThunk(
);
return;
}
const { payload: newBlockId } = await dispatch(
// if current block has slash command, remove slash command
if (text.slice(0, 1) === '/') {
const updateNode = {
...node,
data: {
...node.data,
delta: delta.slice(1, delta.length()).ops,
},
};
await controller.applyActions([controller.getUpdateAction(updateNode)]);
}
const insertNodePayload = await dispatch(
insertAfterNodeThunk({
id,
controller,
@ -91,6 +109,8 @@ export const triggerSlashCommandActionThunk = createAsyncThunk(
},
})
);
dispatch(setCursorBeforeThunk({ id: newBlockId as string }));
const newBlockId = insertNodePayload.payload as string;
dispatch(rangeActions.setCaret({ id: newBlockId, index: 0, length: 0 }));
}
);

View File

@ -0,0 +1,221 @@
import { createAsyncThunk } from '@reduxjs/toolkit';
import { RootState } from '$app/stores/store';
import { rangeActions } from '$app_reducers/document/slice';
import { getNextLineId } from '$app/utils/document/block';
import Delta from 'quill-delta';
import { DocumentController } from '$app/stores/effects/document/document_controller';
import {
getAfterMergeCaretByRange,
getInsertEnterNodeAction,
getMergeEndDeltaToStartActionsByRange,
getMiddleIdsByRange,
getStartAndEndDeltaExpectRange,
} from '$app/utils/document/action';
import { RangeState, SplitRelationship } from '$app/interfaces/document';
import { blockConfig } from '$app/constants/document/config';
interface storeRangeThunkPayload {
id: string;
range: {
index: number;
length: number;
};
}
/**
* store range to redux store
* 1. if isDragging is false, just store range
* 2. if isDragging is true, we need amend range between anchor and focus
*/
export const storeRangeThunk = createAsyncThunk('document/storeRange', (payload: storeRangeThunkPayload, thunkAPI) => {
const { id, range } = payload;
const { dispatch, getState } = thunkAPI;
const state = getState() as RootState;
const rangeState = state.documentRange;
// we need amend range between anchor and focus
const { anchor, focus, isDragging } = rangeState;
if (!isDragging || !anchor || !focus) return;
const ranges: RangeState['ranges'] = {};
ranges[id] = range;
// pin anchor index
let anchorIndex = anchor.point.index;
let anchorLength = anchor.point.length;
if (anchorIndex === undefined || anchorLength === undefined) {
dispatch(rangeActions.setAnchorPointRange(range));
anchorIndex = range.index;
anchorLength = range.length;
}
// if anchor and focus are in the same node, we don't need to amend range
if (anchor.id === id) {
dispatch(rangeActions.setRanges(ranges));
return;
}
// amend anchor range because slatejs will stop update selection when dragging quickly
const isForward = anchor.point.y < focus.point.y;
const anchorDelta = new Delta(state.document.nodes[anchor.id].data.delta);
if (isForward) {
const selectedDelta = anchorDelta.slice(anchorIndex);
ranges[anchor.id] = {
index: anchorIndex,
length: selectedDelta.length(),
};
} else {
const selectedDelta = anchorDelta.slice(0, anchorIndex + anchorLength);
ranges[anchor.id] = {
index: 0,
length: selectedDelta.length(),
};
}
// select all ids between anchor and focus
const startId = isForward ? anchor.id : focus.id;
const endId = isForward ? focus.id : anchor.id;
let currentId: string | undefined = startId;
while (currentId && currentId !== endId) {
const nextId = getNextLineId(state.document, currentId);
if (nextId && nextId !== endId) {
const node = state.document.nodes[nextId];
if (!node || !node.data.delta) return;
const delta = new Delta(node.data.delta);
// set full range
const rangeStatic = {
index: 0,
length: delta.length(),
};
ranges[nextId] = rangeStatic;
}
currentId = nextId;
}
dispatch(rangeActions.setRanges(ranges));
});
/**
* delete range and insert delta
* 1. merge start and end delta to start node and delete end node
* 2. delete middle nodes
* 3. clear range
*/
export const deleteRangeAndInsertThunk = createAsyncThunk(
'document/deleteRange',
async (payload: { controller: DocumentController; insertDelta?: Delta }, thunkAPI) => {
const { controller, insertDelta } = payload;
const { getState, dispatch } = thunkAPI;
const state = getState() as RootState;
const rangeState = state.documentRange;
const actions = [];
// get merge actions
const mergeActions = getMergeEndDeltaToStartActionsByRange(state, controller, insertDelta);
if (mergeActions) {
actions.push(...mergeActions);
}
// get middle nodes
const middleIds = getMiddleIdsByRange(rangeState, state.document);
// delete middle nodes
const deleteMiddleNodesActions = middleIds?.map((id) => controller.getDeleteAction(state.document.nodes[id])) || [];
actions.push(...deleteMiddleNodesActions);
const caret = getAfterMergeCaretByRange(rangeState, insertDelta);
// apply actions
await controller.applyActions(actions);
// clear range
dispatch(rangeActions.clearRange());
if (caret) {
dispatch(rangeActions.setCaret(caret));
}
}
);
/**
* delete range and insert enter
* 1. if shift key, insert '\n' to start node and concat end node delta
* 2. if not shift key
* 2.1 insert node under start node, and concat end node delta to insert node
* 2.2 filter rest children and move to insert node, if need
* 3. delete middle nodes
* 4. clear range
*/
export const deleteRangeAndInsertEnterThunk = createAsyncThunk(
'document/deleteRangeAndInsertEnter',
async (payload: { controller: DocumentController; shiftKey: boolean }, thunkAPI) => {
const { controller, shiftKey } = payload;
const { getState, dispatch } = thunkAPI;
const state = getState() as RootState;
const rangeState = state.documentRange;
const actions = [];
const { startDelta, endDelta, endNode, startNode } = getStartAndEndDeltaExpectRange(state) || {};
if (!startDelta || !endDelta || !endNode || !startNode) return;
// get middle nodes
const middleIds = getMiddleIdsByRange(rangeState, state.document);
let newStartDelta = new Delta(startDelta);
let caret = null;
if (shiftKey) {
newStartDelta = newStartDelta.insert('\n').concat(endDelta);
caret = getAfterMergeCaretByRange(rangeState, new Delta().insert('\n'));
} else {
const insertNodeDelta = new Delta(endDelta);
const insertNodeAction = getInsertEnterNodeAction(startNode, insertNodeDelta, controller);
if (!insertNodeAction) return;
actions.push(insertNodeAction.action);
caret = {
id: insertNodeAction.id,
index: 0,
length: 0,
};
// move start node children to insert node
const needMoveChildren =
blockConfig[startNode.type].splitProps?.nextLineRelationShip === SplitRelationship.NextSibling;
if (needMoveChildren) {
// filter children by delete middle ids
const children = state.document.children[startNode.children].filter((id) => middleIds?.includes(id));
const moveChildrenAction = needMoveChildren
? controller.getMoveChildrenAction(
children.map((id) => state.document.nodes[id]),
insertNodeAction.id,
''
)
: [];
actions.push(...moveChildrenAction);
}
}
// udpate start node
const updateAction = controller.getUpdateAction({
...startNode,
data: {
...startNode.data,
delta: newStartDelta.ops,
},
});
if (endNode.id !== startNode.id) {
// delete end node
const deleteAction = controller.getDeleteAction(endNode);
actions.push(updateAction, deleteAction);
}
// delete middle nodes
const deleteMiddleNodesActions = middleIds?.map((id) => controller.getDeleteAction(state.document.nodes[id])) || [];
actions.push(...deleteMiddleNodesActions);
// apply actions
await controller.applyActions(actions);
// clear range
dispatch(rangeActions.clearRange());
if (caret) {
dispatch(rangeActions.setCaret(caret));
}
}
);

View File

@ -1,119 +0,0 @@
import { createAsyncThunk } from '@reduxjs/toolkit';
import { DocumentState, TextSelection } from '$app/interfaces/document';
import { rangeSelectionActions } from '$app_reducers/document/slice';
import { getNodeBeginSelection, getNodeEndSelection } from '$app/utils/document/blocks/text/delta';
import { isEqual } from '$app/utils/tool';
import { RootState } from '$app/stores/store';
import { getNodesInRange } from '$app/utils/document/blocks/common';
const amendAnchorNodeThunk = createAsyncThunk(
'document/amendAnchorNode',
async (
payload: {
id: string;
},
thunkAPI
) => {
const { id } = payload;
const { getState, dispatch } = thunkAPI;
const nodes = (getState() as { document: DocumentState }).document.nodes;
const state = getState() as RootState;
const { isDragging, isForward, ...range } = state.documentRangeSelection;
const { anchor: anchorNode, focus: focusNode } = range;
if (!isDragging || !anchorNode || anchorNode.id !== id) return;
const isCollapsed = focusNode?.id === id && anchorNode?.id === id;
if (isCollapsed) return;
const selection = anchorNode.selection;
const node = nodes[id];
const focus = isForward ? getNodeEndSelection(node.data.delta).anchor : getNodeBeginSelection().anchor;
if (isEqual(focus, selection.focus)) return;
const newSelection = {
anchor: selection.anchor,
focus,
};
dispatch(
rangeSelectionActions.setRange({
anchor: {
id,
selection: newSelection as TextSelection,
},
})
);
}
);
export const syncRangeSelectionThunk = createAsyncThunk(
'document/syncRangeSelection',
async (
payload: {
id: string;
selection: TextSelection;
},
thunkAPI
) => {
const { getState, dispatch } = thunkAPI;
const state = getState() as RootState;
const range = state.documentRangeSelection;
const isDragging = range.isDragging;
const { id, selection } = payload;
const updateRange = {
focus: {
id,
selection,
},
};
if (!isDragging && range.anchor?.id === id) {
Object.assign(updateRange, {
anchor: {
id,
selection: { ...selection },
},
});
dispatch(rangeSelectionActions.setRange(updateRange));
return;
}
if (!range.anchor || range.anchor.id === id) {
Object.assign(updateRange, {
anchor: {
id,
selection: {
anchor: !range.anchor ? selection.anchor : range.anchor.selection.anchor,
focus: selection.focus,
},
},
});
}
dispatch(rangeSelectionActions.setRange(updateRange));
const anchorId = range.anchor?.id;
// more than one node is selected
if (anchorId && anchorId !== id) {
dispatch(amendAnchorNodeThunk({ id: anchorId }));
}
}
);
export const setRangeSelectionThunk = createAsyncThunk('document/setRangeSelection', async (payload, thunkAPI) => {
const { getState, dispatch } = thunkAPI;
const state = getState() as RootState;
const { anchor, focus, isForward } = state.documentRangeSelection;
const document = state.document;
if (!anchor || !focus || isForward === undefined) return;
const rangeIds = getNodesInRange(
{
startId: anchor.id,
endId: focus.id,
},
isForward,
document
);
dispatch(rangeSelectionActions.setSelection(rangeIds));
});

View File

@ -1,7 +1,7 @@
import { createAsyncThunk } from "@reduxjs/toolkit";
import { getNextNodeId, getPrevNodeId } from "$app/utils/document/blocks/common";
import { DocumentState } from "$app/interfaces/document";
import { rectSelectionActions } from "$app_reducers/document/slice";
import { createAsyncThunk } from '@reduxjs/toolkit';
import { getNextNodeId, getPrevNodeId } from '$app/utils/document/block';
import { DocumentState } from '$app/interfaces/document';
import { rectSelectionActions } from '$app_reducers/document/slice';
export const setRectSelectionThunk = createAsyncThunk(
'document/setRectSelection',
@ -22,6 +22,6 @@ export const setRectSelectionThunk = createAsyncThunk(
selected[node.parent] = true;
}
});
dispatch(rectSelectionActions.updateSelections(payload.filter((id) => selected[id])))
dispatch(rectSelectionActions.updateSelections(payload.filter((id) => selected[id])));
}
);

View File

@ -1,10 +1,9 @@
import { createAsyncThunk } from '@reduxjs/toolkit';
import { DocumentController } from '$app/stores/effects/document/document_controller';
import { BlockData, BlockType, DocumentState, NestedBlock, TextDelta } from '$app/interfaces/document';
import { setCursorBeforeThunk } from '$app_reducers/document/async-actions/cursor';
import { BlockData, BlockType, DocumentState } from '$app/interfaces/document';
import { blockConfig } from '$app/constants/document/config';
import { newBlock } from '$app/utils/document/blocks/common';
import { insertAfterNodeThunk } from '$app_reducers/document/async-actions/blocks';
import { newBlock } from '$app/utils/document/block';
import { rangeActions } from '$app_reducers/document/slice';
/**
* transform to block
@ -27,10 +26,15 @@ export const turnToBlockThunk = createAsyncThunk(
const parent = state.nodes[node.parent];
const children = state.children[node.children].map((id) => state.nodes[id]);
const block = newBlock<any>(type, parent.id, data);
const block = newBlock<any>(type, parent.id, type === BlockType.DividerBlock ? {} : data);
let caretId = block.id;
// insert new block after current block
const insertHeadingAction = controller.getInsertAction(block, node.id);
let insertActions = [controller.getInsertAction(block, node.id)];
if (type === BlockType.DividerBlock) {
const newTextNode = newBlock<any>(BlockType.TextBlock, parent.id, data);
insertActions.push(controller.getInsertAction(newTextNode, block.id));
caretId = newTextNode.id;
}
// check if prev node is allowed to have children
const config = blockConfig[block.type];
// if new block is not allowed to have children, move children to parent
@ -43,34 +47,36 @@ export const turnToBlockThunk = createAsyncThunk(
const deleteAction = controller.getDeleteAction(node);
// submit actions
await controller.applyActions([insertHeadingAction, ...moveChildrenActions, deleteAction]);
await controller.applyActions([...insertActions, ...moveChildrenActions, deleteAction]);
// set cursor in new block
await dispatch(setCursorBeforeThunk({ id: block.id }));
dispatch(rangeActions.setCaret({ id: caretId, index: 0, length: 0 }));
}
);
/**
* turn to divider block
* 1. insert text block with delta after current block
* 2. turn current block to divider block
* transform to text block
* 1. insert text block after current block
* 2. move children to text block
* 3. delete current block
*/
export const turnToDividerBlockThunk = createAsyncThunk(
'document/turnToDividerBlock',
async (payload: { id: string; controller: DocumentController; delta: TextDelta[] }, thunkAPI) => {
const { id, controller, delta } = payload;
const { dispatch } = thunkAPI;
const { payload: newNodeId } = await dispatch(
insertAfterNodeThunk({
export const turnToTextBlockThunk = createAsyncThunk(
'document/turnToTextBlock',
async (payload: { id: string; controller: DocumentController }, thunkAPI) => {
const { id, controller } = payload;
const { dispatch, getState } = thunkAPI;
const state = (getState() as { document: DocumentState }).document;
const node = state.nodes[id];
const data = {
delta: node.data.delta,
};
await dispatch(
turnToBlockThunk({
id,
controller,
type: BlockType.TextBlock,
data: {
delta,
},
data,
})
);
if (!newNodeId) return;
await dispatch(turnToBlockThunk({ id, type: BlockType.DividerBlock, controller, data: {} }));
dispatch(setCursorBeforeThunk({ id: newNodeId as string }));
}
);

View File

@ -1,10 +1,10 @@
import {
DocumentState,
Node,
PointState,
RangeSelectionState,
RectSelectionState,
SlashCommandState,
RangeState,
RangeStatic,
} from '@/appflowy_app/interfaces/document';
import { BlockEventPayloadPB } from '@/services/backend';
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
@ -20,9 +20,9 @@ const rectSelectionInitialState: RectSelectionState = {
isDragging: false,
};
const rangeSelectionInitialState: RangeSelectionState = {
const rangeInitialState: RangeState = {
isDragging: false,
selection: [],
ranges: {},
};
const slashCommandInitialState: SlashCommandState = {
@ -99,37 +99,81 @@ export const rectSelectionSlice = createSlice({
},
});
export const rangeSelectionSlice = createSlice({
name: 'documentRangeSelection',
initialState: rangeSelectionInitialState,
export const rangeSlice = createSlice({
name: 'documentRange',
initialState: rangeInitialState,
reducers: {
setRanges: (state, action: PayloadAction<RangeState['ranges']>) => {
state.ranges = action.payload;
},
setRange: (
state,
action: PayloadAction<{
anchor?: PointState;
focus?: PointState;
id: string;
rangeStatic: {
index: number;
length: number;
};
}>
) => {
return {
...state,
const { id, rangeStatic } = action.payload;
state.ranges[id] = rangeStatic;
},
removeRange: (state, action: PayloadAction<string>) => {
const id = action.payload;
delete state.ranges[id];
},
setAnchorPoint: (
state,
action: PayloadAction<{
id: string;
point: { x: number; y: number };
}>
) => {
state.anchor = action.payload;
},
setAnchorPointRange: (
state,
action: PayloadAction<{
index: number;
length: number;
}>
) => {
const anchor = state.anchor;
if (!anchor) return;
anchor.point = {
...anchor.point,
...action.payload,
};
},
setSelection: (state, action: PayloadAction<string[]>) => {
state.selection = action.payload;
setFocusPoint: (
state,
action: PayloadAction<{
id: string;
point: { x: number; y: number };
}>
) => {
state.focus = action.payload;
},
setDragging: (state, action: PayloadAction<boolean>) => {
state.isDragging = action.payload;
},
setForward: (state, action: PayloadAction<boolean>) => {
state.isForward = action.payload;
setCaret: (state, action: PayloadAction<RangeStatic>) => {
const id = action.payload.id;
state.ranges[id] = {
index: action.payload.index,
length: action.payload.length,
};
state.caret = action.payload;
},
clearRange: (state, _: PayloadAction) => {
return rangeSelectionInitialState;
state.isDragging = false;
state.ranges = {};
state.anchor = undefined;
state.focus = undefined;
},
},
});
export const slashCommandSlice = createSlice({
name: 'documentSlashCommand',
initialState: slashCommandInitialState,
@ -156,12 +200,11 @@ export const slashCommandSlice = createSlice({
export const documentReducers = {
[documentSlice.name]: documentSlice.reducer,
[rectSelectionSlice.name]: rectSelectionSlice.reducer,
[rangeSelectionSlice.name]: rangeSelectionSlice.reducer,
[rangeSlice.name]: rangeSlice.reducer,
[slashCommandSlice.name]: slashCommandSlice.reducer,
};
export const documentActions = documentSlice.actions;
export const rectSelectionActions = rectSelectionSlice.actions;
export const rangeSelectionActions = rangeSelectionSlice.actions;
export const rangeActions = rangeSlice.actions;
export const slashCommandActions = slashCommandSlice.actions;

View File

@ -0,0 +1,307 @@
import {
BlockType,
ControllerAction,
DocumentState,
NestedBlock,
RangeState,
RangeStatic,
SplitRelationship,
} from '$app/interfaces/document';
import { getNextLineId, getPrevLineId, newBlock } from '$app/utils/document/block';
import Delta from 'quill-delta';
import { RootState } from '$app/stores/store';
import { DocumentController } from '$app/stores/effects/document/document_controller';
import { blockConfig } from '$app/constants/document/config';
import {
caretInBottomEdgeByDelta,
caretInTopEdgeByDelta,
getDeltaText,
getIndexRelativeEnter,
getLastLineIndex,
transformIndexToNextLine,
transformIndexToPrevLine,
} from '$app/utils/document/delta';
export function getMiddleIdsByRange(rangeState: RangeState, document: DocumentState) {
const { anchor, focus } = rangeState;
if (!anchor || !focus) return;
if (anchor.id === focus.id) return;
const isForward = anchor.point.y < focus.point.y;
// get all ids between anchor and focus
const amendIds = [];
const startId = isForward ? anchor.id : focus.id;
const endId = isForward ? focus.id : anchor.id;
let currentId: string | undefined = startId;
while (currentId && currentId !== endId) {
const nextId = getNextLineId(document, currentId);
if (nextId && nextId !== endId) {
amendIds.push(nextId);
}
currentId = nextId;
}
return amendIds;
}
export function getAfterMergeCaretByRange(rangeState: RangeState, insertDelta?: Delta) {
const { anchor, focus, ranges } = rangeState;
if (!anchor || !focus) return;
if (anchor.id === focus.id) return;
const isForward = anchor.point.y < focus.point.y;
const startId = isForward ? anchor.id : focus.id;
const startRange = ranges[startId];
if (!startRange) return;
const offset = insertDelta ? insertDelta.length() : 0;
return {
id: startId,
index: startRange.index + offset,
length: 0,
};
}
export function getStartAndEndDeltaExpectRange(state: RootState) {
const rangeState = state.documentRange;
const { anchor, focus, ranges } = rangeState;
if (!anchor || !focus) return;
if (anchor.id === focus.id) return;
const isForward = anchor.point.y < focus.point.y;
const startId = isForward ? anchor.id : focus.id;
const endId = isForward ? focus.id : anchor.id;
// get start and end delta
const startRange = ranges[startId];
const endRange = ranges[endId];
if (!startRange || !endRange) return;
const startNode = state.document.nodes[startId];
let startDelta = new Delta(startNode.data.delta);
startDelta = startDelta.slice(0, startRange.index);
const endNode = state.document.nodes[endId];
let endDelta = new Delta(endNode.data.delta);
endDelta = endDelta.slice(endRange.index + endRange.length);
return {
startNode,
endNode,
startDelta,
endDelta,
};
}
export function getMergeEndDeltaToStartActionsByRange(
state: RootState,
controller: DocumentController,
insertDelta?: Delta
) {
const actions = [];
const { startDelta, endDelta, endNode, startNode } = getStartAndEndDeltaExpectRange(state) || {};
if (!startDelta || !endDelta || !endNode || !startNode) return;
// merge start and end nodes
const mergeDelta = startDelta.concat(insertDelta || new Delta()).concat(endDelta);
actions.push(
controller.getUpdateAction({
...startNode,
data: {
delta: mergeDelta.ops,
},
})
);
if (endNode.id !== startNode.id) {
// delete end node
actions.push(controller.getDeleteAction(endNode));
}
return actions;
}
export function getInsertEnterNodeFields(sourceNode: NestedBlock) {
if (!sourceNode.parent) return;
const parentId = sourceNode.parent;
const config = blockConfig[sourceNode.type].splitProps || {
nextLineRelationShip: SplitRelationship.NextSibling,
nextLineBlockType: BlockType.TextBlock,
};
const newNodeType = config.nextLineBlockType;
const relationShip = config.nextLineRelationShip;
const defaultData = blockConfig[newNodeType].defaultData;
// if the defaultData property is not defined for the new block type, we throw an error.
if (!defaultData) {
throw new Error(`Cannot split node of type ${sourceNode.type} to ${newNodeType}`);
}
const newParentId = relationShip === SplitRelationship.NextSibling ? parentId : sourceNode.id;
const newPrevId = relationShip === SplitRelationship.NextSibling ? sourceNode.id : '';
return {
parentId: newParentId,
prevId: newPrevId,
type: newNodeType,
data: defaultData,
};
}
export function getInsertEnterNodeAction(
sourceNode: NestedBlock,
insertNodeDelta: Delta,
controller: DocumentController
) {
const insertNodeFields = getInsertEnterNodeFields(sourceNode);
if (!insertNodeFields) return;
const { type, data, parentId, prevId } = insertNodeFields;
const insertNode = newBlock<any>(type, parentId, {
...data,
delta: insertNodeDelta.ops,
});
return {
id: insertNode.id,
action: controller.getInsertAction(insertNode, prevId),
};
}
export function findPrevHasDeltaNode(state: DocumentState, id: string) {
const prevLineId = getPrevLineId(state, id);
if (!prevLineId) return;
let prevLine = state.nodes[prevLineId];
// Find the prev line that has delta
while (prevLine && !prevLine.data.delta) {
const id = getPrevLineId(state, prevLine.id);
if (!id) return;
prevLine = state.nodes[id];
}
return prevLine;
}
export function findNextHasDeltaNode(state: DocumentState, id: string) {
const nextLineId = getNextLineId(state, id);
if (!nextLineId) return;
let nextLine = state.nodes[nextLineId];
// Find the next line that has delta
while (nextLine && !nextLine.data.delta) {
const id = getNextLineId(state, nextLine.id);
if (!id) return;
nextLine = state.nodes[id];
}
return nextLine;
}
export function isPrintableKeyEvent(event: KeyboardEvent) {
const key = event.key;
const isPrintable = key.length === 1;
return isPrintable;
}
export function getLeftCaretByRange(rangeState: RangeState) {
const { anchor, ranges, focus } = rangeState;
if (!anchor || !focus) return;
const isForward = anchor.point.y < focus.point.y;
const startId = isForward ? anchor.id : focus.id;
const range = ranges[startId];
if (!range) return;
return {
id: startId,
index: range.index,
length: 0,
};
}
export function getRightCaretByRange(rangeState: RangeState) {
const { anchor, focus, ranges, caret } = rangeState;
if (!anchor || !focus) return;
const isForward = anchor.point.y < focus.point.y;
const endId = isForward ? focus.id : anchor.id;
const range = ranges[endId];
if (!range) return;
return {
id: endId,
index: range.index + range.length,
length: 0,
};
}
export function transformToPrevLineCaret(document: DocumentState, caret: RangeStatic) {
const delta = new Delta(document.nodes[caret.id].data.delta);
const inTopEdge = caretInTopEdgeByDelta(delta, caret.index);
if (!inTopEdge) {
const index = transformIndexToPrevLine(delta, caret.index);
return {
id: caret.id,
index,
length: 0,
};
}
const prevLine = findPrevHasDeltaNode(document, caret.id);
if (!prevLine) return;
const relativeIndex = getIndexRelativeEnter(delta, caret.index);
const prevLineIndex = getLastLineIndex(new Delta(prevLine.data.delta));
const prevLineText = getDeltaText(new Delta(prevLine.data.delta));
const newPrevLineIndex = prevLineIndex + relativeIndex;
const prevLineLength = prevLineText.length;
const index = newPrevLineIndex > prevLineLength ? prevLineLength : newPrevLineIndex;
return {
id: prevLine.id,
index,
length: 0,
};
}
export function transformToNextLineCaret(document: DocumentState, caret: RangeStatic) {
const delta = new Delta(document.nodes[caret.id].data.delta);
const inBottomEdge = caretInBottomEdgeByDelta(delta, caret.index);
if (!inBottomEdge) {
const index = transformIndexToNextLine(delta, caret.index);
return {
id: caret.id,
index,
length: 0,
};
return;
}
const nextLine = findNextHasDeltaNode(document, caret.id);
if (!nextLine) return;
const nextLineText = getDeltaText(new Delta(nextLine.data.delta));
const relativeIndex = getIndexRelativeEnter(delta, caret.index);
const index = relativeIndex >= nextLineText.length ? nextLineText.length : relativeIndex;
return {
id: nextLine.id,
index,
length: 0,
};
}
export function getDuplicateActions(
id: string,
parentId: string,
document: DocumentState,
controller: DocumentController
) {
const actions: ControllerAction[] = [];
const node = document.nodes[id];
if (!node) return;
// duplicate new node
const newNode = newBlock<any>(node.type, parentId, {
...node.data,
});
actions.push(controller.getInsertAction(newNode, node.id));
const children = document.children[node.children];
children.forEach((child) => {
const duplicateChildActions = getDuplicateActions(child, newNode.id, document, controller);
if (!duplicateChildActions) return;
actions.push(...duplicateChildActions.actions);
});
return {
actions,
newNodeId: newNode.id,
};
}

View File

@ -0,0 +1,92 @@
import { BlockData, BlockType, DocumentState, NestedBlock } from '$app/interfaces/document';
import { BlockPB } from '@/services/backend';
import { Log } from '$app/utils/log';
import { nanoid } from 'nanoid';
export function blockPB2Node(block: BlockPB) {
let data = {};
try {
data = JSON.parse(block.data);
} catch {
Log.error('[Document Open] json parse error', block.data);
}
const node = {
id: block.id,
type: block.ty as BlockType,
parent: block.parent_id,
children: block.children_id,
data,
};
return node;
}
export function generateId() {
return nanoid(10);
}
export function getPrevLineId(state: DocumentState, id: string) {
const node = state.nodes[id];
if (!node.parent) return;
const parent = state.nodes[node.parent];
const children = state.children[parent.children];
const index = children.indexOf(id);
const prevNodeId = children[index - 1];
const prevNode = state.nodes[prevNodeId];
if (!prevNode) {
return parent.id;
}
// find prev line
let prevLineId = prevNode.id;
while (prevLineId) {
const prevLineChildren = state.children[state.nodes[prevLineId].children];
if (prevLineChildren.length === 0) break;
prevLineId = prevLineChildren[prevLineChildren.length - 1];
}
return prevLineId || parent.id;
}
export function getNextLineId(state: DocumentState, id: string) {
const node = state.nodes[id];
if (!node.parent) return;
const firstChild = state.children[node.children][0];
if (firstChild) return firstChild;
let nextNodeId = getNextNodeId(state, id);
let parent: NestedBlock | null = state.nodes[node.parent];
while (!nextNodeId && parent) {
nextNodeId = getNextNodeId(state, parent.id);
parent = parent.parent ? state.nodes[parent.parent] : null;
}
return nextNodeId;
}
export function getNextNodeId(state: DocumentState, id: string) {
const node = state.nodes[id];
if (!node.parent) return;
const parent = state.nodes[node.parent];
const children = state.children[parent.children];
const index = children.indexOf(id);
const nextNodeId = children[index + 1];
return nextNodeId;
}
export function getPrevNodeId(state: DocumentState, id: string) {
const node = state.nodes[id];
if (!node.parent) return;
const parent = state.nodes[node.parent];
const children = state.children[parent.children];
const index = children.indexOf(id);
const prevNodeId = children[index - 1];
return prevNodeId;
}
export function newBlock<Type>(type: BlockType, parentId: string, data: BlockData<Type>): NestedBlock<Type> {
return {
id: generateId(),
type,
parent: parentId,
children: generateId(),
data,
};
}

View File

@ -1,34 +0,0 @@
import { getPointOfCurrentLineBeginning } from '$app/utils/document/blocks/text/delta';
import { Editor, Transforms } from 'slate';
export function indent(editor: Editor, distance: number) {
const beginPoint = getPointOfCurrentLineBeginning(editor);
const emptyStr = ''.padStart(distance);
Transforms.insertText(editor, emptyStr, {
at: beginPoint,
});
}
export function outdent(editor: Editor, distance: number) {
const beginPoint = getPointOfCurrentLineBeginning(editor);
if (!beginPoint) return;
const afterBeginPoint = Editor.after(editor, beginPoint, {
distance,
});
if (!afterBeginPoint) return;
const deleteChar = Editor.string(editor, {
anchor: beginPoint,
focus: afterBeginPoint,
});
const emptyStr = ''.padStart(distance);
if (deleteChar !== emptyStr) {
if (distance > 1) {
outdent(editor, distance - 1);
}
return;
}
Transforms.delete(editor, {
at: beginPoint,
distance,
});
}

View File

@ -1,220 +0,0 @@
import {
BlockData,
BlockType,
DocumentState,
NestedBlock,
RangeSelectionState,
TextDelta,
TextSelection,
} from '$app/interfaces/document';
import { Descendant, Element, Text } from 'slate';
import { BlockPB } from '@/services/backend';
import { Log } from '$app/utils/log';
import { nanoid } from 'nanoid';
import { clone } from '$app/utils/tool';
export function slateValueToDelta(slateNodes: Descendant[]) {
const element = slateNodes[0] as Element;
const children = element.children as Text[];
return children.map((child) => {
const { text, ...attributes } = child;
return {
insert: text,
attributes,
};
});
}
export function deltaToSlateValue(delta: TextDelta[]) {
const slateNode = {
type: 'paragraph',
children: [{ text: '' }],
};
const slateNodes = [slateNode];
if (delta.length > 0) {
slateNode.children = delta.map((d) => {
return {
...d.attributes,
text: d.insert,
};
});
}
return slateNodes;
}
export function getDeltaFromSlateNodes(slateNodes: Descendant[]) {
const element = slateNodes[0] as Element;
const children = element.children as Text[];
return children.map((child) => {
const { text, ...attributes } = child;
return {
insert: text,
attributes,
};
});
}
export function blockPB2Node(block: BlockPB) {
let data = {};
try {
data = JSON.parse(block.data);
} catch {
Log.error('[Document Open] json parse error', block.data);
}
const node = {
id: block.id,
type: block.ty as BlockType,
parent: block.parent_id,
children: block.children_id,
data,
};
return node;
}
export function generateId() {
return nanoid(10);
}
export function getPrevLineId(state: DocumentState, id: string) {
const node = state.nodes[id];
if (!node.parent) return;
const parent = state.nodes[node.parent];
const children = state.children[parent.children];
const index = children.indexOf(id);
const prevNodeId = children[index - 1];
const prevNode = state.nodes[prevNodeId];
if (!prevNode) {
return parent.id;
}
// find prev line
let prevLineId = prevNode.id;
while (prevLineId) {
const prevLineChildren = state.children[state.nodes[prevLineId].children];
if (prevLineChildren.length === 0) break;
prevLineId = prevLineChildren[prevLineChildren.length - 1];
}
return prevLineId || parent.id;
}
export function getNextLineId(state: DocumentState, id: string) {
const node = state.nodes[id];
if (!node.parent) return;
const firstChild = state.children[node.children][0];
if (firstChild) return firstChild;
let nextNodeId = getNextNodeId(state, id);
let parent: NestedBlock | null = state.nodes[node.parent];
while (!nextNodeId && parent) {
nextNodeId = getNextNodeId(state, parent.id);
parent = parent.parent ? state.nodes[parent.parent] : null;
}
return nextNodeId;
}
export function getNextNodeId(state: DocumentState, id: string) {
const node = state.nodes[id];
if (!node.parent) return;
const parent = state.nodes[node.parent];
const children = state.children[parent.children];
const index = children.indexOf(id);
const nextNodeId = children[index + 1];
return nextNodeId;
}
export function getPrevNodeId(state: DocumentState, id: string) {
const node = state.nodes[id];
if (!node.parent) return;
const parent = state.nodes[node.parent];
const children = state.children[parent.children];
const index = children.indexOf(id);
const prevNodeId = children[index - 1];
return prevNodeId;
}
export function newBlock<Type>(type: BlockType, parentId: string, data: BlockData<Type>): NestedBlock<Type> {
return {
id: generateId(),
type,
parent: parentId,
children: generateId(),
data,
};
}
export function getCollapsedRange(id: string, selection: TextSelection): RangeSelectionState {
const point = {
id,
selection,
};
return {
anchor: clone(point),
focus: clone(point),
isDragging: false,
selection: [],
};
}
export function iterateNodes(
range: {
startId: string;
endId: string;
},
isForward: boolean,
document: DocumentState,
callback: (nodeId?: string) => boolean
) {
const { startId, endId } = range;
let currentId = startId;
while (currentId && currentId !== endId) {
if (isForward) {
currentId = getNextLineId(document, currentId) || '';
} else {
currentId = getPrevLineId(document, currentId) || '';
}
if (callback(currentId)) {
break;
}
}
}
export function getNodesInRange(
range: {
startId: string;
endId: string;
},
isForward: boolean,
document: DocumentState
) {
const nodeIds: string[] = [];
nodeIds.push(range.startId);
iterateNodes(range, isForward, document, (nodeId) => {
if (nodeId) {
nodeIds.push(nodeId);
return false;
} else {
return true;
}
});
nodeIds.push(range.endId);
return nodeIds;
}
export function nodeInRange(
id: string,
range: {
startId: string;
endId: string;
},
isForward: boolean,
document: DocumentState
) {
let match = false;
iterateNodes(range, isForward, document, (nodeId) => {
if (nodeId === id) {
match = true;
return true;
}
return false;
});
return match;
}

View File

@ -1,132 +0,0 @@
import { Editor } from 'slate';
import {
BulletListBlockData,
CalloutBlockData,
HeadingBlockData,
NumberedListBlockData,
TodoListBlockData,
ToggleListBlockData,
} from '$app/interfaces/document';
import { getAfterRangeAt, getBeforeRangeAt, getDeltaAfterSelection } from '$app/utils/document/blocks/text/delta';
/**
* get heading data from editor, only support markdown
* @param editor
*/
export function getHeadingDataFromEditor(editor: Editor): HeadingBlockData | undefined {
const selection = editor.selection;
if (!selection) return;
const hashTags = Editor.string(editor, getBeforeRangeAt(editor, selection));
const level = hashTags.match(/#/g)?.length;
if (!level) return;
const delta = getDeltaAfterSelection(editor);
if (!delta) return;
return {
level,
delta,
};
}
/**
* get quote data from editor, only support markdown
* @param editor
*/
export function getQuoteDataFromEditor(editor: Editor) {
const delta = getDeltaAfterSelection(editor);
if (!delta) return;
return {
delta,
size: 'default',
};
}
/**
* get todo_list data from editor, only support markdown
* @param editor
*/
export function getTodoListDataFromEditor(editor: Editor): TodoListBlockData | undefined {
const selection = editor.selection;
if (!selection) return;
const hashTags = Editor.string(editor, getBeforeRangeAt(editor, selection));
const checked = hashTags.match(/x/g)?.length;
const delta = getDeltaAfterSelection(editor);
if (!delta) return;
return {
delta,
checked: !!checked,
};
}
/**
* get bulleted_list data from editor, only support markdown
* @param editor
*/
export function getBulletedDataFromEditor(editor: Editor): BulletListBlockData | undefined {
const delta = getDeltaAfterSelection(editor);
if (!delta) return;
return {
delta,
format: 'default',
};
}
/**
* get numbered_list data from editor, only support markdown
* @param editor
*/
export function getNumberedListDataFromEditor(editor: Editor): NumberedListBlockData | undefined {
const delta = getDeltaAfterSelection(editor);
if (!delta) return;
return {
delta,
format: 'default',
};
}
/**
* get toggle_list data from editor, only support markdown
*/
export function getToggleListDataFromEditor(editor: Editor): ToggleListBlockData | undefined {
const delta = getDeltaAfterSelection(editor);
if (!delta) return;
return {
delta,
collapsed: false,
};
}
/**
* get callout data from editor, only support markdown
*/
export function getCalloutDataFromEditor(editor: Editor): CalloutBlockData | undefined {
const delta = getDeltaAfterSelection(editor);
if (!delta) return;
const selection = editor.selection;
if (!selection) return;
const hashTags = Editor.string(editor, getBeforeRangeAt(editor, selection));
const tag = hashTags.match(/(TIP|INFO|WARNING|DANGER)/g)?.[0];
if (!tag) return;
const iconMap: Record<string, string> = {
TIP: '💡',
INFO: '❗',
WARNING: '⚠️',
DANGER: '‼️',
};
return {
delta,
icon: iconMap[tag],
};
}
/**
* get code block data from editor, only support markdown
*/
export function getCodeBlockDataFromEditor(editor: Editor) {
const delta = getDeltaAfterSelection(editor);
if (!delta) return;
return {
delta,
language: 'javascript',
wrap: true,
};
}

View File

@ -1,22 +0,0 @@
export function isPointInBlock(target: HTMLElement | null) {
let node = target;
while (node) {
if (node.getAttribute('data-block-id')) {
return true;
}
node = node.parentElement;
}
return false;
}
export function getBlockIdByPoint(target: HTMLElement | null) {
let node = target;
while (node) {
const id = node.getAttribute('data-block-id');
if (id) {
return id;
}
node = node.parentElement;
}
return null;
}

View File

@ -1,378 +0,0 @@
import { Editor, Element, Location, Text, Range } from 'slate';
import { SelectionPoint, TextDelta, TextSelection } from '$app/interfaces/document';
import * as Y from 'yjs';
import { getDeltaFromSlateNodes } from '$app/utils/document/blocks/common';
export function getDelta(editor: Editor, at: Location): TextDelta[] {
const baseElement = Editor.fragment(editor, at)[0] as Element;
return baseElement.children.map((item) => {
const { text, ...attributes } = item as Text;
return {
insert: text,
attributes,
};
});
}
export function getBeforeRangeDelta(delta: TextDelta[], range: TextSelection): TextDelta[] {
const anchor = Range.start(range);
const sliceNodes = delta.slice(0, anchor.path[1] + 1);
const sliceEnd = sliceNodes[sliceNodes.length - 1];
const sliceEndText = sliceEnd.insert.slice(0, anchor.offset);
const sliceEndAttributes = sliceEnd.attributes;
const sliceEndNode =
sliceEndText.length > 0
? {
insert: sliceEndText,
attributes: sliceEndAttributes,
}
: null;
const sliceMiddleNodes = sliceNodes.slice(0, sliceNodes.length - 1);
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
return [...sliceMiddleNodes, sliceEndNode].filter((item) => item);
}
export function getAfterRangeDelta(delta: TextDelta[], range: TextSelection): TextDelta[] {
const focus = Range.end(range);
const sliceNodes = delta.slice(focus.path[1], delta.length);
const sliceStart = sliceNodes[0];
const sliceStartText = sliceStart.insert.slice(focus.offset);
const sliceStartAttributes = sliceStart.attributes;
const sliceStartNode =
sliceStartText.length > 0
? {
insert: sliceStartText,
attributes: sliceStartAttributes,
}
: null;
const sliceMiddleNodes = sliceNodes.slice(1, sliceNodes.length);
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
return [sliceStartNode, ...sliceMiddleNodes].filter((item) => item);
}
export function getRangeDelta(delta: TextDelta[], range: TextSelection): TextDelta[] {
const anchor = Range.start(range);
const focus = Range.end(range);
const sliceNodes = delta.slice(anchor.path[1], focus.path[1] + 1);
if (anchor.path[1] === focus.path[1]) {
return sliceNodes.map((item) => {
const { insert, attributes } = item;
const text = insert.slice(anchor.offset, focus.offset);
return {
insert: text,
attributes,
};
});
}
const sliceStart = sliceNodes[0];
const sliceEnd = sliceNodes[sliceNodes.length - 1];
const sliceStartText = sliceStart.insert.slice(anchor.offset);
const sliceEndText = sliceEnd.insert.slice(0, focus.offset);
const sliceStartAttributes = sliceStart.attributes;
const sliceEndAttributes = sliceEnd.attributes;
const sliceStartNode =
sliceStartText.length > 0
? {
insert: sliceStartText,
attributes: sliceStartAttributes,
}
: null;
const sliceEndNode =
sliceEndText.length > 0
? {
insert: sliceEndText,
attributes: sliceEndAttributes,
}
: null;
const sliceMiddleNodes = sliceNodes.slice(1, sliceNodes.length - 1);
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
return [sliceStartNode, ...sliceMiddleNodes, sliceEndNode].filter((item) => item);
}
/**
* get the selection between the beginning of the editor and the point
* form 0 to point
* @param editor
* @param at
*/
export function getBeforeRangeAt(editor: Editor, at: Location) {
const start = Editor.start(editor, at);
return {
anchor: { path: [0, 0], offset: 0 },
focus: start,
};
}
/**
* get the selection between the point and the end of the editor
* from point to end
* @param editor
* @param at
*/
export function getAfterRangeAt(editor: Editor, at: Location) {
const end = Editor.end(editor, at);
const fragment = (editor.children[0] as Element).children;
const lastIndex = fragment.length - 1;
const lastNode = fragment[lastIndex] as Text;
return {
anchor: end,
focus: { path: [0, lastIndex], offset: lastNode.text.length },
};
}
/**
* check if the point is in the beginning of the editor
* @param editor
* @param at
*/
export function pointInBegin(editor: Editor, at: Location) {
const start = Editor.start(editor, at);
return Editor.before(editor, start) === undefined;
}
/**
* check if the point is in the end of the editor
* @param editor
* @param at
*/
export function pointInEnd(editor: Editor, at: Location) {
const end = Editor.end(editor, at);
return Editor.after(editor, end) === undefined;
}
/**
* get the selection of the beginning of the node
*/
export function getNodeBeginSelection(): TextSelection {
const point: SelectionPoint = {
path: [0, 0],
offset: 0,
};
const selection: TextSelection = {
anchor: clonePoint(point),
focus: clonePoint(point),
};
return selection;
}
export function getEditorEndPoint(editor: Editor): SelectionPoint {
const fragment = (editor.children[0] as Element).children;
const lastIndex = fragment.length - 1;
const lastNode = fragment[lastIndex] as Text;
return { path: [0, lastIndex], offset: lastNode.text.length };
}
/**
* get the selection of the end of the node
* @param delta
*/
export function getNodeEndSelection(delta: TextDelta[]) {
const len = delta.length;
const offset = len > 0 ? delta[len - 1].insert.length : 0;
const cursorPoint: SelectionPoint = {
path: [0, Math.max(len - 1, 0)],
offset,
};
const selection: TextSelection = {
anchor: clonePoint(cursorPoint),
focus: clonePoint(cursorPoint),
};
return selection;
}
/**
* get lines by delta
* @param delta
*/
export function getLinesByDelta(delta: TextDelta[]): string[] {
const text = delta.map((item) => item.insert).join('');
return text.split('\n');
}
/**
* get the offset of the last line
* @param delta
*/
export function getLastLineOffsetByDelta(delta: TextDelta[]): number {
const text = delta.map((item) => item.insert).join('');
const index = text.lastIndexOf('\n');
return index === -1 ? 0 : index + 1;
}
/**
* get the offset of per line beginning
* @param editor
*/
export function getOffsetOfPerLineBeginning(editor: Editor): number[] {
const delta = getDeltaFromSlateNodes(editor.children);
const lines = getLinesByDelta(delta);
const offsets: number[] = [];
let offset = 0;
for (let i = 0; i < lines.length; i++) {
const lineText = lines[i] + '\n';
offsets.push(offset);
offset += lineText.length;
}
return offsets;
}
/**
* get the selection of the end line by offset
* @param delta
* @param offset relative offset of the end line
*/
export function getEndLineSelectionByOffset(delta: TextDelta[], offset: number) {
const lines = getLinesByDelta(delta);
const endLine = lines[lines.length - 1];
// if the offset is greater than the length of the end line, set cursor to the end of prev line
if (offset >= endLine.length) {
return getNodeEndSelection(delta);
}
const textOffset = getLastLineOffsetByDelta(delta) + offset;
return getSelectionByTextOffset(delta, textOffset);
}
/**
* get the selection of the start line by offset
* @param delta
* @param offset relative offset of the start line
*/
export function getStartLineSelectionByOffset(delta: TextDelta[], offset: number) {
const lines = getLinesByDelta(delta);
if (lines.length === 0) {
return getNodeBeginSelection();
}
const startLine = lines[0];
// if the offset is greater than the length of the end line, set cursor to the end of prev line
if (offset >= startLine.length) {
return getSelectionByTextOffset(delta, startLine.length);
}
return getSelectionByTextOffset(delta, offset);
}
/**
* get the selection by text offset
* @param delta
* @param offset absolute offset
*/
export function getSelectionByTextOffset(delta: TextDelta[], offset: number) {
const point = getPointByTextOffset(delta, offset);
const selection: TextSelection = {
anchor: clonePoint(point),
focus: clonePoint(point),
};
return selection;
}
/**
* get the text offset by selection
* @param delta
* @param point
*/
export function getTextOffsetBySelection(delta: TextDelta[], point: SelectionPoint) {
let textOffset = 0;
for (let i = 0; i < point.path[1]; i++) {
const item = delta[i];
textOffset += item.insert.length;
}
textOffset += point.offset;
return textOffset;
}
/**
* get the point by text offset
* @param delta
* @param offset absolute offset
*/
export function getPointByTextOffset(delta: TextDelta[], offset: number): SelectionPoint {
let textOffset = 0;
let path: [number, number] = [0, 0];
let textLength = 0;
for (let i = 0; i < delta.length; i++) {
const item = delta[i];
if (textOffset + item.insert.length >= offset) {
path = [0, i];
textLength = offset - textOffset;
break;
}
textOffset += item.insert.length;
}
return {
path,
offset: textLength,
};
}
export function clonePoint(point: SelectionPoint): SelectionPoint {
return {
path: [...point.path],
offset: point.offset,
};
}
export function isSameDelta(referDelta: TextDelta[], delta: TextDelta[]) {
const ydoc = new Y.Doc();
const yText = ydoc.getText('1');
const yTextRefer = ydoc.getText('2');
yText.applyDelta(delta);
yTextRefer.applyDelta(referDelta);
return JSON.stringify(yText.toDelta()) === JSON.stringify(yTextRefer.toDelta());
}
export function getDeltaBeforeSelection(editor: Editor) {
const selection = editor.selection;
if (!selection) return;
const beforeRange = getBeforeRangeAt(editor, selection);
return getDelta(editor, beforeRange);
}
export function getDeltaAfterSelection(editor: Editor): TextDelta[] | undefined {
const selection = editor.selection;
if (!selection) return;
const afterRange = getAfterRangeAt(editor, selection);
return getDelta(editor, afterRange);
}
export function getSplitDelta(editor: Editor) {
// get the retain content
const retain = getDeltaBeforeSelection(editor) || [];
// get the insert content
const insert = getDeltaAfterSelection(editor) || [];
return { retain, insert };
}
export function getPointOfCurrentLineBeginning(editor: Editor) {
const { selection } = editor;
if (!selection) return;
const delta = getDeltaFromSlateNodes(editor.children);
const textOffset = getTextOffsetBySelection(delta, selection.anchor as SelectionPoint);
const offsets = getOffsetOfPerLineBeginning(editor);
let lineNumber = offsets.findIndex((item) => item > textOffset);
if (lineNumber === -1) {
lineNumber = offsets.length - 1;
} else {
lineNumber -= 1;
}
const lineBeginOffset = offsets[lineNumber];
const beginPoint = getPointByTextOffset(delta, lineBeginOffset);
return beginPoint;
}
export function selectionIsForward(selection: TextSelection | null) {
if (!selection) return false;
const { anchor, focus } = selection;
if (!anchor || !focus) return false;
return anchor.path[1] < focus.path[1] || (anchor.path[1] === focus.path[1] && anchor.offset < focus.offset);
}

View File

@ -1,79 +0,0 @@
import isHotkey from 'is-hotkey';
import { Editor, Range } from 'slate';
import { getAfterRangeAt, getBeforeRangeAt, pointInBegin, pointInEnd } from './delta';
import { keyBoardEventKeyMap } from '$app/constants/document/text_block';
const HOTKEYS: Record<string, string> = {
'mod+b': 'bold',
'mod+i': 'italic',
'mod+u': 'underline',
'mod+e': 'code',
'mod+shift+X': 'strikethrough',
'mod+shift+S': 'strikethrough',
};
export function canHandleBackspaceKey(event: React.KeyboardEvent<HTMLDivElement>, editor: Editor) {
const isBackspaceKey = isHotkey('backspace', event);
const selection = editor.selection;
if (!isBackspaceKey || !selection) {
return false;
}
// It should be handled if the selection is collapsed and the cursor is at the beginning of the block
const isCollapsed = Range.isCollapsed(selection);
return isCollapsed && pointInBegin(editor, selection);
}
export function canHandleUpKey(event: React.KeyboardEvent<HTMLDivElement>, editor: Editor) {
const isUpKey = event.key === keyBoardEventKeyMap.Up;
const selection = editor.selection;
if (!isUpKey || !selection) {
return false;
}
// It should be handled if the selection is collapsed and the cursor is at the first line of the block
const isCollapsed = Range.isCollapsed(selection);
const beforeString = Editor.string(editor, getBeforeRangeAt(editor, selection));
const isTopEdge = !beforeString.includes('\n');
return isCollapsed && isTopEdge;
}
export function canHandleDownKey(event: React.KeyboardEvent<HTMLDivElement>, editor: Editor) {
const isDownKey = event.key === keyBoardEventKeyMap.Down;
const selection = editor.selection;
if (!isDownKey || !selection) {
return false;
}
// It should be handled if the selection is collapsed and the cursor is at the last line of the block
const isCollapsed = Range.isCollapsed(selection);
const afterString = Editor.string(editor, getAfterRangeAt(editor, selection));
const isBottomEdge = !afterString.includes('\n');
return isCollapsed && isBottomEdge;
}
export function canHandleLeftKey(event: React.KeyboardEvent<HTMLDivElement>, editor: Editor) {
const isLeftKey = event.key === keyBoardEventKeyMap.Left;
const selection = editor.selection;
if (!isLeftKey || !selection) {
return false;
}
// It should be handled if the selection is collapsed and the cursor is at the beginning of the block
const isCollapsed = Range.isCollapsed(selection);
return isCollapsed && pointInBegin(editor, selection);
}
export function canHandleRightKey(event: React.KeyboardEvent<HTMLDivElement>, editor: Editor) {
const isRightKey = event.key === keyBoardEventKeyMap.Right;
const selection = editor.selection;
if (!isRightKey || !selection) {
return false;
}
// It should be handled if the selection is collapsed and the cursor is at the end of the block
const isCollapsed = Range.isCollapsed(selection);
return isCollapsed && pointInEnd(editor, selection);
}

View File

@ -0,0 +1,71 @@
import Delta from "quill-delta";
export function getDeltaText(delta: Delta) {
const text = delta
.filter((op) => typeof op.insert === "string")
.map((op) => op.insert)
.join("");
return text;
}
export function caretInTopEdgeByDelta(delta: Delta, index: number) {
const text = getDeltaText(delta.slice(0, index));
if (!text) return true;
const firstLine = text.split("\n")[0];
return index <= firstLine.length;
}
export function caretInBottomEdgeByDelta(delta: Delta, index: number) {
const text = getDeltaText(delta.slice(index));
if (!text) return true;
return !text.includes("\n");
}
export function getLineByIndex(delta: Delta, index: number) {
const beforeText = getDeltaText(delta.slice(0, index));
const afterText = getDeltaText(delta.slice(index));
const beforeLines = beforeText.split("\n");
const afterLines = afterText.split("\n");
const startLineText = beforeLines[beforeLines.length - 1];
const currentLineText = startLineText + afterLines[0];
return {
text: currentLineText,
index: beforeText.length - startLineText.length,
};
}
export function transformIndexToPrevLine(delta: Delta, index: number) {
const text = getDeltaText(delta.slice(0, index));
const lines = text.split("\n");
if (lines.length < 2) return 0;
const prevLineText = lines[lines.length - 2];
const transformedIndex = index - prevLineText.length - 1;
return transformedIndex > 0 ? transformedIndex : 0;
}
function getCurrentLineText(delta: Delta, index: number) {
return getLineByIndex(delta, index).text;
}
export function transformIndexToNextLine(delta: Delta, index: number) {
const text = getDeltaText(delta);
const currentLineText = getCurrentLineText(delta, index);
const transformedIndex = index + currentLineText.length + 1;
return transformedIndex > text.length ? text.length : transformedIndex;
}
export function getIndexRelativeEnter(delta: Delta, index: number) {
const text = getDeltaText(delta.slice(0, index));
const beforeLines = text.split("\n");
const beforeLineText = beforeLines[beforeLines.length - 1];
return beforeLineText.length;
}
export function getLastLineIndex(delta: Delta) {
const text = getDeltaText(delta);
const lastIndex = text.lastIndexOf("\n");
return lastIndex === -1 ? 0 : lastIndex + 1;
}

View File

@ -0,0 +1,232 @@
function isTextNode(node: Node): boolean {
return node.nodeType === Node.TEXT_NODE;
}
export function exclude(node: Element) {
let isPlaceholder = false;
try {
isPlaceholder = !!node.getAttribute('data-slate-placeholder');
} catch (e) {
// ignore
}
return isPlaceholder;
}
function findFirstTextNode(node: Node): Node | null {
if (isTextNode(node)) {
return node;
}
if (exclude && exclude(node as Element)) {
return null;
}
const children = node.childNodes;
for (let i = 0; i < children.length; i++) {
const textNode = findFirstTextNode(children[i]);
if (textNode) {
return textNode;
}
}
return null;
}
export function setCursorAtStartOfNode(node: Node): void {
const range = document.createRange();
const textNode = findFirstTextNode(node);
if (textNode) {
range.setStart(textNode, 0);
range.collapse(true); // 将选区折叠到起始位置
}
const selection = window.getSelection();
selection?.removeAllRanges();
selection?.addRange(range);
}
function findLastTextNode(node: Node): Node | null {
if (isTextNode(node)) {
return node;
}
if (exclude && exclude(node as Element)) {
return null;
}
const children = node.childNodes;
for (let i = children.length - 1; i >= 0; i--) {
const textNode = findLastTextNode(children[i]);
if (textNode) {
return textNode;
}
}
return null;
}
export function setCursorAtEndOfNode(node: Node): void {
const range = document.createRange();
const textNode = findLastTextNode(node);
if (textNode) {
const textLength = textNode.textContent?.length || 0;
range.setStart(textNode, textLength);
range.setEnd(textNode, textLength);
}
const selection = window.getSelection();
selection?.removeAllRanges();
selection?.addRange(range);
}
export function setFullRangeAtNode(node: Node): void {
const range = document.createRange();
const firstTextNode = findFirstTextNode(node);
const lastTextNode = findLastTextNode(node);
if (!firstTextNode || !lastTextNode) return;
range.setStart(firstTextNode, 0);
range.setEnd(lastTextNode, lastTextNode.textContent?.length || 0);
const selection = window.getSelection();
selection?.removeAllRanges();
selection?.addRange(range);
}
export function getBlockIdByPoint(target: HTMLElement | null) {
let node = target;
while (node) {
const id = node.getAttribute('data-block-id');
if (id) {
return id;
}
node = node.parentElement;
}
return null;
}
export function findTextBoxParent(target: HTMLElement | null) {
let node = target;
while (node) {
if (node.getAttribute('role') === 'textbox') {
return node;
}
node = node.parentElement;
}
return null;
}
export function isFocused(blockId: string) {
const selection = window.getSelection();
if (!selection) return false;
const { anchorNode, focusNode } = selection;
if (!anchorNode || !focusNode) return false;
const anchorElement = anchorNode.parentElement;
const focusElement = focusNode.parentElement;
if (!anchorElement || !focusElement) return false;
const anchorBlockId = getBlockIdByPoint(anchorElement);
const focusBlockId = getBlockIdByPoint(focusElement);
return anchorBlockId === blockId || focusBlockId === blockId;
}
export function getNode(id: string) {
return document.querySelector(`[data-block-id="${id}"]`);
}
export function isPointInBlock(target: HTMLElement | null) {
let node = target;
while (node) {
if (node.getAttribute('data-block-id')) {
return true;
}
node = node.parentElement;
}
return false;
}
export function findTextNode(
node: Element,
index: number,
): {
node?: Node;
offset?: number;
remainingIndex?: number;
} {
if (isTextNode(node)) {
const textLength = node.textContent?.length || 0;
if (index <= textLength) {
return { node, offset: index };
}
return { remainingIndex: index - textLength };
}
if (exclude && exclude(node)) {
return { remainingIndex: index };
}
let remainingIndex = index;
for (const childNode of node.childNodes) {
const result = findTextNode(childNode as Element, remainingIndex);
if (result.node) {
return result;
}
remainingIndex = result.remainingIndex || index;
}
return { remainingIndex };
}
export function focusNodeByIndex(node: Element, index: number, length: number) {
const textBoxNode = node.querySelector(`[role="textbox"]`);
if (!textBoxNode) return;
const anchorNode = findTextNode(textBoxNode, index);
const focusNode = findTextNode(textBoxNode, index + length);
if (!anchorNode?.node || !focusNode?.node) return;
const range = document.createRange();
range.setStart(anchorNode.node, anchorNode.offset || 0);
range.setEnd(focusNode.node, focusNode.offset || 0);
const selection = window.getSelection();
selection?.removeAllRanges();
selection?.addRange(range);
}
export function getNodeTextBoxByBlockId(blockId: string) {
const node = getNode(blockId);
return node?.querySelector(`[role="textbox"]`);
}
export function getNodeText(node: Element) {
if (isTextNode(node)) {
return node.textContent || '';
}
if (exclude && exclude(node)) {
return '';
}
let text = '';
for (const childNode of node.childNodes) {
text += getNodeText(childNode as Element);
}
return replaceZeroWidthSpace(text);
}
export function replaceZeroWidthSpace(text: string) {
// Unicode has the following characters that are invisible and have no width:
// \u200B - zero width space
// \u200C - zero width non-joiner
// \u200D - zero width joiner
// \uFEFF - zero width no-break space
return text.replace(/[\u200B-\u200D\uFEFF]/g, '');
}
export function findParent(node: Element, parentSelector: string) {
let parentNode: Element | null = node;
while (parentNode) {
if (parentNode.matches(parentSelector)) {
return parentNode;
}
parentNode = parentNode.parentElement;
}
return null;
}

View File

@ -0,0 +1,59 @@
import { Op } from 'quill-delta';
import { TextAction } from '$app/interfaces/document';
export function adaptDeltaForQuill(inputOps: Op[], isOutput = false): Op[] {
if (inputOps.length === 0) {
return inputOps;
}
// quill attribute -> custom attribute
const attributeMapping = {
strike: TextAction.Strikethrough,
};
const newOps = inputOps.map((op) => {
if (!op.attributes) return op;
const newOpAttributes = { ...op.attributes };
Object.entries(attributeMapping).forEach(([attribute, customAttribute]) => {
if (isOutput) {
if (attribute in newOpAttributes) {
newOpAttributes[customAttribute] = newOpAttributes[attribute];
delete newOpAttributes[attribute];
}
} else {
if (customAttribute in newOpAttributes) {
newOpAttributes[attribute] = newOpAttributes[customAttribute];
delete newOpAttributes[customAttribute];
}
}
});
return {
...op,
attributes: newOpAttributes,
};
});
const lastOpIndex = newOps.length - 1;
const lastOp = newOps[lastOpIndex];
const text = lastOp.insert as string;
const endsWithNewline = text.endsWith('\n');
if (isOutput && !endsWithNewline) {
return newOps;
}
if (isOutput) {
const newText = text.slice(0, -1);
if (newText !== '') {
newOps[lastOpIndex] = { ...lastOp, insert: newText };
} else {
newOps.pop();
}
} else {
newOps.push({ insert: '\n' });
}
return newOps;
}

View File

@ -0,0 +1,134 @@
import { BaseElement, BasePoint, Descendant, Editor, Element, Selection, Text } from "slate";
import Delta from "quill-delta";
import { getLineByIndex } from "$app/utils/document/delta";
export function convertToSlateSelection(index: number, length: number, slateValue: Descendant[]){
if (!slateValue || slateValue.length === 0) return null;
const texts = (slateValue[0] as BaseElement).children.map((child) => (child as Text).text);
const anchorIndex = index;
const focusIndex = index + length;
let anchorPath: number[] = [];
let focusPath: number[] = [];
let anchorOffset = 0;
let focusOffset = 0;
let charCount = 0;
texts.forEach((text, i) => {
const endOffset = charCount + text.length;
if (anchorIndex >= charCount && anchorIndex <= endOffset) {
anchorPath = [0, i];
anchorOffset = anchorIndex - charCount;
}
if (focusIndex >= charCount && focusIndex <= endOffset) {
focusPath = [0, i];
focusOffset = focusIndex - charCount;
}
charCount += text.length;
});
return {
anchor: {
path: anchorPath,
offset: anchorOffset,
},
focus: {
path: focusPath,
offset: focusOffset,
},
};
}
export function converToIndexLength(editor: Editor, range: Selection) {
if (!range) return null;
const start = Editor.start(editor, [0, 0]);
const before = Editor.start(editor, range);
const after = Editor.end(editor, range);
const index = Editor.string(editor, {
anchor: start,
focus: before,
}).length;
const focusIndex = Editor.string(editor, {
anchor: start,
focus: after,
}).length;
const length = focusIndex - index;
return { index, length };
}
export function convertToSlateValue(delta: Delta): Descendant[] {
const ops = delta.ops;
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
const children: Text[] =
ops.length === 0
? [
{
text: '',
},
]
: ops.map((op) => ({
text: op.insert || '',
...op.attributes,
}));
return [
{
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
type: 'paragraph',
children,
},
];
}
export function convertToDelta(slateValue: Descendant[]) {
const ops = (slateValue[0] as Element).children.map((child) => {
const { text, ...attributes } = child as Text;
return {
insert: text,
attributes,
};
});
return new Delta(ops);
}
function getBreakLineBeginPoint(editor: Editor, at: Selection): BasePoint | undefined {
const delta = convertToDelta(editor.children);
const currentSelection = converToIndexLength(editor, at);
if (!currentSelection) return;
const { index } = getLineByIndex(delta, currentSelection.index);
const selection = convertToSlateSelection(index, 0, editor.children);
return selection?.anchor;
}
export function indent(editor: Editor, distance: number) {
const beginPoint = getBreakLineBeginPoint(editor, editor.selection);
if (!beginPoint) return;
const emptyStr = "".padStart(distance);
editor.insertText(emptyStr, {
at: beginPoint
});
}
export function outdent(editor: Editor, distance: number) {
const beginPoint = getBreakLineBeginPoint(editor, editor.selection);
if (!beginPoint) return;
const afterBeginPoint = Editor.after(editor, beginPoint, {
distance
});
if (!afterBeginPoint) return;
const deleteChar = Editor.string(editor, {
anchor: beginPoint,
focus: afterBeginPoint
});
const emptyStr = "".padStart(distance);
if (deleteChar !== emptyStr) {
if (distance > 1) {
outdent(editor, distance - 1);
}
return;
}
editor.delete({
at: beginPoint,
distance
});
}

View File

@ -1,4 +1,4 @@
export function calcToolbarPosition(toolbarDom: HTMLDivElement) {
export function calcToolbarPosition(toolbarDom: HTMLDivElement, node: Element, container: HTMLDivElement) {
const domSelection = window.getSelection();
let domRange;
if (domSelection?.rangeCount === 0) {
@ -7,13 +7,27 @@ export function calcToolbarPosition(toolbarDom: HTMLDivElement) {
domRange = domSelection?.getRangeAt(0);
}
const nodeRect = node.getBoundingClientRect();
const rect = domRange?.getBoundingClientRect() || { top: 0, left: 0, width: 0, height: 0 };
let top = rect.top - toolbarDom.offsetHeight;
let left = rect.left - toolbarDom.offsetWidth / 2 + rect.width / 2;
const top = rect.top - nodeRect.top - toolbarDom.offsetHeight;
let left = rect.left - nodeRect.left - toolbarDom.offsetWidth / 2 + rect.width / 2;
// fix toolbar position when it is out of the container
const containerRect = container.getBoundingClientRect();
const leftBound = containerRect.left - nodeRect.left;
const rightBound = containerRect.right;
const rightThreshold = 20;
if (left < leftBound) {
left = leftBound;
} else if (left + nodeRect.left + toolbarDom.offsetWidth > rightBound) {
left = rightBound - toolbarDom.offsetWidth - nodeRect.left - rightThreshold;
}
return {
top: top + 'px',
left: left + 'px',
top,
left,
};
}

View File

@ -46,6 +46,10 @@ module.exports = {
3: '#E2E4EB',
fiol: '#2C144B',
},
custom: {
code: 'rgba(221, 221, 221, 0.4)',
caret: 'rgb(55, 53, 47)'
}
},
boxShadow: {
md: '0px 0px 20px rgba(0, 0, 0, 0.1);',