mirror of
https://github.com/AppFlowy-IO/AppFlowy.git
synced 2024-08-30 18:12:39 +00:00
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:
parent
33e0f8d26d
commit
8cee792b94
@ -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",
|
||||
|
@ -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'}
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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]);
|
||||
|
@ -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} />;
|
||||
|
@ -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;
|
||||
}
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
@ -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,
|
||||
|
@ -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;
|
||||
|
@ -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
|
||||
|
@ -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 />,
|
||||
},
|
||||
],
|
||||
],
|
||||
[]
|
||||
|
@ -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 {
|
||||
|
@ -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
|
||||
};
|
||||
}
|
@ -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}
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
@ -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> = {
|
||||
|
@ -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}
|
||||
|
@ -0,0 +1,7 @@
|
||||
import React from 'react';
|
||||
|
||||
function BlockOverlay({ id }: { id: string }) {
|
||||
return <div className='block-overlay' />;
|
||||
}
|
||||
|
||||
export default BlockOverlay;
|
@ -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>
|
||||
</>
|
||||
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
@ -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;
|
||||
|
@ -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);
|
||||
|
@ -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;
|
||||
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
@ -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;
|
@ -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,
|
||||
};
|
||||
}
|
@ -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,
|
||||
};
|
||||
}
|
@ -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}`));
|
||||
};
|
||||
}
|
@ -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} />
|
||||
</>
|
||||
);
|
||||
|
@ -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,
|
||||
};
|
||||
}
|
@ -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;
|
||||
}
|
@ -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';
|
||||
|
||||
|
@ -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';
|
||||
|
||||
|
@ -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;
|
||||
},
|
||||
|
@ -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>
|
||||
|
@ -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,
|
||||
};
|
||||
}
|
@ -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;
|
||||
}
|
@ -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,
|
||||
};
|
||||
}
|
@ -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,
|
||||
};
|
||||
}
|
@ -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;
|
||||
}
|
||||
|
||||
|
@ -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);
|
@ -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,
|
||||
};
|
||||
}
|
@ -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);
|
@ -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 (
|
@ -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);
|
@ -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;
|
||||
}
|
||||
});
|
||||
}
|
@ -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;
|
@ -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;
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
@ -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];
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
@ -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();
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
@ -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,
|
||||
};
|
||||
}
|
@ -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,
|
||||
};
|
||||
}
|
@ -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',
|
||||
},
|
||||
];
|
||||
|
@ -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: '`',
|
||||
},
|
||||
};
|
@ -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: '/',
|
||||
};
|
@ -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;
|
||||
}
|
||||
|
@ -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);
|
||||
|
@ -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]));
|
||||
}
|
||||
);
|
||||
|
@ -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]);
|
@ -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';
|
||||
|
@ -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;
|
||||
}
|
||||
);
|
||||
|
@ -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);
|
||||
}
|
||||
);
|
@ -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 }));
|
||||
}
|
||||
);
|
@ -1,6 +0,0 @@
|
||||
export * from './indent';
|
||||
export * from './backspace';
|
||||
export * from './outdent';
|
||||
export * from './split';
|
||||
export * from './turn_to';
|
||||
export * from './update';
|
@ -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));
|
||||
}
|
||||
);
|
@ -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 }));
|
||||
}
|
||||
);
|
@ -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,
|
||||
})
|
||||
);
|
||||
}
|
||||
);
|
@ -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([
|
@ -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)));
|
||||
}
|
||||
);
|
@ -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,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
@ -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';
|
||||
|
@ -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));
|
||||
}
|
||||
);
|
@ -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 }));
|
||||
}
|
||||
);
|
||||
|
@ -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));
|
||||
}
|
||||
}
|
||||
);
|
@ -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));
|
||||
});
|
@ -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])));
|
||||
}
|
||||
);
|
||||
|
@ -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 }));
|
||||
}
|
||||
);
|
||||
|
@ -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;
|
||||
|
@ -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,
|
||||
};
|
||||
}
|
@ -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,
|
||||
};
|
||||
}
|
@ -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,
|
||||
});
|
||||
}
|
@ -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;
|
||||
}
|
@ -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,
|
||||
};
|
||||
}
|
@ -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;
|
||||
}
|
@ -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);
|
||||
}
|
@ -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);
|
||||
}
|
@ -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;
|
||||
}
|
232
frontend/appflowy_tauri/src/appflowy_app/utils/document/node.ts
Normal file
232
frontend/appflowy_tauri/src/appflowy_app/utils/document/node.ts
Normal 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;
|
||||
}
|
@ -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;
|
||||
}
|
@ -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
|
||||
});
|
||||
}
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
@ -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);',
|
||||
|
Loading…
Reference in New Issue
Block a user