mirror of
https://github.com/AppFlowy-IO/AppFlowy.git
synced 2024-08-30 18:12:39 +00:00
feat: support text equation (#2876)
This commit is contained in:
parent
2a2b5fe246
commit
5e0a0f92ff
@ -27,12 +27,14 @@
|
|||||||
"@tauri-apps/api": "^1.2.0",
|
"@tauri-apps/api": "^1.2.0",
|
||||||
"dayjs": "^1.11.7",
|
"dayjs": "^1.11.7",
|
||||||
"emoji-mart": "^5.5.2",
|
"emoji-mart": "^5.5.2",
|
||||||
|
"emoji-regex": "^10.2.1",
|
||||||
"events": "^3.3.0",
|
"events": "^3.3.0",
|
||||||
"google-protobuf": "^3.21.2",
|
"google-protobuf": "^3.21.2",
|
||||||
"i18next": "^22.4.10",
|
"i18next": "^22.4.10",
|
||||||
"i18next-browser-languagedetector": "^7.0.1",
|
"i18next-browser-languagedetector": "^7.0.1",
|
||||||
"is-hotkey": "^0.2.0",
|
"is-hotkey": "^0.2.0",
|
||||||
"jest": "^29.5.0",
|
"jest": "^29.5.0",
|
||||||
|
"katex": "^0.16.7",
|
||||||
"nanoid": "^4.0.0",
|
"nanoid": "^4.0.0",
|
||||||
"prismjs": "^1.29.0",
|
"prismjs": "^1.29.0",
|
||||||
"protoc-gen-ts": "^0.8.5",
|
"protoc-gen-ts": "^0.8.5",
|
||||||
@ -44,6 +46,7 @@
|
|||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
"react-error-boundary": "^3.1.4",
|
"react-error-boundary": "^3.1.4",
|
||||||
"react-i18next": "^12.2.0",
|
"react-i18next": "^12.2.0",
|
||||||
|
"react-katex": "^3.0.1",
|
||||||
"react-redux": "^8.0.5",
|
"react-redux": "^8.0.5",
|
||||||
"react-router-dom": "^6.8.0",
|
"react-router-dom": "^6.8.0",
|
||||||
"react18-input-otp": "^1.1.2",
|
"react18-input-otp": "^1.1.2",
|
||||||
@ -60,12 +63,14 @@
|
|||||||
"@tauri-apps/cli": "^1.2.2",
|
"@tauri-apps/cli": "^1.2.2",
|
||||||
"@types/google-protobuf": "^3.15.6",
|
"@types/google-protobuf": "^3.15.6",
|
||||||
"@types/is-hotkey": "^0.1.7",
|
"@types/is-hotkey": "^0.1.7",
|
||||||
|
"@types/katex": "^0.16.0",
|
||||||
"@types/node": "^18.7.10",
|
"@types/node": "^18.7.10",
|
||||||
"@types/prismjs": "^1.26.0",
|
"@types/prismjs": "^1.26.0",
|
||||||
"@types/quill": "^2.0.10",
|
"@types/quill": "^2.0.10",
|
||||||
"@types/react": "^18.0.15",
|
"@types/react": "^18.0.15",
|
||||||
"@types/react-beautiful-dnd": "^13.1.3",
|
"@types/react-beautiful-dnd": "^13.1.3",
|
||||||
"@types/react-dom": "^18.0.6",
|
"@types/react-dom": "^18.0.6",
|
||||||
|
"@types/react-katex": "^3.0.0",
|
||||||
"@types/utf8": "^3.0.1",
|
"@types/utf8": "^3.0.1",
|
||||||
"@types/uuid": "^9.0.1",
|
"@types/uuid": "^9.0.1",
|
||||||
"@typescript-eslint/eslint-plugin": "^5.51.0",
|
"@typescript-eslint/eslint-plugin": "^5.51.0",
|
||||||
|
@ -37,6 +37,9 @@ dependencies:
|
|||||||
emoji-mart:
|
emoji-mart:
|
||||||
specifier: ^5.5.2
|
specifier: ^5.5.2
|
||||||
version: 5.5.2
|
version: 5.5.2
|
||||||
|
emoji-regex:
|
||||||
|
specifier: ^10.2.1
|
||||||
|
version: 10.2.1
|
||||||
events:
|
events:
|
||||||
specifier: ^3.3.0
|
specifier: ^3.3.0
|
||||||
version: 3.3.0
|
version: 3.3.0
|
||||||
@ -55,6 +58,9 @@ dependencies:
|
|||||||
jest:
|
jest:
|
||||||
specifier: ^29.5.0
|
specifier: ^29.5.0
|
||||||
version: 29.5.0(@types/node@18.16.9)
|
version: 29.5.0(@types/node@18.16.9)
|
||||||
|
katex:
|
||||||
|
specifier: ^0.16.7
|
||||||
|
version: 0.16.7
|
||||||
nanoid:
|
nanoid:
|
||||||
specifier: ^4.0.0
|
specifier: ^4.0.0
|
||||||
version: 4.0.2
|
version: 4.0.2
|
||||||
@ -88,6 +94,9 @@ dependencies:
|
|||||||
react-i18next:
|
react-i18next:
|
||||||
specifier: ^12.2.0
|
specifier: ^12.2.0
|
||||||
version: 12.2.2(i18next@22.4.15)(react-dom@18.2.0)(react@18.2.0)
|
version: 12.2.2(i18next@22.4.15)(react-dom@18.2.0)(react@18.2.0)
|
||||||
|
react-katex:
|
||||||
|
specifier: ^3.0.1
|
||||||
|
version: 3.0.1(prop-types@15.8.1)(react@18.2.0)
|
||||||
react-redux:
|
react-redux:
|
||||||
specifier: ^8.0.5
|
specifier: ^8.0.5
|
||||||
version: 8.0.5(@types/react-dom@18.2.4)(@types/react@18.2.6)(react-dom@18.2.0)(react@18.2.0)(redux@4.2.1)
|
version: 8.0.5(@types/react-dom@18.2.4)(@types/react@18.2.6)(react-dom@18.2.0)(react@18.2.0)(redux@4.2.1)
|
||||||
@ -132,6 +141,9 @@ devDependencies:
|
|||||||
'@types/is-hotkey':
|
'@types/is-hotkey':
|
||||||
specifier: ^0.1.7
|
specifier: ^0.1.7
|
||||||
version: 0.1.7
|
version: 0.1.7
|
||||||
|
'@types/katex':
|
||||||
|
specifier: ^0.16.0
|
||||||
|
version: 0.16.0
|
||||||
'@types/node':
|
'@types/node':
|
||||||
specifier: ^18.7.10
|
specifier: ^18.7.10
|
||||||
version: 18.16.9
|
version: 18.16.9
|
||||||
@ -150,6 +162,9 @@ devDependencies:
|
|||||||
'@types/react-dom':
|
'@types/react-dom':
|
||||||
specifier: ^18.0.6
|
specifier: ^18.0.6
|
||||||
version: 18.2.4
|
version: 18.2.4
|
||||||
|
'@types/react-katex':
|
||||||
|
specifier: ^3.0.0
|
||||||
|
version: 3.0.0
|
||||||
'@types/utf8':
|
'@types/utf8':
|
||||||
specifier: ^3.0.1
|
specifier: ^3.0.1
|
||||||
version: 3.0.1
|
version: 3.0.1
|
||||||
@ -1632,6 +1647,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-wOuvG1SN4Us4rez+tylwwwCV1psiNVOkJeM3AUWUNWg/jDQY2+HE/444y5gc+jBmRqASOm2Oeh5c1axHobwRKQ==}
|
resolution: {integrity: sha512-wOuvG1SN4Us4rez+tylwwwCV1psiNVOkJeM3AUWUNWg/jDQY2+HE/444y5gc+jBmRqASOm2Oeh5c1axHobwRKQ==}
|
||||||
dev: true
|
dev: true
|
||||||
|
|
||||||
|
/@types/katex@0.16.0:
|
||||||
|
resolution: {integrity: sha512-hz+S3nV6Mym5xPbT9fnO8dDhBFQguMYpY0Ipxv06JMi1ORgnEM4M1ymWDUhUNer3ElLmT583opRo4RzxKmh9jw==}
|
||||||
|
dev: true
|
||||||
|
|
||||||
/@types/lodash.memoize@4.1.7:
|
/@types/lodash.memoize@4.1.7:
|
||||||
resolution: {integrity: sha512-lGN7WeO4vO6sICVpf041Q7BX/9k1Y24Zo3FY0aUezr1QlKznpjzsDk3T3wvH8ofYzoK0QupN9TWcFAFZlyPwQQ==}
|
resolution: {integrity: sha512-lGN7WeO4vO6sICVpf041Q7BX/9k1Y24Zo3FY0aUezr1QlKznpjzsDk3T3wvH8ofYzoK0QupN9TWcFAFZlyPwQQ==}
|
||||||
dependencies:
|
dependencies:
|
||||||
@ -1684,6 +1703,12 @@ packages:
|
|||||||
'@types/react': 17.0.59
|
'@types/react': 17.0.59
|
||||||
dev: false
|
dev: false
|
||||||
|
|
||||||
|
/@types/react-katex@3.0.0:
|
||||||
|
resolution: {integrity: sha512-AiHHXh71a2M7Z6z1wj6iA23SkiRF9r0neHUdu8zjU/cT3MyLxDefYHbcceKhV/gjDEZgF3YaiNHyPNtoGUjPvg==}
|
||||||
|
dependencies:
|
||||||
|
'@types/react': 18.2.6
|
||||||
|
dev: true
|
||||||
|
|
||||||
/@types/react-redux@7.1.25:
|
/@types/react-redux@7.1.25:
|
||||||
resolution: {integrity: sha512-bAGh4e+w5D8dajd6InASVIyCo4pZLJ66oLb80F9OBLO1gKESbZcRCJpTT6uLXX+HAB57zw1WTdwJdAsewuTweg==}
|
resolution: {integrity: sha512-bAGh4e+w5D8dajd6InASVIyCo4pZLJ66oLb80F9OBLO1gKESbZcRCJpTT6uLXX+HAB57zw1WTdwJdAsewuTweg==}
|
||||||
dependencies:
|
dependencies:
|
||||||
@ -2280,6 +2305,11 @@ packages:
|
|||||||
engines: {node: '>= 6'}
|
engines: {node: '>= 6'}
|
||||||
dev: true
|
dev: true
|
||||||
|
|
||||||
|
/commander@8.3.0:
|
||||||
|
resolution: {integrity: sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==}
|
||||||
|
engines: {node: '>= 12'}
|
||||||
|
dev: false
|
||||||
|
|
||||||
/compute-scroll-into-view@1.0.20:
|
/compute-scroll-into-view@1.0.20:
|
||||||
resolution: {integrity: sha512-UCB0ioiyj8CRjtrvaceBLqqhZCVP+1B8+NWQhmdsm0VXOJtobBCf1dBQmebCCo34qZmUwZfIH2MZLqNHazrfjg==}
|
resolution: {integrity: sha512-UCB0ioiyj8CRjtrvaceBLqqhZCVP+1B8+NWQhmdsm0VXOJtobBCf1dBQmebCCo34qZmUwZfIH2MZLqNHazrfjg==}
|
||||||
dev: false
|
dev: false
|
||||||
@ -2437,6 +2467,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-Sqc/nso4cjxhOwWJsp9xkVm8OF5c+mJLZJFoFfzRuKO+yWiN7K8c96xmtughYb0d/fZ8UC6cLIQ/p4BR6Pv3/A==}
|
resolution: {integrity: sha512-Sqc/nso4cjxhOwWJsp9xkVm8OF5c+mJLZJFoFfzRuKO+yWiN7K8c96xmtughYb0d/fZ8UC6cLIQ/p4BR6Pv3/A==}
|
||||||
dev: false
|
dev: false
|
||||||
|
|
||||||
|
/emoji-regex@10.2.1:
|
||||||
|
resolution: {integrity: sha512-97g6QgOk8zlDRdgq1WxwgTMgEWGVAQvB5Fdpgc1MkNy56la5SKP9GsMXKDOdqwn90/41a8yPwIGk1Y6WVbeMQA==}
|
||||||
|
dev: false
|
||||||
|
|
||||||
/emoji-regex@8.0.0:
|
/emoji-regex@8.0.0:
|
||||||
resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==}
|
resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==}
|
||||||
dev: false
|
dev: false
|
||||||
@ -3799,6 +3833,13 @@ packages:
|
|||||||
object.assign: 4.1.4
|
object.assign: 4.1.4
|
||||||
dev: true
|
dev: true
|
||||||
|
|
||||||
|
/katex@0.16.7:
|
||||||
|
resolution: {integrity: sha512-Xk9C6oGKRwJTfqfIbtr0Kes9OSv6IFsuhFGc7tW4urlpMJtuh+7YhzU6YEG9n8gmWKcMAFzkp7nr+r69kV0zrA==}
|
||||||
|
hasBin: true
|
||||||
|
dependencies:
|
||||||
|
commander: 8.3.0
|
||||||
|
dev: false
|
||||||
|
|
||||||
/kleur@3.0.3:
|
/kleur@3.0.3:
|
||||||
resolution: {integrity: sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==}
|
resolution: {integrity: sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
@ -4483,6 +4524,17 @@ packages:
|
|||||||
resolution: {integrity: sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==}
|
resolution: {integrity: sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==}
|
||||||
dev: false
|
dev: false
|
||||||
|
|
||||||
|
/react-katex@3.0.1(prop-types@15.8.1)(react@18.2.0):
|
||||||
|
resolution: {integrity: sha512-wIUW1fU5dHlkKvq4POfDkHruQsYp3fM8xNb/jnc8dnQ+nNCnaj0sx5pw7E6UyuEdLRyFKK0HZjmXBo+AtXXy0A==}
|
||||||
|
peerDependencies:
|
||||||
|
prop-types: ^15.8.1
|
||||||
|
react: '>=15.3.2 <=18'
|
||||||
|
dependencies:
|
||||||
|
katex: 0.16.7
|
||||||
|
prop-types: 15.8.1
|
||||||
|
react: 18.2.0
|
||||||
|
dev: false
|
||||||
|
|
||||||
/react-redux@7.2.9(react-dom@18.2.0)(react@18.2.0):
|
/react-redux@7.2.9(react-dom@18.2.0)(react@18.2.0):
|
||||||
resolution: {integrity: sha512-Gx4L3uM182jEEayZfRbI/G11ZpYdNAnBs70lFVMNdHJI76XYtR+7m0MN+eAs7UHBPhWXcnFPaS+9owSCJQHNpQ==}
|
resolution: {integrity: sha512-Gx4L3uM182jEEayZfRbI/G11ZpYdNAnBs70lFVMNdHJI76XYtR+7m0MN+eAs7UHBPhWXcnFPaS+9owSCJQHNpQ==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
|
@ -11,8 +11,13 @@ import {
|
|||||||
import { useRangeKeyDown } from '$app/components/document/BlockSelection/RangeKeyDown.hooks';
|
import { useRangeKeyDown } from '$app/components/document/BlockSelection/RangeKeyDown.hooks';
|
||||||
import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks';
|
import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks';
|
||||||
import { useSubscribeRanges } from '$app/components/document/_shared/SubscribeSelection.hooks';
|
import { useSubscribeRanges } from '$app/components/document/_shared/SubscribeSelection.hooks';
|
||||||
|
import { isApple } from '$app/utils/env';
|
||||||
|
|
||||||
|
const onFrameTime = 1000 / 60;
|
||||||
|
|
||||||
export function useBlockRangeSelection(container: HTMLDivElement) {
|
export function useBlockRangeSelection(container: HTMLDivElement) {
|
||||||
|
const timeStampRef = useRef(0);
|
||||||
|
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const onKeyDown = useRangeKeyDown();
|
const onKeyDown = useRangeKeyDown();
|
||||||
const { docId } = useSubscribeDocument();
|
const { docId } = useSubscribeDocument();
|
||||||
@ -36,10 +41,12 @@ export function useBlockRangeSelection(container: HTMLDivElement) {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!range) return;
|
if (!range) return;
|
||||||
const { anchor, focus } = range;
|
const { anchor, focus } = range;
|
||||||
|
|
||||||
if (!anchor || !focus) {
|
if (!anchor || !focus) {
|
||||||
container.classList.remove('caret-transparent');
|
container.classList.remove('caret-transparent');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// if the focus block is different from the anchor block, we need to set the caret transparent
|
// if the focus block is different from the anchor block, we need to set the caret transparent
|
||||||
if (focus.id !== anchor.id) {
|
if (focus.id !== anchor.id) {
|
||||||
container.classList.add('caret-transparent');
|
container.classList.add('caret-transparent');
|
||||||
@ -50,18 +57,21 @@ export function useBlockRangeSelection(container: HTMLDivElement) {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const anchor = anchorRef.current;
|
const anchor = anchorRef.current;
|
||||||
|
|
||||||
if (!anchor || !focus) return;
|
if (!anchor || !focus) return;
|
||||||
const selection = window.getSelection();
|
const selection = window.getSelection();
|
||||||
|
|
||||||
if (!selection) return;
|
if (!selection) return;
|
||||||
// update focus point
|
// update focus point
|
||||||
dispatch(
|
dispatch(
|
||||||
rangeActions.setFocusPoint({
|
rangeActions.setFocusPoint({
|
||||||
...focus,
|
focusPoint: focus,
|
||||||
docId,
|
docId,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
const focused = isFocused(focus.id);
|
const focused = isFocused(focus.id);
|
||||||
|
|
||||||
// if the focus block is not focused, we need to set the cursor position
|
// if the focus block is not focused, we need to set the cursor position
|
||||||
if (!focused) {
|
if (!focused) {
|
||||||
// if the focus block is the same as the anchor block, we just update the anchor's range
|
// if the focus block is the same as the anchor block, we just update the anchor's range
|
||||||
@ -70,14 +80,17 @@ export function useBlockRangeSelection(container: HTMLDivElement) {
|
|||||||
anchor.point.x - container.scrollLeft,
|
anchor.point.x - container.scrollLeft,
|
||||||
anchor.point.y - container.scrollTop
|
anchor.point.y - container.scrollTop
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!range) return;
|
if (!range) return;
|
||||||
const selection = window.getSelection();
|
const selection = window.getSelection();
|
||||||
|
|
||||||
selection?.removeAllRanges();
|
selection?.removeAllRanges();
|
||||||
selection?.addRange(range);
|
selection?.addRange(range);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const node = getNodeTextBoxByBlockId(focus.id);
|
const node = getNodeTextBoxByBlockId(focus.id);
|
||||||
|
|
||||||
if (!node) return;
|
if (!node) return;
|
||||||
// if the selection is forward, we set the cursor position to the start of the focus block
|
// if the selection is forward, we set the cursor position to the start of the focus block
|
||||||
if (isForward) {
|
if (isForward) {
|
||||||
@ -89,15 +102,33 @@ export function useBlockRangeSelection(container: HTMLDivElement) {
|
|||||||
}
|
}
|
||||||
}, [container, dispatch, docId, focus, isForward]);
|
}, [container, dispatch, docId, focus, isForward]);
|
||||||
|
|
||||||
const handleDragStart = useCallback(
|
const handleDragEnd = useCallback(() => {
|
||||||
|
timeStampRef.current = Date.now();
|
||||||
|
if (!isDragging) return;
|
||||||
|
setFocus(null);
|
||||||
|
anchorRef.current = null;
|
||||||
|
dispatch(
|
||||||
|
rangeActions.setDragging({
|
||||||
|
isDragging: false,
|
||||||
|
docId,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}, [docId, dispatch, isDragging]);
|
||||||
|
|
||||||
|
const handleMouseDown = useCallback(
|
||||||
(e: MouseEvent) => {
|
(e: MouseEvent) => {
|
||||||
setForward(true);
|
const isTapToClick = isApple() && timeStampRef.current > 0 && Date.now() - timeStampRef.current < onFrameTime;
|
||||||
|
|
||||||
// skip if the target is not a block
|
// skip if the target is not a block
|
||||||
const blockId = getBlockIdByPoint(e.target as HTMLElement);
|
const blockId = getBlockIdByPoint(e.target as HTMLElement);
|
||||||
|
|
||||||
if (!blockId) {
|
if (!blockId) {
|
||||||
dispatch(rangeActions.initialState(docId));
|
dispatch(rangeActions.initialState(docId));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setForward(true);
|
||||||
|
|
||||||
dispatch(rangeActions.clearRanges({ docId, exclude: blockId }));
|
dispatch(rangeActions.clearRanges({ docId, exclude: blockId }));
|
||||||
const startX = e.clientX + container.scrollLeft;
|
const startX = e.clientX + container.scrollLeft;
|
||||||
const startY = e.clientY + container.scrollTop;
|
const startY = e.clientY + container.scrollTop;
|
||||||
@ -113,18 +144,25 @@ export function useBlockRangeSelection(container: HTMLDivElement) {
|
|||||||
anchorRef.current = {
|
anchorRef.current = {
|
||||||
...anchor,
|
...anchor,
|
||||||
};
|
};
|
||||||
|
|
||||||
// set the anchor point and focus point
|
// set the anchor point and focus point
|
||||||
dispatch(rangeActions.setAnchorPoint({ ...anchor, docId }));
|
dispatch(rangeActions.setAnchorPoint({ docId, anchorPoint: anchor }));
|
||||||
dispatch(rangeActions.setFocusPoint({ ...anchor, docId }));
|
dispatch(rangeActions.setFocusPoint({ docId, focusPoint: anchor }));
|
||||||
|
|
||||||
|
// This is a workaround for a bug in Safari where the mouseup event is not fired
|
||||||
|
if (isTapToClick) {
|
||||||
|
handleDragEnd();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
dispatch(
|
dispatch(
|
||||||
rangeActions.setDragging({
|
rangeActions.setDragging({
|
||||||
isDragging: true,
|
isDragging: true,
|
||||||
docId,
|
docId,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
return;
|
|
||||||
},
|
},
|
||||||
[container.scrollLeft, container.scrollTop, dispatch, docId]
|
[container.scrollLeft, container.scrollTop, dispatch, docId, handleDragEnd]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleDraging = useCallback(
|
const handleDraging = useCallback(
|
||||||
@ -133,12 +171,14 @@ export function useBlockRangeSelection(container: HTMLDivElement) {
|
|||||||
|
|
||||||
// skip if the target is not a block
|
// skip if the target is not a block
|
||||||
const blockId = getBlockIdByPoint(e.target as HTMLElement);
|
const blockId = getBlockIdByPoint(e.target as HTMLElement);
|
||||||
|
|
||||||
if (!blockId) {
|
if (!blockId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const endX = e.clientX + container.scrollLeft;
|
const endX = e.clientX + container.scrollLeft;
|
||||||
const endY = e.clientY + container.scrollTop;
|
const endY = e.clientY + container.scrollTop;
|
||||||
|
|
||||||
// set the focus point
|
// set the focus point
|
||||||
setFocus({
|
setFocus({
|
||||||
id: blockId,
|
id: blockId,
|
||||||
@ -149,42 +189,35 @@ export function useBlockRangeSelection(container: HTMLDivElement) {
|
|||||||
});
|
});
|
||||||
// set forward
|
// set forward
|
||||||
const anchorId = anchorRef.current.id;
|
const anchorId = anchorRef.current.id;
|
||||||
|
|
||||||
if (anchorId === blockId) {
|
if (anchorId === blockId) {
|
||||||
const startX = anchorRef.current.point.x;
|
const startX = anchorRef.current.point.x;
|
||||||
|
|
||||||
setForward(startX < endX);
|
setForward(startX < endX);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const startY = anchorRef.current.point.y;
|
const startY = anchorRef.current.point.y;
|
||||||
|
|
||||||
setForward(startY < endY);
|
setForward(startY < endY);
|
||||||
},
|
},
|
||||||
[container.scrollLeft, container.scrollTop, isDragging]
|
[container.scrollLeft, container.scrollTop, isDragging]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleDragEnd = useCallback(() => {
|
|
||||||
if (!isDragging) return;
|
|
||||||
setFocus(null);
|
|
||||||
anchorRef.current = null;
|
|
||||||
dispatch(
|
|
||||||
rangeActions.setDragging({
|
|
||||||
isDragging: false,
|
|
||||||
docId,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}, [docId, dispatch, isDragging]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
document.addEventListener('mousedown', handleDragStart);
|
document.addEventListener('mousedown', handleMouseDown);
|
||||||
document.addEventListener('mousemove', handleDraging);
|
document.addEventListener('mousemove', handleDraging);
|
||||||
document.addEventListener('mouseup', handleDragEnd);
|
document.addEventListener('mouseup', handleDragEnd);
|
||||||
|
|
||||||
container.addEventListener('keydown', onKeyDown, true);
|
container.addEventListener('keydown', onKeyDown, true);
|
||||||
return () => {
|
return () => {
|
||||||
document.removeEventListener('mousedown', handleDragStart);
|
document.removeEventListener('mousedown', handleMouseDown);
|
||||||
document.removeEventListener('mousemove', handleDraging);
|
document.removeEventListener('mousemove', handleDraging);
|
||||||
document.removeEventListener('mouseup', handleDragEnd);
|
document.removeEventListener('mouseup', handleDragEnd);
|
||||||
|
|
||||||
container.removeEventListener('keydown', onKeyDown, true);
|
container.removeEventListener('keydown', onKeyDown, true);
|
||||||
};
|
};
|
||||||
}, [handleDragStart, handleDragEnd, handleDraging, container, onKeyDown]);
|
}, [handleMouseDown, handleDragEnd, handleDraging, container, onKeyDown]);
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
@ -10,6 +10,7 @@ import ToolbarButton from './ToolbarButton';
|
|||||||
import { rectSelectionActions } from '$app_reducers/document/slice';
|
import { rectSelectionActions } from '$app_reducers/document/slice';
|
||||||
import { addBlockBelowClickThunk } from '$app_reducers/document/async-actions/menu';
|
import { addBlockBelowClickThunk } from '$app_reducers/document/async-actions/menu';
|
||||||
import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks';
|
import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks';
|
||||||
|
import { RANGE_NAME, RECT_RANGE_NAME } from '$app/constants/document/name';
|
||||||
|
|
||||||
export default function BlockSideToolbar({ container }: { container: HTMLDivElement }) {
|
export default function BlockSideToolbar({ container }: { container: HTMLDivElement }) {
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
@ -17,7 +18,7 @@ export default function BlockSideToolbar({ container }: { container: HTMLDivElem
|
|||||||
|
|
||||||
const { nodeId, style, ref } = useBlockSideToolbar({ container });
|
const { nodeId, style, ref } = useBlockSideToolbar({ container });
|
||||||
const isDragging = useAppSelector(
|
const isDragging = useAppSelector(
|
||||||
(state) => state.documentRange[docId]?.isDragging || state.documentRectSelection[docId]?.isDragging
|
(state) => state[RANGE_NAME][docId]?.isDragging || state[RECT_RANGE_NAME][docId]?.isDragging
|
||||||
);
|
);
|
||||||
const { handleOpen, ...popoverProps } = usePopover();
|
const { handleOpen, ...popoverProps } = usePopover();
|
||||||
|
|
||||||
|
@ -7,6 +7,7 @@ import { useCopy } from '$app/components/document/_shared/CopyPasteHooks/useCopy
|
|||||||
import { usePaste } from '$app/components/document/_shared/CopyPasteHooks/usePaste';
|
import { usePaste } from '$app/components/document/_shared/CopyPasteHooks/usePaste';
|
||||||
import LinkEditPopover from '$app/components/document/_shared/TextLink/LinkEditPopover';
|
import LinkEditPopover from '$app/components/document/_shared/TextLink/LinkEditPopover';
|
||||||
import { useUndoRedo } from '$app/components/document/_shared/UndoHooks/useUndoRedo';
|
import { useUndoRedo } from '$app/components/document/_shared/UndoHooks/useUndoRedo';
|
||||||
|
import TemporaryPopover from '$app/components/document/_shared/TemporaryInput/TemporaryPopover';
|
||||||
|
|
||||||
export default function Overlay({ container }: { container: HTMLDivElement }) {
|
export default function Overlay({ container }: { container: HTMLDivElement }) {
|
||||||
useCopy(container);
|
useCopy(container);
|
||||||
@ -19,6 +20,7 @@ export default function Overlay({ container }: { container: HTMLDivElement }) {
|
|||||||
<BlockSelection container={container} />
|
<BlockSelection container={container} />
|
||||||
<BlockSlash container={container} />
|
<BlockSlash container={container} />
|
||||||
<LinkEditPopover />
|
<LinkEditPopover />
|
||||||
|
<TemporaryPopover />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -0,0 +1,31 @@
|
|||||||
|
import { TextAction, TextActionMenuProps } from '$app/interfaces/document';
|
||||||
|
|
||||||
|
export const defaultTextActionItems = [
|
||||||
|
TextAction.Turn,
|
||||||
|
TextAction.Link,
|
||||||
|
TextAction.Bold,
|
||||||
|
TextAction.Italic,
|
||||||
|
TextAction.Underline,
|
||||||
|
TextAction.Strikethrough,
|
||||||
|
TextAction.Code,
|
||||||
|
TextAction.Equation,
|
||||||
|
];
|
||||||
|
const groupKeys = {
|
||||||
|
comment: [],
|
||||||
|
format: [
|
||||||
|
TextAction.Bold,
|
||||||
|
TextAction.Italic,
|
||||||
|
TextAction.Underline,
|
||||||
|
TextAction.Strikethrough,
|
||||||
|
TextAction.Code,
|
||||||
|
TextAction.Equation,
|
||||||
|
],
|
||||||
|
link: [TextAction.Link],
|
||||||
|
turn: [TextAction.Turn],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const multiLineTextActionProps: TextActionMenuProps = {
|
||||||
|
customItems: [TextAction.Bold, TextAction.Italic, TextAction.Underline, TextAction.Strikethrough, TextAction.Code],
|
||||||
|
};
|
||||||
|
export const multiLineTextActionGroups = [groupKeys.format];
|
||||||
|
export const textActionGroups = [groupKeys.turn, groupKeys.format, groupKeys.link];
|
@ -1,6 +1,5 @@
|
|||||||
import { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { calcToolbarPosition } from '$app/utils/document/toolbar';
|
import { calcToolbarPosition } from '$app/utils/document/toolbar';
|
||||||
import { useAppSelector } from '$app/stores/store';
|
|
||||||
import { getNode } from '$app/utils/document/node';
|
import { getNode } from '$app/utils/document/node';
|
||||||
import { debounce } from '$app/utils/tool';
|
import { debounce } from '$app/utils/tool';
|
||||||
import { useSubscribeCaret } from '$app/components/document/_shared/SubscribeSelection.hooks';
|
import { useSubscribeCaret } from '$app/components/document/_shared/SubscribeSelection.hooks';
|
||||||
@ -15,9 +14,11 @@ export function useMenuStyle(container: HTMLDivElement) {
|
|||||||
|
|
||||||
const reCalculatePosition = useCallback(() => {
|
const reCalculatePosition = useCallback(() => {
|
||||||
const el = ref.current;
|
const el = ref.current;
|
||||||
|
|
||||||
if (!el || !id) return;
|
if (!el || !id) return;
|
||||||
|
|
||||||
const node = getNode(id);
|
const node = getNode(id);
|
||||||
|
|
||||||
if (!node) return;
|
if (!node) return;
|
||||||
const position = calcToolbarPosition(el, node, container);
|
const position = calcToolbarPosition(el, node, container);
|
||||||
|
|
||||||
@ -50,6 +51,7 @@ export function useMenuStyle(container: HTMLDivElement) {
|
|||||||
setIsScrolling(true);
|
setIsScrolling(true);
|
||||||
debounceScrollEnd();
|
debounceScrollEnd();
|
||||||
};
|
};
|
||||||
|
|
||||||
container.addEventListener('scroll', handleScroll);
|
container.addEventListener('scroll', handleScroll);
|
||||||
return () => {
|
return () => {
|
||||||
debounceScrollEnd.cancel();
|
debounceScrollEnd.cancel();
|
||||||
|
@ -27,10 +27,12 @@ const TextActionComponent = ({ container }: { container: HTMLDivElement }) => {
|
|||||||
</BlockPortal>
|
</BlockPortal>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const TextActionMenu = ({ container }: { container: HTMLDivElement }) => {
|
const TextActionMenu = ({ container }: { container: HTMLDivElement }) => {
|
||||||
const range = useSubscribeRanges();
|
const range = useSubscribeRanges();
|
||||||
const canShow = useMemo(() => {
|
const canShow = useMemo(() => {
|
||||||
const { isDragging, focus, anchor, ranges, caret } = range;
|
const { isDragging, focus, anchor, ranges, caret } = range;
|
||||||
|
|
||||||
// don't show if dragging
|
// don't show if dragging
|
||||||
if (isDragging) return false;
|
if (isDragging) return false;
|
||||||
// don't show if no focus or anchor
|
// don't show if no focus or anchor
|
||||||
@ -39,9 +41,10 @@ const TextActionMenu = ({ container }: { container: HTMLDivElement }) => {
|
|||||||
|
|
||||||
// show toolbar if range has multiple nodes
|
// show toolbar if range has multiple nodes
|
||||||
if (!isSameLine) return true;
|
if (!isSameLine) return true;
|
||||||
const caretRange = ranges[caret.id];
|
const caretRange = ranges?.[caret.id];
|
||||||
// don't show if no caret range
|
|
||||||
if (!caretRange) return false;
|
if (!caretRange) return false;
|
||||||
|
|
||||||
// show toolbar if range is not collapsed
|
// show toolbar if range is not collapsed
|
||||||
return caretRange.length > 0;
|
return caretRange.length > 0;
|
||||||
}, [range]);
|
}, [range]);
|
||||||
|
@ -1,37 +1,36 @@
|
|||||||
import IconButton from '@mui/material/IconButton';
|
import IconButton from '@mui/material/IconButton';
|
||||||
import FormatIcon from './FormatIcon';
|
import React, { useCallback, useEffect, useMemo } from 'react';
|
||||||
import React, { useCallback, useEffect, useMemo, useContext } from 'react';
|
import { TemporaryType, TextAction } from '$app/interfaces/document';
|
||||||
import { TextAction } from '$app/interfaces/document';
|
|
||||||
import MenuTooltip from '$app/components/document/TextActionMenu/menu/MenuTooltip';
|
import MenuTooltip from '$app/components/document/TextActionMenu/menu/MenuTooltip';
|
||||||
import { getFormatActiveThunk, toggleFormatThunk } from '$app_reducers/document/async-actions/format';
|
import { getFormatActiveThunk, toggleFormatThunk } from '$app_reducers/document/async-actions/format';
|
||||||
import { useAppDispatch, useAppSelector } from '$app/stores/store';
|
import { useAppDispatch, useAppSelector } from '$app/stores/store';
|
||||||
import { DocumentControllerContext } from '$app/stores/effects/document/document_controller';
|
|
||||||
import { useSubscribeNode } from '$app/components/document/_shared/SubscribeNode.hooks';
|
import { useSubscribeNode } from '$app/components/document/_shared/SubscribeNode.hooks';
|
||||||
import { newLinkThunk } from '$app_reducers/document/async-actions/link';
|
import { newLinkThunk } from '$app_reducers/document/async-actions/link';
|
||||||
import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks';
|
import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks';
|
||||||
|
import { RANGE_NAME } from '$app/constants/document/name';
|
||||||
|
import { createTemporary } from '$app_reducers/document/async-actions/temporary';
|
||||||
|
import {
|
||||||
|
CodeOutlined,
|
||||||
|
FormatBold,
|
||||||
|
FormatItalic,
|
||||||
|
FormatUnderlined,
|
||||||
|
Functions,
|
||||||
|
StrikethroughSOutlined,
|
||||||
|
} from '@mui/icons-material';
|
||||||
|
import LinkIcon from '@mui/icons-material/AddLink';
|
||||||
|
|
||||||
|
export const iconSize = { width: 18, height: 18 };
|
||||||
|
|
||||||
const FormatButton = ({ format, icon }: { format: TextAction; icon: string }) => {
|
const FormatButton = ({ format, icon }: { format: TextAction; icon: string }) => {
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const { docId, controller } = useSubscribeDocument();
|
const { docId, controller } = useSubscribeDocument();
|
||||||
|
|
||||||
const focusId = useAppSelector((state) => state.documentRange[docId]?.focus?.id || '');
|
const focusId = useAppSelector((state) => state[RANGE_NAME][docId]?.focus?.id || '');
|
||||||
const { node: focusNode } = useSubscribeNode(focusId);
|
const { node: focusNode } = useSubscribeNode(focusId);
|
||||||
|
|
||||||
const [isActive, setIsActive] = React.useState(false);
|
const [isActive, setIsActive] = React.useState(false);
|
||||||
const color = useMemo(() => (isActive ? '#00BCF0' : 'white'), [isActive]);
|
const color = useMemo(() => (isActive ? '#00BCF0' : 'white'), [isActive]);
|
||||||
|
|
||||||
const formatTooltips: Record<string, string> = useMemo(
|
|
||||||
() => ({
|
|
||||||
[TextAction.Bold]: 'Bold',
|
|
||||||
[TextAction.Italic]: 'Italic',
|
|
||||||
[TextAction.Underline]: 'Underline',
|
|
||||||
[TextAction.Strikethrough]: 'Strike through',
|
|
||||||
[TextAction.Code]: 'Mark as Code',
|
|
||||||
[TextAction.Link]: 'Add Link',
|
|
||||||
}),
|
|
||||||
[]
|
|
||||||
);
|
|
||||||
|
|
||||||
const isFormatActive = useCallback(async () => {
|
const isFormatActive = useCallback(async () => {
|
||||||
if (!focusNode) return false;
|
if (!focusNode) return false;
|
||||||
const { payload: isActive } = await dispatch(
|
const { payload: isActive } = await dispatch(
|
||||||
@ -40,6 +39,7 @@ const FormatButton = ({ format, icon }: { format: TextAction; icon: string }) =>
|
|||||||
docId,
|
docId,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
return !!isActive;
|
return !!isActive;
|
||||||
}, [docId, dispatch, format, focusNode]);
|
}, [docId, dispatch, format, focusNode]);
|
||||||
|
|
||||||
@ -65,6 +65,34 @@ const FormatButton = ({ format, icon }: { format: TextAction; icon: string }) =>
|
|||||||
);
|
);
|
||||||
}, [dispatch, docId]);
|
}, [dispatch, docId]);
|
||||||
|
|
||||||
|
const addTemporaryInput = useCallback(
|
||||||
|
(type: TemporaryType) => {
|
||||||
|
dispatch(createTemporary({ type, docId }));
|
||||||
|
},
|
||||||
|
[dispatch, docId]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void (async () => {
|
||||||
|
const isActive = await isFormatActive();
|
||||||
|
|
||||||
|
setIsActive(isActive);
|
||||||
|
})();
|
||||||
|
}, [isFormatActive]);
|
||||||
|
|
||||||
|
const formatTooltips: Record<string, string> = useMemo(
|
||||||
|
() => ({
|
||||||
|
[TextAction.Bold]: 'Bold',
|
||||||
|
[TextAction.Italic]: 'Italic',
|
||||||
|
[TextAction.Underline]: 'Underline',
|
||||||
|
[TextAction.Strikethrough]: 'Strike through',
|
||||||
|
[TextAction.Code]: 'Mark as Code',
|
||||||
|
[TextAction.Link]: 'Add Link',
|
||||||
|
[TextAction.Equation]: 'Create equation',
|
||||||
|
}),
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
const formatClick = useCallback(
|
const formatClick = useCallback(
|
||||||
(format: TextAction) => {
|
(format: TextAction) => {
|
||||||
switch (format) {
|
switch (format) {
|
||||||
@ -76,22 +104,48 @@ const FormatButton = ({ format, icon }: { format: TextAction; icon: string }) =>
|
|||||||
return toggleFormat(format);
|
return toggleFormat(format);
|
||||||
case TextAction.Link:
|
case TextAction.Link:
|
||||||
return addLink();
|
return addLink();
|
||||||
|
case TextAction.Equation:
|
||||||
|
return addTemporaryInput(TemporaryType.Equation);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[addLink, toggleFormat]
|
[addLink, addTemporaryInput, toggleFormat]
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
const formatIcon = useMemo(() => {
|
||||||
void (async () => {
|
switch (icon) {
|
||||||
const isActive = await isFormatActive();
|
case TextAction.Bold:
|
||||||
setIsActive(isActive);
|
return <FormatBold sx={iconSize} />;
|
||||||
})();
|
case TextAction.Underline:
|
||||||
}, [isFormatActive]);
|
return <FormatUnderlined sx={iconSize} />;
|
||||||
|
case TextAction.Italic:
|
||||||
|
return <FormatItalic sx={iconSize} />;
|
||||||
|
case TextAction.Code:
|
||||||
|
return <CodeOutlined sx={iconSize} />;
|
||||||
|
case TextAction.Strikethrough:
|
||||||
|
return <StrikethroughSOutlined sx={iconSize} />;
|
||||||
|
case TextAction.Link:
|
||||||
|
return (
|
||||||
|
<div className={'flex items-center justify-center px-1 text-[0.8rem]'}>
|
||||||
|
<LinkIcon
|
||||||
|
sx={{
|
||||||
|
fontSize: '1.2rem',
|
||||||
|
marginRight: '0.25rem',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className={'underline'}>Link</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
case TextAction.Equation:
|
||||||
|
return <Functions sx={iconSize} />;
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}, [icon]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MenuTooltip title={formatTooltips[format]}>
|
<MenuTooltip title={formatTooltips[format]}>
|
||||||
<IconButton size='small' sx={{ color }} onClick={() => formatClick(format)}>
|
<IconButton size='small' sx={{ color }} onClick={() => formatClick(format)}>
|
||||||
<FormatIcon icon={icon} />
|
{formatIcon}
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</MenuTooltip>
|
</MenuTooltip>
|
||||||
);
|
);
|
||||||
|
@ -1,34 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import { FormatBold, FormatUnderlined, FormatItalic, CodeOutlined, StrikethroughSOutlined } from '@mui/icons-material';
|
|
||||||
import { TextAction } from '$app/interfaces/document';
|
|
||||||
import LinkIcon from '@mui/icons-material/AddLink';
|
|
||||||
export const iconSize = { width: 18, height: 18 };
|
|
||||||
|
|
||||||
export default function FormatIcon({ icon }: { icon: string }) {
|
|
||||||
switch (icon) {
|
|
||||||
case TextAction.Bold:
|
|
||||||
return <FormatBold sx={iconSize} />;
|
|
||||||
case TextAction.Underline:
|
|
||||||
return <FormatUnderlined sx={iconSize} />;
|
|
||||||
case TextAction.Italic:
|
|
||||||
return <FormatItalic sx={iconSize} />;
|
|
||||||
case TextAction.Code:
|
|
||||||
return <CodeOutlined sx={iconSize} />;
|
|
||||||
case TextAction.Strikethrough:
|
|
||||||
return <StrikethroughSOutlined sx={iconSize} />;
|
|
||||||
case TextAction.Link:
|
|
||||||
return (
|
|
||||||
<div className={'flex items-center justify-center px-1 text-[0.8rem]'}>
|
|
||||||
<LinkIcon
|
|
||||||
sx={{
|
|
||||||
fontSize: '1.2rem',
|
|
||||||
marginRight: '0.25rem',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<div className={'underline'}>Link</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
default:
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,14 +1,13 @@
|
|||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
|
import { useSubscribeNode } from '$app/components/document/_shared/SubscribeNode.hooks';
|
||||||
|
import { BlockType, TextAction } from '$app/interfaces/document';
|
||||||
|
import { useSubscribeRanges } from '$app/components/document/_shared/SubscribeSelection.hooks';
|
||||||
import {
|
import {
|
||||||
blockConfig,
|
defaultTextActionItems,
|
||||||
defaultTextActionProps,
|
|
||||||
multiLineTextActionGroups,
|
multiLineTextActionGroups,
|
||||||
multiLineTextActionProps,
|
multiLineTextActionProps,
|
||||||
textActionGroups,
|
textActionGroups,
|
||||||
} from '$app/constants/document/config';
|
} from '$app/components/document/TextActionMenu/config';
|
||||||
import { useSubscribeNode } from '$app/components/document/_shared/SubscribeNode.hooks';
|
|
||||||
import { TextAction } from '$app/interfaces/document';
|
|
||||||
import { useSubscribeRanges } from '$app/components/document/_shared/SubscribeSelection.hooks';
|
|
||||||
|
|
||||||
export function useTextActionMenu() {
|
export function useTextActionMenu() {
|
||||||
const range = useSubscribeRanges();
|
const range = useSubscribeRanges();
|
||||||
@ -22,12 +21,9 @@ export function useTextActionMenu() {
|
|||||||
const items = useMemo(() => {
|
const items = useMemo(() => {
|
||||||
if (!node) return [];
|
if (!node) return [];
|
||||||
if (isSingleLine) {
|
if (isSingleLine) {
|
||||||
const config = blockConfig[node.type];
|
const excludeItems = node.type === BlockType.CodeBlock ? [TextAction.Code] : [];
|
||||||
const { customItems, excludeItems } = {
|
|
||||||
...defaultTextActionProps,
|
return defaultTextActionItems?.filter((item) => !excludeItems?.includes(item)) || [];
|
||||||
...config.textActionMenuProps,
|
|
||||||
};
|
|
||||||
return customItems?.filter((item) => !excludeItems?.includes(item)) || [];
|
|
||||||
} else {
|
} else {
|
||||||
return multiLineTextActionProps.customItems || [];
|
return multiLineTextActionProps.customItems || [];
|
||||||
}
|
}
|
||||||
@ -36,6 +32,7 @@ export function useTextActionMenu() {
|
|||||||
// the groups have default items, so we need to filter the items if this node has excluded items
|
// the groups have default items, so we need to filter the items if this node has excluded items
|
||||||
const groupItems: TextAction[][] = useMemo(() => {
|
const groupItems: TextAction[][] = useMemo(() => {
|
||||||
const groups = node ? textActionGroups : multiLineTextActionGroups;
|
const groups = node ? textActionGroups : multiLineTextActionGroups;
|
||||||
|
|
||||||
return groups.map((group) => {
|
return groups.map((group) => {
|
||||||
return group.filter((item) => items.includes(item));
|
return group.filter((item) => items.includes(item));
|
||||||
});
|
});
|
||||||
|
@ -17,6 +17,7 @@ function TextActionMenuList() {
|
|||||||
case TextAction.Underline:
|
case TextAction.Underline:
|
||||||
case TextAction.Strikethrough:
|
case TextAction.Strikethrough:
|
||||||
case TextAction.Code:
|
case TextAction.Code:
|
||||||
|
case TextAction.Equation:
|
||||||
return <FormatButton format={action} icon={action} />;
|
return <FormatButton format={action} icon={action} />;
|
||||||
default:
|
default:
|
||||||
return null;
|
return null;
|
||||||
|
@ -0,0 +1,75 @@
|
|||||||
|
import { Keyboard } from '$app/constants/document/keyboard';
|
||||||
|
import { BlockType } from '$app/interfaces/document';
|
||||||
|
|
||||||
|
export const turnIntoShortcuts = {
|
||||||
|
[Keyboard.keys.SPACE]: [
|
||||||
|
{
|
||||||
|
type: BlockType.HeadingBlock,
|
||||||
|
/**
|
||||||
|
* # or ## or ###
|
||||||
|
*/
|
||||||
|
markdownRegexp: /^(#{1,3})(\s)+$/,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: BlockType.TodoListBlock,
|
||||||
|
/**
|
||||||
|
* -[] or -[x] or -[ ] or [] or [x] or [ ]
|
||||||
|
*/
|
||||||
|
markdownRegexp: /^((-)?\[(x|\s)?\])(\s)+$/,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: BlockType.BulletedListBlock,
|
||||||
|
/**
|
||||||
|
* - or + or *
|
||||||
|
*/
|
||||||
|
markdownRegexp: /^(\s*[-+*])(\s)+$/,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: BlockType.NumberedListBlock,
|
||||||
|
/**
|
||||||
|
* 1. or 2. or 3.
|
||||||
|
* a. or b. or c.
|
||||||
|
*/
|
||||||
|
markdownRegexp: /^(\s*[\d|a-zA-Z]+\.)(\s)+$/,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: BlockType.QuoteBlock,
|
||||||
|
/**
|
||||||
|
* " or “ or ”
|
||||||
|
*/
|
||||||
|
markdownRegexp: /^("|“|”)(\s)+$/,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: BlockType.CalloutBlock,
|
||||||
|
/**
|
||||||
|
* [!TIP] or [!INFO] or [!WARNING] or [!DANGER]
|
||||||
|
*/
|
||||||
|
markdownRegexp: /^(\[!)(TIP|INFO|WARNING|DANGER)(\])(\s)+$/,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: BlockType.ToggleListBlock,
|
||||||
|
/**
|
||||||
|
* >
|
||||||
|
*/
|
||||||
|
markdownRegexp: /^(>)(\s)+$/,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[Keyboard.keys.BACK_QUOTE]: [
|
||||||
|
{
|
||||||
|
type: BlockType.CodeBlock,
|
||||||
|
/**
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
markdownRegexp: /^(```)$/,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[Keyboard.keys.REDUCE]: [
|
||||||
|
{
|
||||||
|
type: BlockType.DividerBlock,
|
||||||
|
/**
|
||||||
|
* ---
|
||||||
|
*/
|
||||||
|
markdownRegexp: /^(-{3,})$/,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
@ -1,8 +1,7 @@
|
|||||||
import { useCallback, useContext, useMemo } from 'react';
|
import { useCallback, useMemo } from 'react';
|
||||||
import { Keyboard } from '$app/constants/document/keyboard';
|
import { Keyboard } from '$app/constants/document/keyboard';
|
||||||
import isHotkey from 'is-hotkey';
|
import isHotkey from 'is-hotkey';
|
||||||
import { useAppDispatch } from '@/appflowy_app/stores/store';
|
import { useAppDispatch } from '@/appflowy_app/stores/store';
|
||||||
import { DocumentControllerContext } from '$app/stores/effects/document/document_controller';
|
|
||||||
import {
|
import {
|
||||||
enterActionForBlockThunk,
|
enterActionForBlockThunk,
|
||||||
tabActionForBlockThunk,
|
tabActionForBlockThunk,
|
||||||
@ -90,6 +89,7 @@ export function useKeyDown(id: string) {
|
|||||||
const onKeyDown = useCallback(
|
const onKeyDown = useCallback(
|
||||||
(e: React.KeyboardEvent<HTMLDivElement>) => {
|
(e: React.KeyboardEvent<HTMLDivElement>) => {
|
||||||
const filteredEvents = interceptEvents.filter((event) => event.canHandle(e));
|
const filteredEvents = interceptEvents.filter((event) => event.canHandle(e));
|
||||||
|
|
||||||
filteredEvents.forEach((event) => {
|
filteredEvents.forEach((event) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
event.handler(e);
|
event.handler(e);
|
||||||
|
@ -1,7 +1,6 @@
|
|||||||
import { useCallback, useContext, useMemo } from 'react';
|
import { useCallback, useMemo } from 'react';
|
||||||
import { BlockType } from '$app/interfaces/document';
|
import { BlockType } from '$app/interfaces/document';
|
||||||
import { useAppDispatch } from '$app/stores/store';
|
import { useAppDispatch } from '$app/stores/store';
|
||||||
import { DocumentControllerContext } from '$app/stores/effects/document/document_controller';
|
|
||||||
import { turnToBlockThunk } from '$app_reducers/document/async-actions';
|
import { turnToBlockThunk } from '$app_reducers/document/async-actions';
|
||||||
import { blockConfig } from '$app/constants/document/config';
|
import { blockConfig } from '$app/constants/document/config';
|
||||||
|
|
||||||
@ -10,9 +9,9 @@ import { useRangeRef } from '$app/components/document/_shared/SubscribeSelection
|
|||||||
import { getBlock } from '$app/components/document/_shared/SubscribeNode.hooks';
|
import { getBlock } from '$app/components/document/_shared/SubscribeNode.hooks';
|
||||||
import isHotkey from 'is-hotkey';
|
import isHotkey from 'is-hotkey';
|
||||||
import { slashCommandActions } from '$app_reducers/document/slice';
|
import { slashCommandActions } from '$app_reducers/document/slice';
|
||||||
import { Keyboard } from '$app/constants/document/keyboard';
|
|
||||||
import { getDeltaText } from '$app/utils/document/delta';
|
import { getDeltaText } from '$app/utils/document/delta';
|
||||||
import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks';
|
import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks';
|
||||||
|
import { turnIntoShortcuts } from './shortchut';
|
||||||
|
|
||||||
export function useTurnIntoBlockEvents(id: string) {
|
export function useTurnIntoBlockEvents(id: string) {
|
||||||
const { docId, controller } = useSubscribeDocument();
|
const { docId, controller } = useSubscribeDocument();
|
||||||
@ -22,27 +21,35 @@ export function useTurnIntoBlockEvents(id: string) {
|
|||||||
|
|
||||||
const getFlag = useCallback(() => {
|
const getFlag = useCallback(() => {
|
||||||
const range = rangeRef.current?.caret;
|
const range = rangeRef.current?.caret;
|
||||||
|
|
||||||
if (!range || range.id !== id) return;
|
if (!range || range.id !== id) return;
|
||||||
const node = getBlock(docId, id);
|
const node = getBlock(docId, id);
|
||||||
const delta = new Delta(node.data.delta || []);
|
const delta = new Delta(node.data.delta || []);
|
||||||
|
|
||||||
return getDeltaText(delta.slice(0, range.index));
|
return getDeltaText(delta.slice(0, range.index));
|
||||||
}, [docId, id, rangeRef]);
|
}, [docId, id, rangeRef]);
|
||||||
|
|
||||||
const getDeltaContent = useCallback(() => {
|
const getDeltaContent = useCallback(() => {
|
||||||
const range = rangeRef.current?.caret;
|
const range = rangeRef.current?.caret;
|
||||||
|
|
||||||
if (!range || range.id !== id) return;
|
if (!range || range.id !== id) return;
|
||||||
const node = getBlock(docId, id);
|
const node = getBlock(docId, id);
|
||||||
const delta = new Delta(node.data.delta || []);
|
const delta = new Delta(node.data.delta || []);
|
||||||
const content = delta.slice(range.index);
|
const content = delta.slice(range.index);
|
||||||
|
|
||||||
return new Delta(content);
|
return new Delta(content);
|
||||||
}, [docId, id, rangeRef]);
|
}, [docId, id, rangeRef]);
|
||||||
|
|
||||||
const canHandle = useCallback(
|
const canHandle = useCallback(
|
||||||
(event: React.KeyboardEvent<HTMLDivElement>, type: BlockType, triggerKey: string) => {
|
(event: React.KeyboardEvent<HTMLDivElement>, type: BlockType) => {
|
||||||
{
|
{
|
||||||
const config = blockConfig[type];
|
const triggerKey = event.key;
|
||||||
|
const shortcutItem = turnIntoShortcuts[triggerKey]?.find((item) => item.type === type);
|
||||||
|
|
||||||
|
if (!shortcutItem) return false;
|
||||||
|
|
||||||
|
const regex = shortcutItem.markdownRegexp;
|
||||||
|
|
||||||
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
|
// This error will be thrown if the block type is not in the config, and it will happen in development environment
|
||||||
if (!regex) {
|
if (!regex) {
|
||||||
throw new Error(`canHandle: block type ${type} is not supported`);
|
throw new Error(`canHandle: block type ${type} is not supported`);
|
||||||
@ -53,10 +60,12 @@ export function useTurnIntoBlockEvents(id: string) {
|
|||||||
if (!isTrigger) {
|
if (!isTrigger) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const flag = getFlag();
|
const flag = getFlag();
|
||||||
|
|
||||||
if (!flag) return false;
|
if (!flag) return false;
|
||||||
|
|
||||||
return regex.some((r) => r.test(`${flag}${triggerKey}`));
|
return regex.test(`${flag}${triggerKey}`);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[getFlag]
|
[getFlag]
|
||||||
@ -64,6 +73,7 @@ export function useTurnIntoBlockEvents(id: string) {
|
|||||||
|
|
||||||
const getTurnIntoBlockDelta = useCallback(() => {
|
const getTurnIntoBlockDelta = useCallback(() => {
|
||||||
const content = getDeltaContent();
|
const content = getDeltaContent();
|
||||||
|
|
||||||
if (!content) return;
|
if (!content) return;
|
||||||
return {
|
return {
|
||||||
delta: content.ops,
|
delta: content.ops,
|
||||||
@ -74,8 +84,10 @@ export function useTurnIntoBlockEvents(id: string) {
|
|||||||
return {
|
return {
|
||||||
[BlockType.HeadingBlock]: () => {
|
[BlockType.HeadingBlock]: () => {
|
||||||
const flag = getFlag();
|
const flag = getFlag();
|
||||||
|
|
||||||
if (!flag) return;
|
if (!flag) return;
|
||||||
const level = flag.match(/#/g)?.length;
|
const level = flag.match(/#/g)?.length;
|
||||||
|
|
||||||
if (!level || level > 3) return;
|
if (!level || level > 3) return;
|
||||||
return {
|
return {
|
||||||
level,
|
level,
|
||||||
@ -84,6 +96,7 @@ export function useTurnIntoBlockEvents(id: string) {
|
|||||||
},
|
},
|
||||||
[BlockType.TodoListBlock]: () => {
|
[BlockType.TodoListBlock]: () => {
|
||||||
const flag = getFlag();
|
const flag = getFlag();
|
||||||
|
|
||||||
if (!flag) return;
|
if (!flag) return;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@ -97,8 +110,10 @@ export function useTurnIntoBlockEvents(id: string) {
|
|||||||
[BlockType.ToggleListBlock]: getTurnIntoBlockDelta,
|
[BlockType.ToggleListBlock]: getTurnIntoBlockDelta,
|
||||||
[BlockType.CalloutBlock]: () => {
|
[BlockType.CalloutBlock]: () => {
|
||||||
const flag = getFlag();
|
const flag = getFlag();
|
||||||
|
|
||||||
if (!flag) return;
|
if (!flag) return;
|
||||||
const tag = flag.match(/(TIP|INFO|WARNING|DANGER)/g)?.[0];
|
const tag = flag.match(/(TIP|INFO|WARNING|DANGER)/g)?.[0];
|
||||||
|
|
||||||
if (!tag) return;
|
if (!tag) return;
|
||||||
const iconMap: Record<string, string> = {
|
const iconMap: Record<string, string> = {
|
||||||
TIP: '💡',
|
TIP: '💡',
|
||||||
@ -106,6 +121,7 @@ export function useTurnIntoBlockEvents(id: string) {
|
|||||||
WARNING: '⚠️',
|
WARNING: '⚠️',
|
||||||
DANGER: '‼️',
|
DANGER: '‼️',
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
icon: iconMap[tag],
|
icon: iconMap[tag],
|
||||||
...getTurnIntoBlockDelta(),
|
...getTurnIntoBlockDelta(),
|
||||||
@ -117,24 +133,24 @@ export function useTurnIntoBlockEvents(id: string) {
|
|||||||
const turnIntoBlockEvents = useMemo(() => {
|
const turnIntoBlockEvents = useMemo(() => {
|
||||||
const spaceTriggerEvents = Object.entries(spaceTriggerMap).map(([type, getData]) => {
|
const spaceTriggerEvents = Object.entries(spaceTriggerMap).map(([type, getData]) => {
|
||||||
const blockType = type as BlockType;
|
const blockType = type as BlockType;
|
||||||
const triggerKey = Keyboard.keys.Space;
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
canHandle: (e: React.KeyboardEvent<HTMLDivElement>) => canHandle(e, blockType, triggerKey),
|
canHandle: (e: React.KeyboardEvent<HTMLDivElement>) => canHandle(e, blockType),
|
||||||
handler: (e: React.KeyboardEvent<HTMLDivElement>) => {
|
handler: (e: React.KeyboardEvent<HTMLDivElement>) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!controller) return;
|
if (!controller) return;
|
||||||
const data = getData();
|
const data = getData();
|
||||||
|
|
||||||
if (!data) return;
|
if (!data) return;
|
||||||
dispatch(turnToBlockThunk({ id, data, type: blockType, controller }));
|
dispatch(turnToBlockThunk({ id, data, type: blockType, controller }));
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
return [
|
return [
|
||||||
...spaceTriggerEvents,
|
...spaceTriggerEvents,
|
||||||
{
|
{
|
||||||
canHandle: (e: React.KeyboardEvent<HTMLDivElement>) =>
|
canHandle: (e: React.KeyboardEvent<HTMLDivElement>) => canHandle(e, BlockType.DividerBlock),
|
||||||
canHandle(e, BlockType.DividerBlock, Keyboard.keys.Reduce),
|
|
||||||
handler: (e: React.KeyboardEvent<HTMLDivElement>) => {
|
handler: (e: React.KeyboardEvent<HTMLDivElement>) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!controller) return;
|
if (!controller) return;
|
||||||
@ -153,8 +169,7 @@ export function useTurnIntoBlockEvents(id: string) {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
canHandle: (e: React.KeyboardEvent<HTMLDivElement>) =>
|
canHandle: (e: React.KeyboardEvent<HTMLDivElement>) => canHandle(e, BlockType.CodeBlock),
|
||||||
canHandle(e, BlockType.CodeBlock, Keyboard.keys.BackQuote),
|
|
||||||
handler: (e: React.KeyboardEvent<HTMLDivElement>) => {
|
handler: (e: React.KeyboardEvent<HTMLDivElement>) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!controller) return;
|
if (!controller) return;
|
||||||
@ -163,6 +178,7 @@ export function useTurnIntoBlockEvents(id: string) {
|
|||||||
...defaultData,
|
...defaultData,
|
||||||
delta: getDeltaContent()?.ops as Op[],
|
delta: getDeltaContent()?.ops as Op[],
|
||||||
};
|
};
|
||||||
|
|
||||||
dispatch(turnToBlockThunk({ id, data, type: BlockType.CodeBlock, controller }));
|
dispatch(turnToBlockThunk({ id, data, type: BlockType.CodeBlock, controller }));
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@ -170,6 +186,7 @@ export function useTurnIntoBlockEvents(id: string) {
|
|||||||
// Here custom slash key event for TextBlock
|
// Here custom slash key event for TextBlock
|
||||||
canHandle: (e: React.KeyboardEvent<HTMLDivElement>) => {
|
canHandle: (e: React.KeyboardEvent<HTMLDivElement>) => {
|
||||||
const flag = getFlag();
|
const flag = getFlag();
|
||||||
|
|
||||||
return isHotkey('/', e) && flag === '';
|
return isHotkey('/', e) && flag === '';
|
||||||
},
|
},
|
||||||
handler: (_: React.KeyboardEvent<HTMLDivElement>) => {
|
handler: (_: React.KeyboardEvent<HTMLDivElement>) => {
|
||||||
|
@ -61,19 +61,18 @@ export function useCommonKeyEvents(id: string) {
|
|||||||
{
|
{
|
||||||
// handle left arrow key and no other key is pressed
|
// handle left arrow key and no other key is pressed
|
||||||
canHandle: (e: React.KeyboardEvent<HTMLDivElement>) => {
|
canHandle: (e: React.KeyboardEvent<HTMLDivElement>) => {
|
||||||
return isHotkey(Keyboard.keys.LEFT, e) && caretRef.current?.index === 0 && caretRef.current?.length === 0;
|
return isHotkey(Keyboard.keys.LEFT, e);
|
||||||
},
|
},
|
||||||
handler: (e: React.KeyboardEvent<HTMLDivElement>) => {
|
handler: (e: React.KeyboardEvent<HTMLDivElement>) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
dispatch(leftActionForBlockThunk({ docId, id }));
|
dispatch(leftActionForBlockThunk({ docId, id }));
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
// handle right arrow key and no other key is pressed
|
// handle right arrow key and no other key is pressed
|
||||||
canHandle: (e: React.KeyboardEvent<HTMLDivElement>) => {
|
canHandle: (e: React.KeyboardEvent<HTMLDivElement>) => {
|
||||||
const block = getBlock(docId, id);
|
return isHotkey(Keyboard.keys.RIGHT, e);
|
||||||
const isEndOfBlock = caretRef.current?.index === new Delta(block.data.delta).length();
|
|
||||||
return isHotkey(Keyboard.keys.RIGHT, e) && isEndOfBlock && caretRef.current?.length === 0;
|
|
||||||
},
|
},
|
||||||
handler: (e: React.KeyboardEvent<HTMLDivElement>) => {
|
handler: (e: React.KeyboardEvent<HTMLDivElement>) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@ -86,6 +85,7 @@ export function useCommonKeyEvents(id: string) {
|
|||||||
handler: (e: React.KeyboardEvent<HTMLDivElement>) => {
|
handler: (e: React.KeyboardEvent<HTMLDivElement>) => {
|
||||||
if (!controller) return;
|
if (!controller) return;
|
||||||
const format = parseFormat(e);
|
const format = parseFormat(e);
|
||||||
|
|
||||||
if (!format) return;
|
if (!format) return;
|
||||||
dispatch(
|
dispatch(
|
||||||
toggleFormatThunk({
|
toggleFormatThunk({
|
||||||
@ -97,5 +97,6 @@ export function useCommonKeyEvents(id: string) {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
}, [docId, caretRef, controller, dispatch, focused, id]);
|
}, [docId, caretRef, controller, dispatch, focused, id]);
|
||||||
|
|
||||||
return commonKeyEvents;
|
return commonKeyEvents;
|
||||||
}
|
}
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useContext, useEffect, useState } from 'react';
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
import { RangeStatic } from 'quill';
|
import { RangeStatic } from 'quill';
|
||||||
import { useAppDispatch } from '$app/stores/store';
|
import { useAppDispatch } from '$app/stores/store';
|
||||||
import { rangeActions } from '$app_reducers/document/slice';
|
import { rangeActions } from '$app_reducers/document/slice';
|
||||||
@ -44,7 +44,15 @@ export function useSelection(id: string) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (rangeRef.current && rangeRef.current?.isDragging) return;
|
if (rangeRef.current) {
|
||||||
|
const { isDragging, anchor, focus } = rangeRef.current;
|
||||||
|
const mouseDownFocused = anchor?.point.x === focus?.point.x && anchor?.point.y === focus?.point.y;
|
||||||
|
|
||||||
|
if (isDragging && !mouseDownFocused) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!focusCaret) {
|
if (!focusCaret) {
|
||||||
setSelection(undefined);
|
setSelection(undefined);
|
||||||
return;
|
return;
|
||||||
|
@ -0,0 +1,144 @@
|
|||||||
|
import React, { useCallback, useContext, useEffect, useRef } from 'react';
|
||||||
|
import './inline.css';
|
||||||
|
import { NodeIdContext } from '$app/components/document/_shared/SubscribeNode.hooks';
|
||||||
|
import { useFocused, useRangeRef } from '$app/components/document/_shared/SubscribeSelection.hooks';
|
||||||
|
import { RangeStaticNoId, TemporaryType } from '$app/interfaces/document';
|
||||||
|
import { useAppDispatch } from '$app/stores/store';
|
||||||
|
import { createTemporary } from '$app_reducers/document/async-actions/temporary';
|
||||||
|
import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks';
|
||||||
|
import KatexMath from '$app/components/document/_shared/KatexMath';
|
||||||
|
import { rangeActions } from '$app_reducers/document/slice';
|
||||||
|
|
||||||
|
const LEFT_CARET_CLASS = 'inline-block-with-cursor-left';
|
||||||
|
const RIGHT_CARET_CLASS = 'inline-block-with-cursor-right';
|
||||||
|
|
||||||
|
function InlineContainer({
|
||||||
|
isFirst,
|
||||||
|
isLast,
|
||||||
|
children,
|
||||||
|
getSelection,
|
||||||
|
selectedText,
|
||||||
|
data,
|
||||||
|
temporaryType,
|
||||||
|
}: {
|
||||||
|
getSelection: (node: Element) => RangeStaticNoId | null;
|
||||||
|
children: React.ReactNode;
|
||||||
|
formula: string;
|
||||||
|
selectedText: string;
|
||||||
|
isLast: boolean;
|
||||||
|
isFirst: boolean;
|
||||||
|
data: {
|
||||||
|
latex?: string;
|
||||||
|
};
|
||||||
|
temporaryType: TemporaryType;
|
||||||
|
}) {
|
||||||
|
const id = useContext(NodeIdContext);
|
||||||
|
const { docId } = useSubscribeDocument();
|
||||||
|
const { focused, focusCaret } = useFocused(id);
|
||||||
|
const rangeRef = useRangeRef();
|
||||||
|
const ref = useRef<HTMLSpanElement>(null);
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
const onClick = useCallback(
|
||||||
|
(node: HTMLSpanElement) => {
|
||||||
|
const selection = getSelection(node);
|
||||||
|
|
||||||
|
if (!selection) return;
|
||||||
|
const temporaryData = temporaryType === TemporaryType.Equation ? { latex: data.latex } : {};
|
||||||
|
|
||||||
|
dispatch(
|
||||||
|
createTemporary({
|
||||||
|
docId,
|
||||||
|
state: {
|
||||||
|
id,
|
||||||
|
selection,
|
||||||
|
selectedText,
|
||||||
|
type: temporaryType,
|
||||||
|
data: temporaryData as { latex: string },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[getSelection, temporaryType, data.latex, dispatch, docId, id, selectedText]
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderNode = useCallback(() => {
|
||||||
|
switch (temporaryType) {
|
||||||
|
case TemporaryType.Equation:
|
||||||
|
return <KatexMath latex={data.latex!} isInline />;
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}, [data, temporaryType]);
|
||||||
|
|
||||||
|
const resetCaret = useCallback(() => {
|
||||||
|
if (!ref.current) return;
|
||||||
|
ref.current.classList.remove(RIGHT_CARET_CLASS);
|
||||||
|
ref.current.classList.remove(LEFT_CARET_CLASS);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
resetCaret();
|
||||||
|
if (!ref.current) return;
|
||||||
|
if (!focused || !focusCaret || rangeRef.current?.isDragging) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const inlineBlockSelection = getSelection(ref.current);
|
||||||
|
|
||||||
|
if (!inlineBlockSelection) return;
|
||||||
|
const distance = inlineBlockSelection.index - focusCaret.index;
|
||||||
|
|
||||||
|
if (distance === 0 && isFirst) {
|
||||||
|
ref.current.classList.add(LEFT_CARET_CLASS);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (distance === -1) {
|
||||||
|
ref.current.classList.add(RIGHT_CARET_CLASS);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}, [focused, focusCaret, getSelection, resetCaret, isFirst, rangeRef]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!ref.current) return;
|
||||||
|
const onMouseDown = (e: MouseEvent) => {
|
||||||
|
if (e.target === ref.current) {
|
||||||
|
e.stopPropagation();
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// prevent page scroll when the caret change by mouse down
|
||||||
|
document.addEventListener('mousedown', onMouseDown, true);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('mousedown', onMouseDown, true);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!selectedText) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className={'inline-block-with-cursor relative'} ref={ref} onClick={() => onClick(ref.current!)}>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
pointerEvents: 'none',
|
||||||
|
}}
|
||||||
|
className={`absolute caret-transparent opacity-0`}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
data-slate-placeholder={true}
|
||||||
|
contentEditable={false}
|
||||||
|
style={{
|
||||||
|
pointerEvents: 'none',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{renderNode()}
|
||||||
|
</span>
|
||||||
|
{isLast && <span data-slate-string={false}></span>}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default InlineContainer;
|
@ -0,0 +1,31 @@
|
|||||||
|
.inline-block-with-cursor {
|
||||||
|
position: relative;
|
||||||
|
display: inline-block;
|
||||||
|
padding: 0 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inline-block-with-cursor-left::before,
|
||||||
|
.inline-block-with-cursor-right::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0px;
|
||||||
|
width: 1px;
|
||||||
|
height: 100%;
|
||||||
|
background-color: rgb(55, 53, 47);
|
||||||
|
opacity: 0.5;
|
||||||
|
animation: cursor-blink 1s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inline-block-with-cursor-left::before {
|
||||||
|
left: -1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inline-block-with-cursor-right::after {
|
||||||
|
right: -1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes cursor-blink {
|
||||||
|
0% { opacity: 0; }
|
||||||
|
50% { opacity: 1; }
|
||||||
|
100% { opacity: 0; }
|
||||||
|
}
|
@ -0,0 +1,9 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import 'katex/dist/katex.min.css';
|
||||||
|
import { BlockMath, InlineMath } from 'react-katex';
|
||||||
|
|
||||||
|
function KatexMath({ latex, isInline = false }: { latex: string; isInline?: boolean }) {
|
||||||
|
return isInline ? <InlineMath math={latex} /> : <BlockMath math={latex} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default KatexMath;
|
@ -49,7 +49,11 @@ const MenuItem = forwardRef(function (
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={`mr-2 flex h-[${imgSize.height}px] w-[${imgSize.width}px] items-center justify-center rounded border border-shade-5`}
|
style={{
|
||||||
|
width: imgSize.width,
|
||||||
|
height: imgSize.height,
|
||||||
|
}}
|
||||||
|
className={`mr-2 flex items-center justify-center rounded border border-shade-5`}
|
||||||
>
|
>
|
||||||
{icon}
|
{icon}
|
||||||
</div>
|
</div>
|
||||||
|
@ -4,6 +4,9 @@ import { useCallback, useRef } from 'react';
|
|||||||
import TextLink from '../TextLink';
|
import TextLink from '../TextLink';
|
||||||
import { converToIndexLength } from '$app/utils/document/slate_editor';
|
import { converToIndexLength } from '$app/utils/document/slate_editor';
|
||||||
import LinkHighLight from '$app/components/document/_shared/TextLink/LinkHighLight';
|
import LinkHighLight from '$app/components/document/_shared/TextLink/LinkHighLight';
|
||||||
|
import TemporaryInput from '$app/components/document/_shared/TemporaryInput';
|
||||||
|
import InlineContainer from '$app/components/document/_shared/InlineBlock/InlineContainer';
|
||||||
|
import { TemporaryType } from '$app/interfaces/document';
|
||||||
|
|
||||||
interface Attributes {
|
interface Attributes {
|
||||||
bold?: boolean;
|
bold?: boolean;
|
||||||
@ -16,6 +19,8 @@ interface Attributes {
|
|||||||
prism_token?: string;
|
prism_token?: string;
|
||||||
link_selection_lighted?: boolean;
|
link_selection_lighted?: boolean;
|
||||||
link_placeholder?: string;
|
link_placeholder?: string;
|
||||||
|
temporary?: boolean;
|
||||||
|
formula?: string;
|
||||||
}
|
}
|
||||||
interface TextLeafProps extends RenderLeafProps {
|
interface TextLeafProps extends RenderLeafProps {
|
||||||
leaf: BaseText & Attributes;
|
leaf: BaseText & Attributes;
|
||||||
@ -27,6 +32,9 @@ const TextLeaf = (props: TextLeafProps) => {
|
|||||||
const { attributes, children, leaf, isCodeBlock, editor } = props;
|
const { attributes, children, leaf, isCodeBlock, editor } = props;
|
||||||
const ref = useRef<HTMLSpanElement>(null);
|
const ref = useRef<HTMLSpanElement>(null);
|
||||||
|
|
||||||
|
const customAttributes = {
|
||||||
|
...attributes,
|
||||||
|
};
|
||||||
let newChildren = children;
|
let newChildren = children;
|
||||||
|
|
||||||
if (leaf.code) {
|
if (leaf.code) {
|
||||||
@ -51,6 +59,7 @@ const TextLeaf = (props: TextLeafProps) => {
|
|||||||
anchor: { path, offset: 0 },
|
anchor: { path, offset: 0 },
|
||||||
focus: { path, offset: leaf.text.length },
|
focus: { path, offset: leaf.text.length },
|
||||||
});
|
});
|
||||||
|
|
||||||
return selection;
|
return selection;
|
||||||
},
|
},
|
||||||
[editor, leaf]
|
[editor, leaf]
|
||||||
@ -64,6 +73,26 @@ const TextLeaf = (props: TextLeafProps) => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (leaf.formula) {
|
||||||
|
const { isLast, text, parent } = children.props;
|
||||||
|
const temporaryType = TemporaryType.Equation;
|
||||||
|
const data = { latex: leaf.formula };
|
||||||
|
|
||||||
|
newChildren = (
|
||||||
|
<InlineContainer
|
||||||
|
isLast={isLast}
|
||||||
|
isFirst={text === parent.children[0]}
|
||||||
|
getSelection={getSelection}
|
||||||
|
formula={leaf.formula}
|
||||||
|
data={data}
|
||||||
|
temporaryType={temporaryType}
|
||||||
|
selectedText={leaf.text}
|
||||||
|
>
|
||||||
|
{newChildren}
|
||||||
|
</InlineContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const className = [
|
const className = [
|
||||||
isCodeBlock && 'token',
|
isCodeBlock && 'token',
|
||||||
leaf.prism_token && leaf.prism_token,
|
leaf.prism_token && leaf.prism_token,
|
||||||
@ -83,8 +112,13 @@ const TextLeaf = (props: TextLeafProps) => {
|
|||||||
</LinkHighLight>
|
</LinkHighLight>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (leaf.temporary) {
|
||||||
|
newChildren = <TemporaryInput leaf={leaf}>{newChildren}</TemporaryInput>;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span ref={ref} {...attributes} className={className.join(' ')}>
|
<span ref={ref} {...customAttributes} className={className.join(' ')}>
|
||||||
{newChildren}
|
{newChildren}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
|
@ -0,0 +1,156 @@
|
|||||||
|
import { TextAction } from '$app/interfaces/document';
|
||||||
|
import { Keyboard } from '$app/constants/document/keyboard';
|
||||||
|
import { ReactEditor } from 'slate-react';
|
||||||
|
import { Editor, Range } from 'slate';
|
||||||
|
import { converToSlatePoint } from '$app/utils/document/slate_editor';
|
||||||
|
import { EQUATION_PLACEHOLDER } from '$app/constants/document/name';
|
||||||
|
|
||||||
|
const bold = {
|
||||||
|
type: TextAction.Bold,
|
||||||
|
/**
|
||||||
|
* ** or __
|
||||||
|
*/
|
||||||
|
markdownRegexp: /(\*\*|__)([^\s](?:[^\s]*?[^\s])?)(\*\*|__)$/,
|
||||||
|
};
|
||||||
|
const italic = {
|
||||||
|
type: TextAction.Italic,
|
||||||
|
/**
|
||||||
|
* * or _
|
||||||
|
*/
|
||||||
|
markdownRegexp: /(\*|_)([^\s](?:[^\s]*?[^\s])?)(\*|_)$/,
|
||||||
|
};
|
||||||
|
const strikethrough = {
|
||||||
|
type: TextAction.Strikethrough,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ~~
|
||||||
|
*/
|
||||||
|
markdownRegexp: /(~~)([^\s](?:[^\s]*?[^\s])?)(~~)$/,
|
||||||
|
};
|
||||||
|
const inlineCode = {
|
||||||
|
type: TextAction.Code,
|
||||||
|
/**
|
||||||
|
* `
|
||||||
|
*/
|
||||||
|
markdownRegexp: /(`)([^\s](?:[^\s]*?[^\s])?)(`)$/,
|
||||||
|
};
|
||||||
|
const inlineEquation = {
|
||||||
|
type: TextAction.Equation,
|
||||||
|
/**
|
||||||
|
* $
|
||||||
|
*/
|
||||||
|
markdownRegexp: /(\$)([^\s](?:[^\s]*?[^\s])?)(\$)$/,
|
||||||
|
};
|
||||||
|
const config: Record<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
type: TextAction;
|
||||||
|
getValue?: (matchStr: string) => string | boolean;
|
||||||
|
markdownRegexp: RegExp;
|
||||||
|
}[]
|
||||||
|
> = {
|
||||||
|
[Keyboard.keys.ASTERISK]: [bold, italic],
|
||||||
|
[Keyboard.keys.UNDER_SCORE]: [bold, italic],
|
||||||
|
[Keyboard.keys.TILDE]: [strikethrough],
|
||||||
|
[Keyboard.keys.BACK_QUOTE]: [inlineCode],
|
||||||
|
[Keyboard.keys.DOLLAR]: [inlineEquation],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const withMarkdown = (editor: ReactEditor) => {
|
||||||
|
const { insertText } = editor;
|
||||||
|
|
||||||
|
editor.insertText = (text) => {
|
||||||
|
const { selection } = editor;
|
||||||
|
const char = text.charAt(text.length - 1);
|
||||||
|
const matchFormatTypes = config[char];
|
||||||
|
|
||||||
|
if (matchFormatTypes && matchFormatTypes.length > 0 && selection && Range.isCollapsed(selection)) {
|
||||||
|
const { anchor } = selection;
|
||||||
|
const start = Editor.start(editor, []);
|
||||||
|
const range = { anchor, focus: start };
|
||||||
|
const textString = Editor.string(editor, range) + text;
|
||||||
|
const prevChar = textString.charAt(textString.length - 2);
|
||||||
|
|
||||||
|
// If the previous character is a space, we don't want to trigger the markdown
|
||||||
|
if (prevChar === ' ') {
|
||||||
|
return insertText(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const formatType of matchFormatTypes) {
|
||||||
|
const match = textString.match(formatType.markdownRegexp);
|
||||||
|
|
||||||
|
if (match) {
|
||||||
|
const pluralStart = match[0].substring(0, 2) === char.padStart(2, char);
|
||||||
|
const pluralEnd = prevChar === char;
|
||||||
|
|
||||||
|
if (pluralStart && !pluralEnd) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const matchIndex = match.index || 0;
|
||||||
|
|
||||||
|
if (formatType.type === TextAction.Equation) {
|
||||||
|
formatEquation(editor, matchIndex, match[2]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// format already applied
|
||||||
|
editor.select({
|
||||||
|
anchor,
|
||||||
|
focus: converToSlatePoint(editor, matchIndex),
|
||||||
|
});
|
||||||
|
if (isMarkAction(editor, formatType.type)) {
|
||||||
|
editor.select(anchor);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
Editor.addMark(editor, formatType.type, true);
|
||||||
|
|
||||||
|
// delete extra characters
|
||||||
|
editor.select(converToSlatePoint(editor, matchIndex));
|
||||||
|
editor.delete({
|
||||||
|
distance: pluralStart ? 2 : 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
editor.select(converToSlatePoint(editor, matchIndex + match[2].length));
|
||||||
|
if (pluralStart) {
|
||||||
|
editor.delete({
|
||||||
|
distance: 1,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
insertText(text);
|
||||||
|
};
|
||||||
|
|
||||||
|
return editor;
|
||||||
|
};
|
||||||
|
|
||||||
|
function isMarkAction(editor: Editor, format: string) {
|
||||||
|
const marks = Editor.marks(editor) as Record<string, boolean> | null;
|
||||||
|
|
||||||
|
return marks ? !!marks[format] : false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatEquation(editor: Editor, index: number, latex: string) {
|
||||||
|
editor.select(converToSlatePoint(editor, index));
|
||||||
|
editor.delete({
|
||||||
|
distance: latex.length + 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
editor.insertNode(
|
||||||
|
{
|
||||||
|
text: EQUATION_PLACEHOLDER,
|
||||||
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
|
// @ts-ignore
|
||||||
|
formula: latex,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
select: true,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
@ -15,6 +15,8 @@ import Delta from 'quill-delta';
|
|||||||
import isHotkey from 'is-hotkey';
|
import isHotkey from 'is-hotkey';
|
||||||
import { useSlateYjs } from '$app/components/document/_shared/SlateEditor/useSlateYjs';
|
import { useSlateYjs } from '$app/components/document/_shared/SlateEditor/useSlateYjs';
|
||||||
|
|
||||||
|
const AFTER_RENDER_DELAY = 100;
|
||||||
|
|
||||||
export function useEditor({
|
export function useEditor({
|
||||||
onChange,
|
onChange,
|
||||||
onSelectionChange,
|
onSelectionChange,
|
||||||
@ -24,6 +26,7 @@ export function useEditor({
|
|||||||
onKeyDown,
|
onKeyDown,
|
||||||
isCodeBlock,
|
isCodeBlock,
|
||||||
linkDecorateSelection,
|
linkDecorateSelection,
|
||||||
|
temporarySelection,
|
||||||
}: EditorProps) {
|
}: EditorProps) {
|
||||||
const { editor } = useSlateYjs({ delta });
|
const { editor } = useSlateYjs({ delta });
|
||||||
const ref = useRef<HTMLDivElement | null>(null);
|
const ref = useRef<HTMLDivElement | null>(null);
|
||||||
@ -31,6 +34,7 @@ export function useEditor({
|
|||||||
const onSelectionChangeHandler = useCallback(
|
const onSelectionChangeHandler = useCallback(
|
||||||
(slateSelection: Selection) => {
|
(slateSelection: Selection) => {
|
||||||
const rangeStatic = converToIndexLength(editor, slateSelection);
|
const rangeStatic = converToIndexLength(editor, slateSelection);
|
||||||
|
|
||||||
onSelectionChange?.(rangeStatic, null);
|
onSelectionChange?.(rangeStatic, null);
|
||||||
},
|
},
|
||||||
[editor, onSelectionChange]
|
[editor, onSelectionChange]
|
||||||
@ -39,6 +43,7 @@ export function useEditor({
|
|||||||
const onChangeHandler = useCallback(
|
const onChangeHandler = useCallback(
|
||||||
(slateValue: Descendant[]) => {
|
(slateValue: Descendant[]) => {
|
||||||
const oldContents = delta || new Delta();
|
const oldContents = delta || new Delta();
|
||||||
|
|
||||||
onChange?.(convertToDelta(slateValue), oldContents);
|
onChange?.(convertToDelta(slateValue), oldContents);
|
||||||
onSelectionChangeHandler(editor.selection);
|
onSelectionChangeHandler(editor.selection);
|
||||||
},
|
},
|
||||||
@ -67,8 +72,10 @@ export function useEditor({
|
|||||||
) => {
|
) => {
|
||||||
if (!selection) return null;
|
if (!selection) return null;
|
||||||
const range = convertToSlateSelection(selection.index, selection.length, editor.children) as BaseRange;
|
const range = convertToSlateSelection(selection.index, selection.length, editor.children) as BaseRange;
|
||||||
|
|
||||||
if (range && !Range.isCollapsed(range)) {
|
if (range && !Range.isCollapsed(range)) {
|
||||||
const intersection = Range.intersection(range, Editor.range(editor, path));
|
const intersection = Range.intersection(range, Editor.range(editor, path));
|
||||||
|
|
||||||
if (intersection) {
|
if (intersection) {
|
||||||
return {
|
return {
|
||||||
...intersection,
|
...intersection,
|
||||||
@ -76,6 +83,7 @@ export function useEditor({
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
},
|
},
|
||||||
[editor]
|
[editor]
|
||||||
@ -93,11 +101,14 @@ export function useEditor({
|
|||||||
link_selection_lighted: true,
|
link_selection_lighted: true,
|
||||||
link_placeholder: linkDecorateSelection?.placeholder,
|
link_placeholder: linkDecorateSelection?.placeholder,
|
||||||
}),
|
}),
|
||||||
|
getDecorateRange(path, temporarySelection, {
|
||||||
|
temporary: true,
|
||||||
|
}),
|
||||||
].filter((range) => range !== null) as Range[];
|
].filter((range) => range !== null) as Range[];
|
||||||
|
|
||||||
return ranges;
|
return ranges;
|
||||||
},
|
},
|
||||||
[decorateSelection, linkDecorateSelection, getDecorateRange]
|
[temporarySelection, decorateSelection, linkDecorateSelection, getDecorateRange]
|
||||||
);
|
);
|
||||||
|
|
||||||
const onKeyDownRewrite = useCallback(
|
const onKeyDownRewrite = useCallback(
|
||||||
@ -107,6 +118,7 @@ export function useEditor({
|
|||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
editor.insertText('\n');
|
editor.insertText('\n');
|
||||||
};
|
};
|
||||||
|
|
||||||
// There is different behavior for code block and normal text
|
// There is different behavior for code block and normal text
|
||||||
// In code block, we press enter to insert a new line
|
// In code block, we press enter to insert a new line
|
||||||
// In normal text, we press shift + enter to insert a new line
|
// In normal text, we press shift + enter to insert a new line
|
||||||
@ -115,11 +127,13 @@ export function useEditor({
|
|||||||
insertBreak();
|
insertBreak();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isHotkey(Keyboard.keys.TAB, event)) {
|
if (isHotkey(Keyboard.keys.TAB, event)) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
indent(editor, 2);
|
indent(editor, 2);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isHotkey(Keyboard.keys.SHIFT_TAB, event)) {
|
if (isHotkey(Keyboard.keys.SHIFT_TAB, event)) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
outdent(editor, 2);
|
outdent(editor, 2);
|
||||||
@ -141,13 +155,16 @@ export function useEditor({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!ref.current) return;
|
if (!ref.current) return;
|
||||||
|
|
||||||
const isFocused = ReactEditor.isFocused(editor);
|
const isFocused = ReactEditor.isFocused(editor);
|
||||||
|
|
||||||
if (!selection) {
|
if (!selection) {
|
||||||
isFocused && editor.deselect();
|
isFocused && editor.deselect();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const slateSelection = convertToSlateSelection(selection.index, selection.length, editor.children);
|
const slateSelection = convertToSlateSelection(selection.index, selection.length, editor.children);
|
||||||
|
|
||||||
if (!slateSelection) return;
|
if (!slateSelection) return;
|
||||||
|
|
||||||
if (isFocused && JSON.stringify(slateSelection) === JSON.stringify(editor.selection)) return;
|
if (isFocused && JSON.stringify(slateSelection) === JSON.stringify(editor.selection)) return;
|
||||||
@ -156,15 +173,16 @@ export function useEditor({
|
|||||||
// because the slate must be focused before change selection,
|
// because the slate must be focused before change selection,
|
||||||
// but then it will trigger selection change, and the selection is not what we want
|
// but then it will trigger selection change, and the selection is not what we want
|
||||||
const isSuccess = focusNodeByIndex(ref.current, selection.index, selection.length);
|
const isSuccess = focusNodeByIndex(ref.current, selection.index, selection.length);
|
||||||
|
|
||||||
if (!isSuccess) {
|
if (!isSuccess) {
|
||||||
Transforms.select(editor, slateSelection);
|
Transforms.select(editor, slateSelection);
|
||||||
} else {
|
} else {
|
||||||
// Fix: the slate is possible to lose focus in next tick after focusNodeByIndex
|
// Fix: the slate is possible to lose focus in next tick after focusNodeByIndex
|
||||||
requestAnimationFrame(() => {
|
setTimeout(() => {
|
||||||
if (window.getSelection()?.type === 'None' && !editor.selection) {
|
if (window.getSelection()?.type === 'None' && !editor.selection) {
|
||||||
Transforms.select(editor, slateSelection);
|
Transforms.select(editor, slateSelection);
|
||||||
}
|
}
|
||||||
});
|
}, AFTER_RENDER_DELAY);
|
||||||
}
|
}
|
||||||
}, [editor, selection]);
|
}, [editor, selection]);
|
||||||
|
|
||||||
|
@ -5,6 +5,7 @@ import { convertToSlateValue } from '$app/utils/document/slate_editor';
|
|||||||
import { slateNodesToInsertDelta, withYjs, YjsEditor } from '@slate-yjs/core';
|
import { slateNodesToInsertDelta, withYjs, YjsEditor } from '@slate-yjs/core';
|
||||||
import { withReact } from 'slate-react';
|
import { withReact } from 'slate-react';
|
||||||
import { createEditor } from 'slate';
|
import { createEditor } from 'slate';
|
||||||
|
import { withMarkdown } from '$app/components/document/_shared/SlateEditor/markdown';
|
||||||
|
|
||||||
export function useSlateYjs({ delta }: { delta?: Delta }) {
|
export function useSlateYjs({ delta }: { delta?: Delta }) {
|
||||||
const [yText, setYText] = useState<Y.Text | undefined>(undefined);
|
const [yText, setYText] = useState<Y.Text | undefined>(undefined);
|
||||||
@ -13,13 +14,14 @@ export function useSlateYjs({ delta }: { delta?: Delta }) {
|
|||||||
const sharedType = yDoc.get('content', Y.XmlText) as Y.XmlText;
|
const sharedType = yDoc.get('content', Y.XmlText) as Y.XmlText;
|
||||||
const value = convertToSlateValue(delta || new Delta());
|
const value = convertToSlateValue(delta || new Delta());
|
||||||
const insertDelta = slateNodesToInsertDelta(value);
|
const insertDelta = slateNodesToInsertDelta(value);
|
||||||
|
|
||||||
sharedType.applyDelta(insertDelta);
|
sharedType.applyDelta(insertDelta);
|
||||||
setYText(insertDelta[0].insert as Y.Text);
|
setYText(insertDelta[0].insert as Y.Text);
|
||||||
return sharedType;
|
return sharedType;
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const editor = useMemo(() => withReact(withYjs(createEditor(), sharedType)), []);
|
const editor = useMemo(() => withYjs(withMarkdown(withReact(createEditor())), sharedType), []);
|
||||||
|
|
||||||
// Connect editor in useEffect to comply with concurrent mode requirements.
|
// Connect editor in useEffect to comply with concurrent mode requirements.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -33,6 +35,7 @@ export function useSlateYjs({ delta }: { delta?: Delta }) {
|
|||||||
if (!yText) return;
|
if (!yText) return;
|
||||||
const oldContents = new Delta(yText.toDelta());
|
const oldContents = new Delta(yText.toDelta());
|
||||||
const diffDelta = oldContents.diff(delta || new Delta());
|
const diffDelta = oldContents.diff(delta || new Delta());
|
||||||
|
|
||||||
if (diffDelta.ops.length === 0) return;
|
if (diffDelta.ops.length === 0) return;
|
||||||
yText.applyDelta(diffDelta.ops);
|
yText.applyDelta(diffDelta.ops);
|
||||||
}, [delta, editor, yText]);
|
}, [delta, editor, yText]);
|
||||||
|
@ -1,10 +1,12 @@
|
|||||||
import { DocumentControllerContext } from '$app/stores/effects/document/document_controller';
|
import { DocumentControllerContext } from '$app/stores/effects/document/document_controller';
|
||||||
import { useContext } from 'react';
|
import { useContext } from 'react';
|
||||||
import { useAppSelector } from '$app/stores/store';
|
import { useAppSelector } from '$app/stores/store';
|
||||||
|
import { DOCUMENT_NAME } from '$app/constants/document/name';
|
||||||
|
|
||||||
export function useSubscribeDocument() {
|
export function useSubscribeDocument() {
|
||||||
const controller = useContext(DocumentControllerContext);
|
const controller = useContext(DocumentControllerContext);
|
||||||
const docId = controller.documentId;
|
const docId = controller.documentId;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
docId,
|
docId,
|
||||||
controller,
|
controller,
|
||||||
@ -14,7 +16,8 @@ export function useSubscribeDocument() {
|
|||||||
export function useSubscribeDocumentData() {
|
export function useSubscribeDocumentData() {
|
||||||
const { docId } = useSubscribeDocument();
|
const { docId } = useSubscribeDocument();
|
||||||
const data = useAppSelector((state) => {
|
const data = useAppSelector((state) => {
|
||||||
return state.document[docId];
|
return state[DOCUMENT_NAME][docId];
|
||||||
});
|
});
|
||||||
|
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
@ -1,11 +1,12 @@
|
|||||||
import { useAppSelector } from '$app/stores/store';
|
import { useAppSelector } from '$app/stores/store';
|
||||||
import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks';
|
import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks';
|
||||||
|
import { TEXT_LINK_NAME } from '$app/constants/document/name';
|
||||||
|
|
||||||
export function useSubscribeLinkPopover() {
|
export function useSubscribeLinkPopover() {
|
||||||
const { docId } = useSubscribeDocument();
|
const { docId } = useSubscribeDocument();
|
||||||
|
|
||||||
const linkPopover = useAppSelector((state) => {
|
const linkPopover = useAppSelector((state) => {
|
||||||
return state.documentLinkPopover[docId];
|
return state[TEXT_LINK_NAME][docId];
|
||||||
});
|
});
|
||||||
|
|
||||||
return linkPopover;
|
return linkPopover;
|
||||||
|
@ -2,6 +2,7 @@ import { store, useAppSelector } from '@/appflowy_app/stores/store';
|
|||||||
import { createContext, useMemo } from 'react';
|
import { createContext, useMemo } from 'react';
|
||||||
import { Node } from '$app/interfaces/document';
|
import { Node } from '$app/interfaces/document';
|
||||||
import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks';
|
import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks';
|
||||||
|
import { DOCUMENT_NAME, RECT_RANGE_NAME } from '$app/constants/document/name';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Subscribe node information
|
* Subscribe node information
|
||||||
@ -11,20 +12,23 @@ export function useSubscribeNode(id: string) {
|
|||||||
const { docId } = useSubscribeDocument();
|
const { docId } = useSubscribeDocument();
|
||||||
|
|
||||||
const node = useAppSelector<Node>((state) => {
|
const node = useAppSelector<Node>((state) => {
|
||||||
const documentState = state.document[docId];
|
const documentState = state[DOCUMENT_NAME][docId];
|
||||||
|
|
||||||
return documentState?.nodes[id];
|
return documentState?.nodes[id];
|
||||||
});
|
});
|
||||||
|
|
||||||
const childIds = useAppSelector<string[] | undefined>((state) => {
|
const childIds = useAppSelector<string[] | undefined>((state) => {
|
||||||
const documentState = state.document[docId];
|
const documentState = state[DOCUMENT_NAME][docId];
|
||||||
|
|
||||||
if (!documentState) return;
|
if (!documentState) return;
|
||||||
const childrenId = documentState.nodes[id]?.children;
|
const childrenId = documentState.nodes[id]?.children;
|
||||||
|
|
||||||
if (!childrenId) return;
|
if (!childrenId) return;
|
||||||
return documentState.children[childrenId];
|
return documentState.children[childrenId];
|
||||||
});
|
});
|
||||||
|
|
||||||
const isSelected = useAppSelector<boolean>((state) => {
|
const isSelected = useAppSelector<boolean>((state) => {
|
||||||
return state.documentRectSelection[docId]?.selection.includes(id) || false;
|
return state[RECT_RANGE_NAME][docId]?.selection.includes(id) || false;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Memoize the node and its children
|
// Memoize the node and its children
|
||||||
|
@ -1,10 +1,12 @@
|
|||||||
import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks';
|
import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks';
|
||||||
import { useAppSelector } from '$app/stores/store';
|
import { useAppSelector } from '$app/stores/store';
|
||||||
|
import { RECT_RANGE_NAME } from '$app/constants/document/name';
|
||||||
|
|
||||||
export function useSubscribeRectRange() {
|
export function useSubscribeRectRange() {
|
||||||
const { docId } = useSubscribeDocument();
|
const { docId } = useSubscribeDocument();
|
||||||
const rectRange = useAppSelector((state) => {
|
const rectRange = useAppSelector((state) => {
|
||||||
return state.documentRectSelection[docId];
|
return state[RECT_RANGE_NAME][docId];
|
||||||
});
|
});
|
||||||
|
|
||||||
return rectRange;
|
return rectRange;
|
||||||
}
|
}
|
||||||
|
@ -2,16 +2,25 @@ import { useAppSelector } from '$app/stores/store';
|
|||||||
import { RangeState, RangeStatic } from '$app/interfaces/document';
|
import { RangeState, RangeStatic } from '$app/interfaces/document';
|
||||||
import { useMemo, useRef } from 'react';
|
import { useMemo, useRef } from 'react';
|
||||||
import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks';
|
import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks';
|
||||||
|
import { RANGE_NAME, TEMPORARY_NAME, TEXT_LINK_NAME } from '$app/constants/document/name';
|
||||||
|
|
||||||
export function useSubscribeDecorate(id: string) {
|
export function useSubscribeDecorate(id: string) {
|
||||||
const { docId } = useSubscribeDocument();
|
const { docId } = useSubscribeDocument();
|
||||||
|
|
||||||
const decorateSelection = useAppSelector((state) => {
|
const decorateSelection = useAppSelector((state) => {
|
||||||
return state.documentRange[docId]?.ranges[id];
|
return state[RANGE_NAME][docId]?.ranges[id];
|
||||||
|
});
|
||||||
|
|
||||||
|
const temporarySelection = useAppSelector((state) => {
|
||||||
|
const temporary = state[TEMPORARY_NAME][docId];
|
||||||
|
|
||||||
|
if (!temporary || temporary.id !== id) return;
|
||||||
|
return temporary.selection;
|
||||||
});
|
});
|
||||||
|
|
||||||
const linkDecorateSelection = useAppSelector((state) => {
|
const linkDecorateSelection = useAppSelector((state) => {
|
||||||
const linkPopoverState = state.documentLinkPopover[docId];
|
const linkPopoverState = state[TEXT_LINK_NAME][docId];
|
||||||
|
|
||||||
if (!linkPopoverState?.open || linkPopoverState?.id !== id) return;
|
if (!linkPopoverState?.open || linkPopoverState?.id !== id) return;
|
||||||
return {
|
return {
|
||||||
selection: linkPopoverState.selection,
|
selection: linkPopoverState.selection,
|
||||||
@ -22,18 +31,22 @@ export function useSubscribeDecorate(id: string) {
|
|||||||
return {
|
return {
|
||||||
decorateSelection,
|
decorateSelection,
|
||||||
linkDecorateSelection,
|
linkDecorateSelection,
|
||||||
|
temporarySelection,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useFocused(id: string) {
|
export function useFocused(id: string) {
|
||||||
const { docId } = useSubscribeDocument();
|
const { docId } = useSubscribeDocument();
|
||||||
|
|
||||||
const caretRef = useRef<RangeStatic>();
|
const caretRef = useRef<RangeStatic>();
|
||||||
const focusCaret = useAppSelector((state) => {
|
const focusCaret = useAppSelector((state) => {
|
||||||
const currentCaret = state.documentRange[docId]?.caret;
|
const currentCaret = state[RANGE_NAME][docId]?.caret;
|
||||||
|
|
||||||
caretRef.current = currentCaret;
|
caretRef.current = currentCaret;
|
||||||
if (currentCaret?.id === id) {
|
if (currentCaret?.id === id) {
|
||||||
return currentCaret;
|
return currentCaret;
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -52,8 +65,10 @@ export function useRangeRef() {
|
|||||||
const { docId, controller } = useSubscribeDocument();
|
const { docId, controller } = useSubscribeDocument();
|
||||||
|
|
||||||
const rangeRef = useRef<RangeState>();
|
const rangeRef = useRef<RangeState>();
|
||||||
|
|
||||||
useAppSelector((state) => {
|
useAppSelector((state) => {
|
||||||
const currentRange = state.documentRange[docId];
|
const currentRange = state[RANGE_NAME][docId];
|
||||||
|
|
||||||
rangeRef.current = currentRange;
|
rangeRef.current = currentRange;
|
||||||
});
|
});
|
||||||
return rangeRef;
|
return rangeRef;
|
||||||
@ -63,7 +78,7 @@ export function useSubscribeRanges() {
|
|||||||
const { docId } = useSubscribeDocument();
|
const { docId } = useSubscribeDocument();
|
||||||
|
|
||||||
const rangeState = useAppSelector((state) => {
|
const rangeState = useAppSelector((state) => {
|
||||||
return state.documentRange[docId];
|
return state[RANGE_NAME][docId];
|
||||||
});
|
});
|
||||||
|
|
||||||
return rangeState;
|
return rangeState;
|
||||||
@ -73,7 +88,7 @@ export function useSubscribeCaret() {
|
|||||||
const { docId } = useSubscribeDocument();
|
const { docId } = useSubscribeDocument();
|
||||||
|
|
||||||
const caret = useAppSelector((state) => {
|
const caret = useAppSelector((state) => {
|
||||||
return state.documentRange[docId]?.caret;
|
return state[RANGE_NAME][docId]?.caret;
|
||||||
});
|
});
|
||||||
|
|
||||||
return caret;
|
return caret;
|
||||||
|
@ -1,11 +1,12 @@
|
|||||||
import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks';
|
import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks';
|
||||||
import { useAppSelector } from '$app/stores/store';
|
import { useAppSelector } from '$app/stores/store';
|
||||||
|
import { SLASH_COMMAND_NAME } from '$app/constants/document/name';
|
||||||
|
|
||||||
export function useSubscribeSlashState() {
|
export function useSubscribeSlashState() {
|
||||||
const { docId } = useSubscribeDocument();
|
const { docId } = useSubscribeDocument();
|
||||||
|
|
||||||
const slashCommandState = useAppSelector((state) => {
|
const slashCommandState = useAppSelector((state) => {
|
||||||
return state.documentSlashCommand[docId];
|
return state[SLASH_COMMAND_NAME][docId];
|
||||||
});
|
});
|
||||||
|
|
||||||
return slashCommandState;
|
return slashCommandState;
|
||||||
|
@ -0,0 +1,11 @@
|
|||||||
|
import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks';
|
||||||
|
import { useAppSelector } from '$app/stores/store';
|
||||||
|
import { TemporaryState } from '$app/interfaces/document';
|
||||||
|
import { TEMPORARY_NAME } from '$app/constants/document/name';
|
||||||
|
|
||||||
|
export function useSubscribeTemporary(): TemporaryState {
|
||||||
|
const { docId } = useSubscribeDocument();
|
||||||
|
const temporaryState = useAppSelector((state) => state[TEMPORARY_NAME][docId]);
|
||||||
|
|
||||||
|
return temporaryState;
|
||||||
|
}
|
@ -0,0 +1,50 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import TextField from '@mui/material/TextField';
|
||||||
|
import { CheckOutlined, FunctionsOutlined } from '@mui/icons-material';
|
||||||
|
import { Divider, IconButton, InputAdornment } from '@mui/material';
|
||||||
|
|
||||||
|
function EquationEditContent({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
onConfirm,
|
||||||
|
}: {
|
||||||
|
value: string;
|
||||||
|
onChange: (newVal: string) => void;
|
||||||
|
onConfirm: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className={'flex p-2'}>
|
||||||
|
<TextField
|
||||||
|
placeholder={'E = mc^2'}
|
||||||
|
autoFocus={true}
|
||||||
|
label='Equation'
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
onConfirm();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
InputProps={{
|
||||||
|
startAdornment: (
|
||||||
|
<InputAdornment position='start'>
|
||||||
|
<FunctionsOutlined />
|
||||||
|
</InputAdornment>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
variant='standard'
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => {
|
||||||
|
const newVal = e.target.value;
|
||||||
|
|
||||||
|
if (newVal === value) return;
|
||||||
|
onChange(newVal);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Divider sx={{ height: 'initial', marginLeft: '10px' }} orientation='vertical' />
|
||||||
|
<IconButton onClick={onConfirm} color='primary' sx={{ p: '10px' }} aria-label='directions'>
|
||||||
|
<CheckOutlined />
|
||||||
|
</IconButton>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default EquationEditContent;
|
@ -0,0 +1,19 @@
|
|||||||
|
import React, { useRef } from 'react';
|
||||||
|
import { Functions } from '@mui/icons-material';
|
||||||
|
import KatexMath from '$app/components/document/_shared/KatexMath';
|
||||||
|
|
||||||
|
function TemporaryEquation({ latex }: { latex: string }) {
|
||||||
|
return (
|
||||||
|
<span className={'rounded bg-shade-6 px-1 py-0.5'} contentEditable={false}>
|
||||||
|
{latex ? (
|
||||||
|
<KatexMath latex={latex} isInline />
|
||||||
|
) : (
|
||||||
|
<span className={'text-shade-3'}>
|
||||||
|
<Functions /> {'New equation'}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default TemporaryEquation;
|
@ -0,0 +1,135 @@
|
|||||||
|
import React, { useCallback, useMemo } from 'react';
|
||||||
|
import Popover from '@mui/material/Popover';
|
||||||
|
import { RangeStaticNoId, TemporaryData, TemporaryState, TemporaryType } from '$app/interfaces/document';
|
||||||
|
import EquationEditContent from '$app/components/document/_shared/TemporaryInput/EquationEditContent';
|
||||||
|
import { temporaryActions } from '$app_reducers/document/temporary_slice';
|
||||||
|
import { rangeActions } from '$app_reducers/document/slice';
|
||||||
|
import { formatTemporary } from '$app_reducers/document/async-actions/temporary';
|
||||||
|
import { useAppDispatch } from '$app/stores/store';
|
||||||
|
import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks';
|
||||||
|
import { useSubscribeTemporary } from '$app/components/document/_shared/SubscribeTemporary.hooks';
|
||||||
|
|
||||||
|
const AFTER_RENDER_DELAY = 100;
|
||||||
|
|
||||||
|
function TemporaryPopover() {
|
||||||
|
const temporaryState = useSubscribeTemporary();
|
||||||
|
const anchorPosition = useMemo(() => temporaryState?.popoverPosition, [temporaryState]);
|
||||||
|
const open = Boolean(anchorPosition);
|
||||||
|
const id = temporaryState?.id;
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
const { docId, controller } = useSubscribeDocument();
|
||||||
|
|
||||||
|
const onChangeData = useCallback(
|
||||||
|
(data: TemporaryData) => {
|
||||||
|
dispatch(
|
||||||
|
temporaryActions.updateTemporaryState({
|
||||||
|
id: docId,
|
||||||
|
state: {
|
||||||
|
data,
|
||||||
|
id,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[dispatch, docId, id]
|
||||||
|
);
|
||||||
|
|
||||||
|
const resetCaret = useCallback(
|
||||||
|
(id: string, selection: RangeStaticNoId) => {
|
||||||
|
dispatch(
|
||||||
|
rangeActions.setCaret({
|
||||||
|
docId,
|
||||||
|
caret: {
|
||||||
|
id,
|
||||||
|
index: selection.index + selection.length,
|
||||||
|
length: 0,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[dispatch, docId]
|
||||||
|
);
|
||||||
|
|
||||||
|
const onClose = useCallback(() => {
|
||||||
|
dispatch(
|
||||||
|
temporaryActions.updateTemporaryState({
|
||||||
|
id: docId,
|
||||||
|
state: {
|
||||||
|
id,
|
||||||
|
popoverPosition: null,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}, [dispatch, docId, id]);
|
||||||
|
|
||||||
|
const handleClose = useCallback(() => {
|
||||||
|
if (!temporaryState) return;
|
||||||
|
onClose();
|
||||||
|
dispatch(temporaryActions.deleteTemporaryState(docId));
|
||||||
|
resetCaret(temporaryState.id, temporaryState.selection);
|
||||||
|
}, [dispatch, docId, onClose, resetCaret, temporaryState]);
|
||||||
|
|
||||||
|
const onConfirm = useCallback(async () => {
|
||||||
|
const res = await dispatch(
|
||||||
|
formatTemporary({
|
||||||
|
controller,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
const state = res.payload as TemporaryState;
|
||||||
|
|
||||||
|
if (!state) return;
|
||||||
|
const { id, selection } = state;
|
||||||
|
|
||||||
|
onClose();
|
||||||
|
dispatch(rangeActions.clearRanges({ docId }));
|
||||||
|
dispatch(temporaryActions.deleteTemporaryState(docId));
|
||||||
|
// wait slate to update the dom
|
||||||
|
setTimeout(() => {
|
||||||
|
resetCaret(id, selection);
|
||||||
|
}, AFTER_RENDER_DELAY);
|
||||||
|
}, [dispatch, controller, onClose, docId, resetCaret]);
|
||||||
|
|
||||||
|
const renderPopoverContent = useCallback(() => {
|
||||||
|
if (!temporaryState) return null;
|
||||||
|
const { type, data } = temporaryState;
|
||||||
|
|
||||||
|
switch (type) {
|
||||||
|
case TemporaryType.Equation:
|
||||||
|
return (
|
||||||
|
<EquationEditContent
|
||||||
|
value={data.latex}
|
||||||
|
onChange={(latex: string) =>
|
||||||
|
onChangeData({
|
||||||
|
latex,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
onConfirm={onConfirm}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}, [onChangeData, onConfirm, temporaryState]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover
|
||||||
|
onClose={handleClose}
|
||||||
|
open={open}
|
||||||
|
anchorPosition={anchorPosition ? anchorPosition : undefined}
|
||||||
|
onMouseDown={(e) => e.stopPropagation()}
|
||||||
|
disableAutoFocus={true}
|
||||||
|
disableRestoreFocus={true}
|
||||||
|
anchorReference={'anchorPosition'}
|
||||||
|
anchorOrigin={{
|
||||||
|
vertical: 'bottom',
|
||||||
|
horizontal: 'center',
|
||||||
|
}}
|
||||||
|
transformOrigin={{
|
||||||
|
vertical: 'top',
|
||||||
|
horizontal: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{renderPopoverContent()}
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default TemporaryPopover;
|
@ -0,0 +1,77 @@
|
|||||||
|
import React, { useCallback, useEffect, useMemo, useRef } from 'react';
|
||||||
|
import { TemporaryType } from '$app/interfaces/document';
|
||||||
|
import TemporaryEquation from '$app/components/document/_shared/TemporaryInput/TemporaryEquation';
|
||||||
|
import { useSubscribeTemporary } from '$app/components/document/_shared/SubscribeTemporary.hooks';
|
||||||
|
import { isOverlappingPrefix } from '$app/utils/document/temporary';
|
||||||
|
import { PopoverPosition } from '@mui/material';
|
||||||
|
import { useAppDispatch } from '$app/stores/store';
|
||||||
|
import { temporaryActions } from '$app_reducers/document/temporary_slice';
|
||||||
|
import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks';
|
||||||
|
|
||||||
|
function TemporaryInput({ leaf, children }: { leaf: { text: string }; children: React.ReactNode }) {
|
||||||
|
const temporaryState = useSubscribeTemporary();
|
||||||
|
const id = temporaryState?.id;
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
const ref = useRef<HTMLSpanElement>(null);
|
||||||
|
const { docId } = useSubscribeDocument();
|
||||||
|
const match = useMemo(() => {
|
||||||
|
if (!leaf.text) return false;
|
||||||
|
if (!temporaryState) return false;
|
||||||
|
const { selectedText, type } = temporaryState;
|
||||||
|
|
||||||
|
switch (type) {
|
||||||
|
case TemporaryType.Equation:
|
||||||
|
// when the leaf is split, the placeholder is not the same as the leaf text,
|
||||||
|
// so we can only check for overlapping prefix and hidden other leafs
|
||||||
|
return leaf.text === selectedText || isOverlappingPrefix(leaf.text, selectedText);
|
||||||
|
default:
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}, [temporaryState, leaf.text]);
|
||||||
|
|
||||||
|
const renderPlaceholder = useCallback(() => {
|
||||||
|
if (!temporaryState) return null;
|
||||||
|
const { type, data } = temporaryState;
|
||||||
|
|
||||||
|
switch (type) {
|
||||||
|
case TemporaryType.Equation:
|
||||||
|
return <TemporaryEquation latex={data.latex} />;
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}, [temporaryState]);
|
||||||
|
|
||||||
|
const setAnchorPosition = useCallback(
|
||||||
|
(position: PopoverPosition | null) => {
|
||||||
|
dispatch(
|
||||||
|
temporaryActions.updateTemporaryState({
|
||||||
|
id: docId,
|
||||||
|
state: {
|
||||||
|
id,
|
||||||
|
popoverPosition: position,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[dispatch, docId, id]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!ref.current || !match) return;
|
||||||
|
const { width, height, top, left } = ref.current.getBoundingClientRect();
|
||||||
|
|
||||||
|
setAnchorPosition({
|
||||||
|
top: top + height,
|
||||||
|
left: left + width / 2,
|
||||||
|
});
|
||||||
|
}, [dispatch, docId, id, match, setAnchorPosition]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span ref={ref}>
|
||||||
|
{match ? renderPlaceholder() : null}
|
||||||
|
<span className={'absolute opacity-0'}>{children}</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default TemporaryInput;
|
@ -1,4 +1,5 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
import { isOverlappingPrefix } from '$app/utils/document/temporary';
|
||||||
|
|
||||||
function LinkHighLight({ children, leaf, title }: { leaf: { text: string }; title: string; children: React.ReactNode }) {
|
function LinkHighLight({ children, leaf, title }: { leaf: { text: string }; title: string; children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
@ -19,15 +20,3 @@ function LinkHighLight({ children, leaf, title }: { leaf: { text: string }; titl
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default LinkHighLight;
|
export default LinkHighLight;
|
||||||
|
|
||||||
function isOverlappingPrefix(first: string, second: string): boolean {
|
|
||||||
if (first.length === 0 || second.length === 0) return false;
|
|
||||||
let i = 0;
|
|
||||||
while (i < first.length) {
|
|
||||||
const chars = first.substring(i);
|
|
||||||
if (chars.length > second.length) return false;
|
|
||||||
if (second.startsWith(chars)) return true;
|
|
||||||
i++;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
@ -122,7 +122,7 @@ const TurnIntoPopover = ({
|
|||||||
},
|
},
|
||||||
// {
|
// {
|
||||||
// type: BlockType.EquationBlock,
|
// type: BlockType.EquationBlock,
|
||||||
// title: 'Block Equation',
|
// title: 'Block KatexMath',
|
||||||
// icon: <Functions />,
|
// icon: <Functions />,
|
||||||
// },
|
// },
|
||||||
],
|
],
|
||||||
|
@ -1,3 +0,0 @@
|
|||||||
export const BLOCK_MAP_NAME = 'blocks';
|
|
||||||
export const META_NAME = 'meta';
|
|
||||||
export const CHILDREN_MAP_NAME = 'children_map';
|
|
@ -1,4 +1,4 @@
|
|||||||
import { BlockConfig, BlockType, SplitRelationship, TextAction, TextActionMenuProps } from '$app/interfaces/document';
|
import { BlockConfig, BlockType, SplitRelationship } from '$app/interfaces/document';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* If the block type is not in the config, it will be thrown an error in development env
|
* If the block type is not in the config, it will be thrown an error in development env
|
||||||
@ -20,10 +20,6 @@ export const blockConfig: Record<string, BlockConfig> = {
|
|||||||
nextLineRelationShip: SplitRelationship.NextSibling,
|
nextLineRelationShip: SplitRelationship.NextSibling,
|
||||||
nextLineBlockType: BlockType.TextBlock,
|
nextLineBlockType: BlockType.TextBlock,
|
||||||
},
|
},
|
||||||
/**
|
|
||||||
* # or ## or ###
|
|
||||||
*/
|
|
||||||
markdownRegexps: [/^(#{1,3})(\s)+$/],
|
|
||||||
},
|
},
|
||||||
[BlockType.TodoListBlock]: {
|
[BlockType.TodoListBlock]: {
|
||||||
canAddChild: true,
|
canAddChild: true,
|
||||||
@ -35,10 +31,6 @@ export const blockConfig: Record<string, BlockConfig> = {
|
|||||||
nextLineRelationShip: SplitRelationship.NextSibling,
|
nextLineRelationShip: SplitRelationship.NextSibling,
|
||||||
nextLineBlockType: BlockType.TodoListBlock,
|
nextLineBlockType: BlockType.TodoListBlock,
|
||||||
},
|
},
|
||||||
/**
|
|
||||||
* -[] or -[x] or -[ ] or [] or [x] or [ ]
|
|
||||||
*/
|
|
||||||
markdownRegexps: [/^((-)?\[(x|\s)?\])(\s)+$/],
|
|
||||||
},
|
},
|
||||||
[BlockType.BulletedListBlock]: {
|
[BlockType.BulletedListBlock]: {
|
||||||
canAddChild: true,
|
canAddChild: true,
|
||||||
@ -50,10 +42,6 @@ export const blockConfig: Record<string, BlockConfig> = {
|
|||||||
nextLineRelationShip: SplitRelationship.NextSibling,
|
nextLineRelationShip: SplitRelationship.NextSibling,
|
||||||
nextLineBlockType: BlockType.BulletedListBlock,
|
nextLineBlockType: BlockType.BulletedListBlock,
|
||||||
},
|
},
|
||||||
/**
|
|
||||||
* - or + or *
|
|
||||||
*/
|
|
||||||
markdownRegexps: [/^(\s*[-+*])(\s)+$/],
|
|
||||||
},
|
},
|
||||||
[BlockType.NumberedListBlock]: {
|
[BlockType.NumberedListBlock]: {
|
||||||
canAddChild: true,
|
canAddChild: true,
|
||||||
@ -65,11 +53,6 @@ export const blockConfig: Record<string, BlockConfig> = {
|
|||||||
nextLineRelationShip: SplitRelationship.NextSibling,
|
nextLineRelationShip: SplitRelationship.NextSibling,
|
||||||
nextLineBlockType: BlockType.NumberedListBlock,
|
nextLineBlockType: BlockType.NumberedListBlock,
|
||||||
},
|
},
|
||||||
/**
|
|
||||||
* 1. or 2. or 3.
|
|
||||||
* a. or b. or c.
|
|
||||||
*/
|
|
||||||
markdownRegexps: [/^(\s*[\d|a-zA-Z]+\.)(\s)+$/],
|
|
||||||
},
|
},
|
||||||
[BlockType.QuoteBlock]: {
|
[BlockType.QuoteBlock]: {
|
||||||
canAddChild: true,
|
canAddChild: true,
|
||||||
@ -81,10 +64,6 @@ export const blockConfig: Record<string, BlockConfig> = {
|
|||||||
nextLineRelationShip: SplitRelationship.NextSibling,
|
nextLineRelationShip: SplitRelationship.NextSibling,
|
||||||
nextLineBlockType: BlockType.TextBlock,
|
nextLineBlockType: BlockType.TextBlock,
|
||||||
},
|
},
|
||||||
/**
|
|
||||||
* " or “ or ”
|
|
||||||
*/
|
|
||||||
markdownRegexps: [/^("|“|”)(\s)+$/],
|
|
||||||
},
|
},
|
||||||
[BlockType.CalloutBlock]: {
|
[BlockType.CalloutBlock]: {
|
||||||
canAddChild: true,
|
canAddChild: true,
|
||||||
@ -96,10 +75,6 @@ export const blockConfig: Record<string, BlockConfig> = {
|
|||||||
nextLineRelationShip: SplitRelationship.NextSibling,
|
nextLineRelationShip: SplitRelationship.NextSibling,
|
||||||
nextLineBlockType: BlockType.TextBlock,
|
nextLineBlockType: BlockType.TextBlock,
|
||||||
},
|
},
|
||||||
/**
|
|
||||||
* [!TIP] or [!INFO] or [!WARNING] or [!DANGER]
|
|
||||||
*/
|
|
||||||
markdownRegexps: [/^(\[!)(TIP|INFO|WARNING|DANGER)(\])(\s)+$/],
|
|
||||||
},
|
},
|
||||||
[BlockType.ToggleListBlock]: {
|
[BlockType.ToggleListBlock]: {
|
||||||
canAddChild: true,
|
canAddChild: true,
|
||||||
@ -111,17 +86,6 @@ export const blockConfig: Record<string, BlockConfig> = {
|
|||||||
nextLineRelationShip: SplitRelationship.FirstChild,
|
nextLineRelationShip: SplitRelationship.FirstChild,
|
||||||
nextLineBlockType: BlockType.TextBlock,
|
nextLineBlockType: BlockType.TextBlock,
|
||||||
},
|
},
|
||||||
/**
|
|
||||||
* >
|
|
||||||
*/
|
|
||||||
markdownRegexps: [/^(>)(\s)+$/],
|
|
||||||
},
|
|
||||||
[BlockType.DividerBlock]: {
|
|
||||||
canAddChild: false,
|
|
||||||
/**
|
|
||||||
* ---
|
|
||||||
*/
|
|
||||||
markdownRegexps: [/^(-{3,})$/],
|
|
||||||
},
|
},
|
||||||
|
|
||||||
[BlockType.CodeBlock]: {
|
[BlockType.CodeBlock]: {
|
||||||
@ -130,49 +94,8 @@ export const blockConfig: Record<string, BlockConfig> = {
|
|||||||
delta: [],
|
delta: [],
|
||||||
language: 'javascript',
|
language: 'javascript',
|
||||||
},
|
},
|
||||||
/**
|
},
|
||||||
* ```
|
[BlockType.DividerBlock]: {
|
||||||
*/
|
canAddChild: false,
|
||||||
markdownRegexps: [/^(```)$/],
|
|
||||||
|
|
||||||
textActionMenuProps: {
|
|
||||||
excludeItems: [TextAction.Code],
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export const defaultTextActionProps: TextActionMenuProps = {
|
|
||||||
customItems: [
|
|
||||||
TextAction.Turn,
|
|
||||||
TextAction.Link,
|
|
||||||
TextAction.Bold,
|
|
||||||
TextAction.Italic,
|
|
||||||
TextAction.Underline,
|
|
||||||
TextAction.Strikethrough,
|
|
||||||
TextAction.Code,
|
|
||||||
TextAction.Equation,
|
|
||||||
],
|
|
||||||
excludeItems: [],
|
|
||||||
};
|
|
||||||
|
|
||||||
const groupKeys = {
|
|
||||||
comment: [],
|
|
||||||
format: [
|
|
||||||
TextAction.Bold,
|
|
||||||
TextAction.Italic,
|
|
||||||
TextAction.Underline,
|
|
||||||
TextAction.Strikethrough,
|
|
||||||
TextAction.Code,
|
|
||||||
TextAction.Equation,
|
|
||||||
],
|
|
||||||
link: [TextAction.Link],
|
|
||||||
turn: [TextAction.Turn],
|
|
||||||
};
|
|
||||||
|
|
||||||
export const multiLineTextActionProps: TextActionMenuProps = {
|
|
||||||
customItems: [TextAction.Bold, TextAction.Italic, TextAction.Underline, TextAction.Strikethrough, TextAction.Code],
|
|
||||||
};
|
|
||||||
|
|
||||||
export const multiLineTextActionGroups = [groupKeys.format];
|
|
||||||
|
|
||||||
export const textActionGroups = [groupKeys.turn, groupKeys.format, groupKeys.link];
|
|
||||||
|
@ -24,10 +24,13 @@ export const Keyboard = {
|
|||||||
DELETE: 'Delete',
|
DELETE: 'Delete',
|
||||||
SHIFT_ENTER: 'Shift+Enter',
|
SHIFT_ENTER: 'Shift+Enter',
|
||||||
SHIFT_TAB: 'Shift+Tab',
|
SHIFT_TAB: 'Shift+Tab',
|
||||||
Slash: '/',
|
SLASH: '/',
|
||||||
Space: ' ',
|
REDUCE: '-',
|
||||||
Reduce: '-',
|
BACK_QUOTE: '`',
|
||||||
BackQuote: '`',
|
UNDER_SCORE: '_',
|
||||||
|
ASTERISK: '*',
|
||||||
|
TILDE: '~',
|
||||||
|
DOLLAR: '$',
|
||||||
FORMAT: {
|
FORMAT: {
|
||||||
BOLD: 'Mod+b',
|
BOLD: 'Mod+b',
|
||||||
ITALIC: 'Mod+i',
|
ITALIC: 'Mod+i',
|
||||||
|
@ -0,0 +1,12 @@
|
|||||||
|
export const DOCUMENT_NAME = 'document';
|
||||||
|
export const TEMPORARY_NAME = 'document/temporary';
|
||||||
|
export const RANGE_NAME = 'document/range';
|
||||||
|
|
||||||
|
export const RECT_RANGE_NAME = 'document/rect_range';
|
||||||
|
export const SLASH_COMMAND_NAME = 'document/slash_command';
|
||||||
|
export const TEXT_LINK_NAME = 'document/text_link';
|
||||||
|
export const BLOCK_MAP_NAME = 'blocks';
|
||||||
|
export const META_NAME = 'meta';
|
||||||
|
export const CHILDREN_MAP_NAME = 'children_map';
|
||||||
|
|
||||||
|
export const EQUATION_PLACEHOLDER = '$';
|
@ -213,7 +213,7 @@ export enum TextAction {
|
|||||||
Underline = 'underline',
|
Underline = 'underline',
|
||||||
Strikethrough = 'strikethrough',
|
Strikethrough = 'strikethrough',
|
||||||
Code = 'code',
|
Code = 'code',
|
||||||
Equation = 'equation',
|
Equation = 'formula',
|
||||||
Link = 'href',
|
Link = 'href',
|
||||||
}
|
}
|
||||||
export interface TextActionMenuProps {
|
export interface TextActionMenuProps {
|
||||||
@ -232,10 +232,6 @@ export interface BlockConfig {
|
|||||||
* Whether the block can have children
|
* Whether the block can have children
|
||||||
*/
|
*/
|
||||||
canAddChild: boolean;
|
canAddChild: boolean;
|
||||||
/**
|
|
||||||
* The regexps that will be used to match the markdown flag
|
|
||||||
*/
|
|
||||||
markdownRegexps?: RegExp[];
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The default data of the block
|
* The default data of the block
|
||||||
@ -255,11 +251,6 @@ export interface BlockConfig {
|
|||||||
*/
|
*/
|
||||||
nextLineBlockType: BlockType;
|
nextLineBlockType: BlockType;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* The props that will be passed to the text action menu
|
|
||||||
*/
|
|
||||||
textActionMenuProps?: TextActionMenuProps;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ControllerAction {
|
export interface ControllerAction {
|
||||||
@ -286,12 +277,10 @@ export interface EditorProps {
|
|||||||
selection?: RangeStaticNoId;
|
selection?: RangeStaticNoId;
|
||||||
decorateSelection?: RangeStaticNoId;
|
decorateSelection?: RangeStaticNoId;
|
||||||
linkDecorateSelection?: {
|
linkDecorateSelection?: {
|
||||||
selection?: {
|
selection?: RangeStaticNoId;
|
||||||
index: number;
|
|
||||||
length: number;
|
|
||||||
};
|
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
};
|
};
|
||||||
|
temporarySelection?: RangeStaticNoId;
|
||||||
onSelectionChange?: (range: RangeStaticNoId | null, oldRange: RangeStaticNoId | null, source?: Sources) => void;
|
onSelectionChange?: (range: RangeStaticNoId | null, oldRange: RangeStaticNoId | null, source?: Sources) => void;
|
||||||
onChange?: (delta: Delta, oldDelta: Delta, source?: Sources) => void;
|
onChange?: (delta: Delta, oldDelta: Delta, source?: Sources) => void;
|
||||||
onKeyDown?: (event: React.KeyboardEvent<HTMLDivElement>) => void;
|
onKeyDown?: (event: React.KeyboardEvent<HTMLDivElement>) => void;
|
||||||
@ -306,11 +295,27 @@ export interface BlockCopyData {
|
|||||||
export interface LinkPopoverState {
|
export interface LinkPopoverState {
|
||||||
anchorPosition?: { top: number; left: number };
|
anchorPosition?: { top: number; left: number };
|
||||||
id?: string;
|
id?: string;
|
||||||
selection?: {
|
selection?: RangeStaticNoId;
|
||||||
index: number;
|
|
||||||
length: number;
|
|
||||||
};
|
|
||||||
open?: boolean;
|
open?: boolean;
|
||||||
href?: string;
|
href?: string;
|
||||||
title?: string;
|
title?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface TemporaryState {
|
||||||
|
id: string;
|
||||||
|
type: TemporaryType;
|
||||||
|
selectedText: string;
|
||||||
|
data: TemporaryData;
|
||||||
|
selection: RangeStaticNoId;
|
||||||
|
popoverPosition?: { top: number; left: number } | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum TemporaryType {
|
||||||
|
Equation = 'equation',
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TemporaryData = InlineEquationData;
|
||||||
|
|
||||||
|
export interface InlineEquationData {
|
||||||
|
latex: string;
|
||||||
|
}
|
||||||
|
@ -11,10 +11,10 @@ import {
|
|||||||
} from '@/services/backend';
|
} from '@/services/backend';
|
||||||
import { DocumentObserver } from './document_observer';
|
import { DocumentObserver } from './document_observer';
|
||||||
import * as Y from 'yjs';
|
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 { get } from '@/appflowy_app/utils/tool';
|
||||||
import { blockPB2Node } from '$app/utils/document/block';
|
import { blockPB2Node } from '$app/utils/document/block';
|
||||||
import { Log } from '$app/utils/log';
|
import { Log } from '$app/utils/log';
|
||||||
|
import { BLOCK_MAP_NAME, CHILDREN_MAP_NAME, META_NAME } from '$app/constants/document/name';
|
||||||
|
|
||||||
export class DocumentController {
|
export class DocumentController {
|
||||||
private readonly backendService: DocumentBackendService;
|
private readonly backendService: DocumentBackendService;
|
||||||
|
@ -2,6 +2,7 @@ import { DocumentController } from '$app/stores/effects/document/document_contro
|
|||||||
import { createAsyncThunk } from '@reduxjs/toolkit';
|
import { createAsyncThunk } from '@reduxjs/toolkit';
|
||||||
|
|
||||||
import { RootState } from '$app/stores/store';
|
import { RootState } from '$app/stores/store';
|
||||||
|
import { DOCUMENT_NAME } from '$app/constants/document/name';
|
||||||
|
|
||||||
export const deleteNodeThunk = createAsyncThunk(
|
export const deleteNodeThunk = createAsyncThunk(
|
||||||
'document/deleteNode',
|
'document/deleteNode',
|
||||||
@ -10,8 +11,9 @@ export const deleteNodeThunk = createAsyncThunk(
|
|||||||
const { getState } = thunkAPI;
|
const { getState } = thunkAPI;
|
||||||
const state = getState() as RootState;
|
const state = getState() as RootState;
|
||||||
const docId = controller.documentId;
|
const docId = controller.documentId;
|
||||||
const docState = state.document[docId];
|
const docState = state[DOCUMENT_NAME][docId];
|
||||||
const node = docState.nodes[id];
|
const node = docState.nodes[id];
|
||||||
|
|
||||||
if (!node) return;
|
if (!node) return;
|
||||||
await controller.applyActions([controller.getDeleteAction(node)]);
|
await controller.applyActions([controller.getDeleteAction(node)]);
|
||||||
}
|
}
|
||||||
|
@ -3,6 +3,7 @@ import { createAsyncThunk } from '@reduxjs/toolkit';
|
|||||||
import { rectSelectionActions } from '$app_reducers/document/slice';
|
import { rectSelectionActions } from '$app_reducers/document/slice';
|
||||||
import { getDuplicateActions } from '$app/utils/document/action';
|
import { getDuplicateActions } from '$app/utils/document/action';
|
||||||
import { RootState } from '$app/stores/store';
|
import { RootState } from '$app/stores/store';
|
||||||
|
import { DOCUMENT_NAME } from '$app/constants/document/name';
|
||||||
|
|
||||||
export const duplicateBelowNodeThunk = createAsyncThunk(
|
export const duplicateBelowNodeThunk = createAsyncThunk(
|
||||||
'document/duplicateBelowNode',
|
'document/duplicateBelowNode',
|
||||||
@ -11,8 +12,9 @@ export const duplicateBelowNodeThunk = createAsyncThunk(
|
|||||||
const { getState, dispatch } = thunkAPI;
|
const { getState, dispatch } = thunkAPI;
|
||||||
const state = getState() as RootState;
|
const state = getState() as RootState;
|
||||||
const docId = controller.documentId;
|
const docId = controller.documentId;
|
||||||
const docState = state.document[docId];
|
const docState = state[DOCUMENT_NAME][docId];
|
||||||
const node = docState.nodes[id];
|
const node = docState.nodes[id];
|
||||||
|
|
||||||
if (!node || !node.parent) return;
|
if (!node || !node.parent) return;
|
||||||
const duplicateActions = getDuplicateActions(id, node.parent, docState, controller);
|
const duplicateActions = getDuplicateActions(id, node.parent, docState, controller);
|
||||||
|
|
||||||
|
@ -4,6 +4,7 @@ import { createAsyncThunk } from '@reduxjs/toolkit';
|
|||||||
import { blockConfig } from '$app/constants/document/config';
|
import { blockConfig } from '$app/constants/document/config';
|
||||||
import { getPrevNodeId } from '$app/utils/document/block';
|
import { getPrevNodeId } from '$app/utils/document/block';
|
||||||
import { RootState } from '$app/stores/store';
|
import { RootState } from '$app/stores/store';
|
||||||
|
import { DOCUMENT_NAME } from '$app/constants/document/name';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* indent node
|
* indent node
|
||||||
@ -19,16 +20,19 @@ export const indentNodeThunk = createAsyncThunk(
|
|||||||
const { getState } = thunkAPI;
|
const { getState } = thunkAPI;
|
||||||
const state = getState() as RootState;
|
const state = getState() as RootState;
|
||||||
const docId = controller.documentId;
|
const docId = controller.documentId;
|
||||||
const docState = state.document[docId];
|
const docState = state[DOCUMENT_NAME][docId];
|
||||||
const node = docState.nodes[id];
|
const node = docState.nodes[id];
|
||||||
|
|
||||||
if (!node.parent) return;
|
if (!node.parent) return;
|
||||||
|
|
||||||
// get prev node
|
// get prev node
|
||||||
const prevNodeId = getPrevNodeId(docState, id);
|
const prevNodeId = getPrevNodeId(docState, id);
|
||||||
|
|
||||||
if (!prevNodeId) return;
|
if (!prevNodeId) return;
|
||||||
const newParentNode = docState.nodes[prevNodeId];
|
const newParentNode = docState.nodes[prevNodeId];
|
||||||
// check if prev node is allowed to have children
|
// check if prev node is allowed to have children
|
||||||
const config = blockConfig[newParentNode.type];
|
const config = blockConfig[newParentNode.type];
|
||||||
|
|
||||||
if (!config.canAddChild) return;
|
if (!config.canAddChild) return;
|
||||||
|
|
||||||
// check if prev node has children and get last child for new prev node
|
// check if prev node has children and get last child for new prev node
|
||||||
|
@ -3,6 +3,7 @@ import { DocumentController } from '$app/stores/effects/document/document_contro
|
|||||||
import { createAsyncThunk } from '@reduxjs/toolkit';
|
import { createAsyncThunk } from '@reduxjs/toolkit';
|
||||||
import { newBlock } from '$app/utils/document/block';
|
import { newBlock } from '$app/utils/document/block';
|
||||||
import { RootState } from '$app/stores/store';
|
import { RootState } from '$app/stores/store';
|
||||||
|
import { DOCUMENT_NAME } from '$app/constants/document/name';
|
||||||
|
|
||||||
export const insertAfterNodeThunk = createAsyncThunk(
|
export const insertAfterNodeThunk = createAsyncThunk(
|
||||||
'document/insertAfterNode',
|
'document/insertAfterNode',
|
||||||
@ -18,22 +19,27 @@ export const insertAfterNodeThunk = createAsyncThunk(
|
|||||||
const { getState } = thunkAPI;
|
const { getState } = thunkAPI;
|
||||||
const state = getState() as RootState;
|
const state = getState() as RootState;
|
||||||
const docId = controller.documentId;
|
const docId = controller.documentId;
|
||||||
const docState = state.document[docId];
|
const docState = state[DOCUMENT_NAME][docId];
|
||||||
const node = docState.nodes[id];
|
const node = docState.nodes[id];
|
||||||
|
|
||||||
if (!node) return;
|
if (!node) return;
|
||||||
const parentId = node.parent;
|
const parentId = node.parent;
|
||||||
|
|
||||||
if (!parentId) return;
|
if (!parentId) return;
|
||||||
// create new node
|
// create new node
|
||||||
const newNode = newBlock<any>(type, parentId, data);
|
const newNode = newBlock<any>(type, parentId, data);
|
||||||
let nodeId = newNode.id;
|
let nodeId = newNode.id;
|
||||||
const actions = [controller.getInsertAction(newNode, node.id)];
|
const actions = [controller.getInsertAction(newNode, node.id)];
|
||||||
|
|
||||||
if (type === BlockType.DividerBlock) {
|
if (type === BlockType.DividerBlock) {
|
||||||
const newTextNode = newBlock<any>(BlockType.TextBlock, parentId, {
|
const newTextNode = newBlock<any>(BlockType.TextBlock, parentId, {
|
||||||
delta: [],
|
delta: [],
|
||||||
});
|
});
|
||||||
|
|
||||||
nodeId = newTextNode.id;
|
nodeId = newTextNode.id;
|
||||||
actions.push(controller.getInsertAction(newTextNode, newNode.id));
|
actions.push(controller.getInsertAction(newTextNode, newNode.id));
|
||||||
}
|
}
|
||||||
|
|
||||||
await controller.applyActions(actions);
|
await controller.applyActions(actions);
|
||||||
|
|
||||||
return nodeId;
|
return nodeId;
|
||||||
|
@ -5,6 +5,7 @@ import Delta from 'quill-delta';
|
|||||||
import { blockConfig } from '$app/constants/document/config';
|
import { blockConfig } from '$app/constants/document/config';
|
||||||
import { getMoveChildrenActions } from '$app/utils/document/action';
|
import { getMoveChildrenActions } from '$app/utils/document/action';
|
||||||
import { RootState } from '$app/stores/store';
|
import { RootState } from '$app/stores/store';
|
||||||
|
import { DOCUMENT_NAME } from '$app/constants/document/name';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Merge two blocks
|
* Merge two blocks
|
||||||
@ -19,9 +20,10 @@ export const mergeDeltaThunk = createAsyncThunk(
|
|||||||
const { getState } = thunkAPI;
|
const { getState } = thunkAPI;
|
||||||
const state = getState() as RootState;
|
const state = getState() as RootState;
|
||||||
const docId = controller.documentId;
|
const docId = controller.documentId;
|
||||||
const docState = state.document[docId];
|
const docState = state[DOCUMENT_NAME][docId];
|
||||||
const target = docState.nodes[targetId];
|
const target = docState.nodes[targetId];
|
||||||
const source = docState.nodes[sourceId];
|
const source = docState.nodes[sourceId];
|
||||||
|
|
||||||
if (!target || !source) return;
|
if (!target || !source) return;
|
||||||
const targetDelta = new Delta(target.data.delta);
|
const targetDelta = new Delta(target.data.delta);
|
||||||
const sourceDelta = new Delta(source.data.delta);
|
const sourceDelta = new Delta(source.data.delta);
|
||||||
@ -43,9 +45,11 @@ export const mergeDeltaThunk = createAsyncThunk(
|
|||||||
children,
|
children,
|
||||||
target,
|
target,
|
||||||
});
|
});
|
||||||
|
|
||||||
actions.push(...moveActions);
|
actions.push(...moveActions);
|
||||||
// delete current block
|
// delete current block
|
||||||
const deleteAction = controller.getDeleteAction(source);
|
const deleteAction = controller.getDeleteAction(source);
|
||||||
|
|
||||||
actions.push(deleteAction);
|
actions.push(deleteAction);
|
||||||
|
|
||||||
await controller.applyActions(actions);
|
await controller.applyActions(actions);
|
||||||
|
@ -2,6 +2,7 @@ import { DocumentController } from '$app/stores/effects/document/document_contro
|
|||||||
import { createAsyncThunk } from '@reduxjs/toolkit';
|
import { createAsyncThunk } from '@reduxjs/toolkit';
|
||||||
import { blockConfig } from '$app/constants/document/config';
|
import { blockConfig } from '$app/constants/document/config';
|
||||||
import { RootState } from '$app/stores/store';
|
import { RootState } from '$app/stores/store';
|
||||||
|
import { DOCUMENT_NAME } from '$app/constants/document/name';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* outdent node
|
* outdent node
|
||||||
@ -19,11 +20,13 @@ export const outdentNodeThunk = createAsyncThunk(
|
|||||||
const { getState } = thunkAPI;
|
const { getState } = thunkAPI;
|
||||||
const state = getState() as RootState;
|
const state = getState() as RootState;
|
||||||
const docId = controller.documentId;
|
const docId = controller.documentId;
|
||||||
const docState = state.document[docId];
|
const docState = state[DOCUMENT_NAME][docId];
|
||||||
const node = docState.nodes[id];
|
const node = docState.nodes[id];
|
||||||
const parentId = node.parent;
|
const parentId = node.parent;
|
||||||
|
|
||||||
if (!parentId) return;
|
if (!parentId) return;
|
||||||
const ancestorId = docState.nodes[parentId].parent;
|
const ancestorId = docState.nodes[parentId].parent;
|
||||||
|
|
||||||
if (!ancestorId) return;
|
if (!ancestorId) return;
|
||||||
|
|
||||||
const parent = docState.nodes[parentId];
|
const parent = docState.nodes[parentId];
|
||||||
@ -32,25 +35,31 @@ export const outdentNodeThunk = createAsyncThunk(
|
|||||||
|
|
||||||
const actions = [];
|
const actions = [];
|
||||||
const moveAction = controller.getMoveAction(node, ancestorId, parentId);
|
const moveAction = controller.getMoveAction(node, ancestorId, parentId);
|
||||||
|
|
||||||
actions.push(moveAction);
|
actions.push(moveAction);
|
||||||
|
|
||||||
const config = blockConfig[node.type];
|
const config = blockConfig[node.type];
|
||||||
|
|
||||||
if (nextSiblingIds.length > 0) {
|
if (nextSiblingIds.length > 0) {
|
||||||
if (config.canAddChild) {
|
if (config.canAddChild) {
|
||||||
const children = docState.children[node.children];
|
const children = docState.children[node.children];
|
||||||
let lastChildId: string | null = null;
|
let lastChildId: string | null = null;
|
||||||
const lastIndex = children.length - 1;
|
const lastIndex = children.length - 1;
|
||||||
|
|
||||||
if (lastIndex >= 0) {
|
if (lastIndex >= 0) {
|
||||||
lastChildId = children[lastIndex];
|
lastChildId = children[lastIndex];
|
||||||
}
|
}
|
||||||
|
|
||||||
const moveChildrenActions = nextSiblingIds
|
const moveChildrenActions = nextSiblingIds
|
||||||
.reverse()
|
.reverse()
|
||||||
.map((id) => controller.getMoveAction(docState.nodes[id], node.id, lastChildId));
|
.map((id) => controller.getMoveAction(docState.nodes[id], node.id, lastChildId));
|
||||||
|
|
||||||
actions.push(...moveChildrenActions);
|
actions.push(...moveChildrenActions);
|
||||||
} else {
|
} else {
|
||||||
const moveChildrenActions = nextSiblingIds
|
const moveChildrenActions = nextSiblingIds
|
||||||
.reverse()
|
.reverse()
|
||||||
.map((id) => controller.getMoveAction(docState.nodes[id], ancestorId, node.id));
|
.map((id) => controller.getMoveAction(docState.nodes[id], ancestorId, node.id));
|
||||||
|
|
||||||
actions.push(...moveChildrenActions);
|
actions.push(...moveChildrenActions);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -3,6 +3,7 @@ import { DocumentController } from '$app/stores/effects/document/document_contro
|
|||||||
import { createAsyncThunk } from '@reduxjs/toolkit';
|
import { createAsyncThunk } from '@reduxjs/toolkit';
|
||||||
import Delta, { Op } from 'quill-delta';
|
import Delta, { Op } from 'quill-delta';
|
||||||
import { RootState } from '$app/stores/store';
|
import { RootState } from '$app/stores/store';
|
||||||
|
import { DOCUMENT_NAME } from '$app/constants/document/name';
|
||||||
|
|
||||||
export const updateNodeDeltaThunk = createAsyncThunk(
|
export const updateNodeDeltaThunk = createAsyncThunk(
|
||||||
'document/updateNodeDelta',
|
'document/updateNodeDelta',
|
||||||
@ -11,9 +12,10 @@ export const updateNodeDeltaThunk = createAsyncThunk(
|
|||||||
const { getState } = thunkAPI;
|
const { getState } = thunkAPI;
|
||||||
const state = getState() as RootState;
|
const state = getState() as RootState;
|
||||||
const docId = controller.documentId;
|
const docId = controller.documentId;
|
||||||
const docState = state.document[docId];
|
const docState = state[DOCUMENT_NAME][docId];
|
||||||
const node = docState.nodes[id];
|
const node = docState.nodes[id];
|
||||||
const diffDelta = new Delta(delta).diff(new Delta(node.data.delta || []));
|
const diffDelta = new Delta(delta).diff(new Delta(node.data.delta || []));
|
||||||
|
|
||||||
if (diffDelta.ops.length === 0) return;
|
if (diffDelta.ops.length === 0) return;
|
||||||
|
|
||||||
const newData = { ...node.data, delta };
|
const newData = { ...node.data, delta };
|
||||||
@ -39,7 +41,7 @@ export const updateNodeDataThunk = createAsyncThunk<
|
|||||||
const { getState } = thunkAPI;
|
const { getState } = thunkAPI;
|
||||||
const state = getState() as RootState;
|
const state = getState() as RootState;
|
||||||
const docId = controller.documentId;
|
const docId = controller.documentId;
|
||||||
const docState = state.document[docId];
|
const docState = state[DOCUMENT_NAME][docId];
|
||||||
const node = docState.nodes[id];
|
const node = docState.nodes[id];
|
||||||
|
|
||||||
const newData = { ...node.data, ...data };
|
const newData = { ...node.data, ...data };
|
||||||
|
@ -13,6 +13,7 @@ import {
|
|||||||
getInsertBlockActions,
|
getInsertBlockActions,
|
||||||
} from '$app/utils/document/copy_paste';
|
} from '$app/utils/document/copy_paste';
|
||||||
import { rangeActions } from '$app_reducers/document/slice';
|
import { rangeActions } from '$app_reducers/document/slice';
|
||||||
|
import { DOCUMENT_NAME, RANGE_NAME } from '$app/constants/document/name';
|
||||||
|
|
||||||
export const copyThunk = createAsyncThunk<
|
export const copyThunk = createAsyncThunk<
|
||||||
void,
|
void,
|
||||||
@ -26,11 +27,13 @@ export const copyThunk = createAsyncThunk<
|
|||||||
const { setClipboardData, isCut = false, controller } = payload;
|
const { setClipboardData, isCut = false, controller } = payload;
|
||||||
const docId = controller.documentId;
|
const docId = controller.documentId;
|
||||||
const state = getState() as RootState;
|
const state = getState() as RootState;
|
||||||
const document = state.document[docId];
|
const document = state[DOCUMENT_NAME][docId];
|
||||||
const documentRange = state.documentRange[docId];
|
const documentRange = state[RANGE_NAME][docId];
|
||||||
const startAndEndIds = getStartAndEndIdsByRange(documentRange);
|
const startAndEndIds = getStartAndEndIdsByRange(documentRange);
|
||||||
|
|
||||||
if (startAndEndIds.length === 0) return;
|
if (startAndEndIds.length === 0) return;
|
||||||
const result: DocumentBlockJSON[] = [];
|
const result: DocumentBlockJSON[] = [];
|
||||||
|
|
||||||
if (startAndEndIds.length === 1) {
|
if (startAndEndIds.length === 1) {
|
||||||
// copy single block
|
// copy single block
|
||||||
const id = startAndEndIds[0];
|
const id = startAndEndIds[0];
|
||||||
@ -38,6 +41,7 @@ export const copyThunk = createAsyncThunk<
|
|||||||
const nodeDelta = new Delta(node.data.delta);
|
const nodeDelta = new Delta(node.data.delta);
|
||||||
const range = documentRange.ranges[id] || { index: 0, length: 0 };
|
const range = documentRange.ranges[id] || { index: 0, length: 0 };
|
||||||
const isFull = range.index === 0 && range.length === nodeDelta.length();
|
const isFull = range.index === 0 && range.length === nodeDelta.length();
|
||||||
|
|
||||||
if (isFull) {
|
if (isFull) {
|
||||||
result.push(getCopyBlock(id, document, documentRange));
|
result.push(getCopyBlock(id, document, documentRange));
|
||||||
} else {
|
} else {
|
||||||
@ -54,13 +58,17 @@ export const copyThunk = createAsyncThunk<
|
|||||||
const copyIds: string[] = [];
|
const copyIds: string[] = [];
|
||||||
const [startId, endId] = startAndEndIds;
|
const [startId, endId] = startAndEndIds;
|
||||||
const middleIds = getMiddleIds(document, startId, endId);
|
const middleIds = getMiddleIds(document, startId, endId);
|
||||||
|
|
||||||
copyIds.push(startId, ...middleIds, endId);
|
copyIds.push(startId, ...middleIds, endId);
|
||||||
const map = new Map<string, DocumentBlockJSON>();
|
const map = new Map<string, DocumentBlockJSON>();
|
||||||
|
|
||||||
copyIds.forEach((id) => {
|
copyIds.forEach((id) => {
|
||||||
const block = getCopyBlock(id, document, documentRange);
|
const block = getCopyBlock(id, document, documentRange);
|
||||||
|
|
||||||
map.set(id, block);
|
map.set(id, block);
|
||||||
const node = document.nodes[id];
|
const node = document.nodes[id];
|
||||||
const parent = node.parent;
|
const parent = node.parent;
|
||||||
|
|
||||||
if (parent && map.has(parent)) {
|
if (parent && map.has(parent)) {
|
||||||
map.get(parent)!.children.push(block);
|
map.get(parent)!.children.push(block);
|
||||||
} else {
|
} else {
|
||||||
@ -68,6 +76,7 @@ export const copyThunk = createAsyncThunk<
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
setClipboardData({
|
setClipboardData({
|
||||||
json: JSON.stringify(result),
|
json: JSON.stringify(result),
|
||||||
// TODO: implement plain text and html
|
// TODO: implement plain text and html
|
||||||
@ -99,15 +108,17 @@ export const pasteThunk = createAsyncThunk<
|
|||||||
>('document/paste', async (payload, thunkAPI) => {
|
>('document/paste', async (payload, thunkAPI) => {
|
||||||
const { getState, dispatch } = thunkAPI;
|
const { getState, dispatch } = thunkAPI;
|
||||||
const { data, controller } = payload;
|
const { data, controller } = payload;
|
||||||
|
|
||||||
// delete range blocks
|
// delete range blocks
|
||||||
await dispatch(deleteRangeAndInsertThunk({ controller }));
|
await dispatch(deleteRangeAndInsertThunk({ controller }));
|
||||||
|
|
||||||
const state = getState() as RootState;
|
const state = getState() as RootState;
|
||||||
const docId = controller.documentId;
|
const docId = controller.documentId;
|
||||||
const document = state.document[docId];
|
const document = state[DOCUMENT_NAME][docId];
|
||||||
const documentRange = state.documentRange[docId];
|
const documentRange = state[RANGE_NAME][docId];
|
||||||
|
|
||||||
let pasteData;
|
let pasteData;
|
||||||
|
|
||||||
if (data.json) {
|
if (data.json) {
|
||||||
pasteData = JSON.parse(data.json) as DocumentBlockJSON[];
|
pasteData = JSON.parse(data.json) as DocumentBlockJSON[];
|
||||||
} else if (data.text) {
|
} else if (data.text) {
|
||||||
@ -115,10 +126,13 @@ export const pasteThunk = createAsyncThunk<
|
|||||||
} else if (data.html) {
|
} else if (data.html) {
|
||||||
// TODO: implement html
|
// TODO: implement html
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!pasteData) return;
|
if (!pasteData) return;
|
||||||
const { caret } = documentRange;
|
const { caret } = documentRange;
|
||||||
|
|
||||||
if (!caret) return;
|
if (!caret) return;
|
||||||
const currentBlock = document.nodes[caret.id];
|
const currentBlock = document.nodes[caret.id];
|
||||||
|
|
||||||
if (!currentBlock.parent) return;
|
if (!currentBlock.parent) return;
|
||||||
const pasteBlocks = generateBlocks(pasteData, currentBlock.parent);
|
const pasteBlocks = generateBlocks(pasteData, currentBlock.parent);
|
||||||
const currentBlockDelta = new Delta(currentBlock.data.delta);
|
const currentBlockDelta = new Delta(currentBlock.data.delta);
|
||||||
@ -128,6 +142,7 @@ export const pasteThunk = createAsyncThunk<
|
|||||||
const firstPasteBlockChildren = pasteBlocks.filter((block) => block.parent === firstPasteBlock.id);
|
const firstPasteBlockChildren = pasteBlocks.filter((block) => block.parent === firstPasteBlock.id);
|
||||||
|
|
||||||
const lastPasteBlock = pasteBlocks[pasteBlocks.length - 1];
|
const lastPasteBlock = pasteBlocks[pasteBlocks.length - 1];
|
||||||
|
|
||||||
if (type === BlockType.TextBlock && currentBlockDelta.length() === 0) {
|
if (type === BlockType.TextBlock && currentBlockDelta.length() === 0) {
|
||||||
// move current block children to first paste block
|
// move current block children to first paste block
|
||||||
const children = document.children[currentBlock.children].map((id) => document.nodes[id]);
|
const children = document.children[currentBlock.children].map((id) => document.nodes[id]);
|
||||||
@ -140,6 +155,7 @@ export const pasteThunk = createAsyncThunk<
|
|||||||
controller,
|
controller,
|
||||||
prevId,
|
prevId,
|
||||||
});
|
});
|
||||||
|
|
||||||
actions.push(...moveChildrenActions);
|
actions.push(...moveChildrenActions);
|
||||||
// delete current block
|
// delete current block
|
||||||
actions.push(controller.getDeleteAction(currentBlock));
|
actions.push(controller.getDeleteAction(currentBlock));
|
||||||
@ -173,6 +189,7 @@ export const pasteThunk = createAsyncThunk<
|
|||||||
const firstPasteBlockDelta = new Delta(firstPasteBlock.data.delta);
|
const firstPasteBlockDelta = new Delta(firstPasteBlock.data.delta);
|
||||||
const lastPasteBlockDelta = new Delta(lastPasteBlock.data.delta);
|
const lastPasteBlockDelta = new Delta(lastPasteBlock.data.delta);
|
||||||
let mergeDelta = new Delta(currentBeforeDelta.ops).concat(firstPasteBlockDelta);
|
let mergeDelta = new Delta(currentBeforeDelta.ops).concat(firstPasteBlockDelta);
|
||||||
|
|
||||||
if (firstPasteBlock.id !== lastPasteBlock.id) {
|
if (firstPasteBlock.id !== lastPasteBlock.id) {
|
||||||
// update the last block of paste data
|
// update the last block of paste data
|
||||||
actions.push(getAppendBlockDeltaAction(lastPasteBlock, currentAfterDelta, false, controller));
|
actions.push(getAppendBlockDeltaAction(lastPasteBlock, currentAfterDelta, false, controller));
|
||||||
@ -208,6 +225,7 @@ export const pasteThunk = createAsyncThunk<
|
|||||||
children: firstPasteBlockChildren,
|
children: firstPasteBlockChildren,
|
||||||
controller,
|
controller,
|
||||||
});
|
});
|
||||||
|
|
||||||
actions.push(...moveChildrenActions);
|
actions.push(...moveChildrenActions);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -3,6 +3,7 @@ import { RootState } from '$app/stores/store';
|
|||||||
import { TextAction } from '$app/interfaces/document';
|
import { TextAction } from '$app/interfaces/document';
|
||||||
import { DocumentController } from '$app/stores/effects/document/document_controller';
|
import { DocumentController } from '$app/stores/effects/document/document_controller';
|
||||||
import Delta from 'quill-delta';
|
import Delta from 'quill-delta';
|
||||||
|
import { DOCUMENT_NAME, RANGE_NAME } from '$app/constants/document/name';
|
||||||
|
|
||||||
export const getFormatActiveThunk = createAsyncThunk<
|
export const getFormatActiveThunk = createAsyncThunk<
|
||||||
boolean,
|
boolean,
|
||||||
@ -13,12 +14,13 @@ export const getFormatActiveThunk = createAsyncThunk<
|
|||||||
>('document/getFormatActive', async ({ format, docId }, thunkAPI) => {
|
>('document/getFormatActive', async ({ format, docId }, thunkAPI) => {
|
||||||
const { getState } = thunkAPI;
|
const { getState } = thunkAPI;
|
||||||
const state = getState() as RootState;
|
const state = getState() as RootState;
|
||||||
const document = state.document[docId];
|
const document = state[DOCUMENT_NAME][docId];
|
||||||
const documentRange = state.documentRange[docId];
|
const documentRange = state[RANGE_NAME][docId];
|
||||||
const { ranges } = documentRange;
|
const { ranges } = documentRange;
|
||||||
const match = (delta: Delta, format: TextAction) => {
|
const match = (delta: Delta, format: TextAction) => {
|
||||||
return delta.ops.every((op) => op.attributes?.[format]);
|
return delta.ops.every((op) => op.attributes?.[format]);
|
||||||
};
|
};
|
||||||
|
|
||||||
return Object.entries(ranges).every(([id, range]) => {
|
return Object.entries(ranges).every(([id, range]) => {
|
||||||
const node = document.nodes[id];
|
const node = document.nodes[id];
|
||||||
const delta = new Delta(node.data?.delta);
|
const delta = new Delta(node.data?.delta);
|
||||||
@ -37,6 +39,7 @@ export const toggleFormatThunk = createAsyncThunk(
|
|||||||
const { format, controller } = payload;
|
const { format, controller } = payload;
|
||||||
const docId = controller.documentId;
|
const docId = controller.documentId;
|
||||||
let isActive = payload.isActive;
|
let isActive = payload.isActive;
|
||||||
|
|
||||||
if (isActive === undefined) {
|
if (isActive === undefined) {
|
||||||
const { payload: active } = await dispatch(
|
const { payload: active } = await dispatch(
|
||||||
getFormatActiveThunk({
|
getFormatActiveThunk({
|
||||||
@ -44,12 +47,14 @@ export const toggleFormatThunk = createAsyncThunk(
|
|||||||
docId,
|
docId,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
isActive = !!active;
|
isActive = !!active;
|
||||||
}
|
}
|
||||||
|
|
||||||
const formatValue = isActive ? undefined : true;
|
const formatValue = isActive ? undefined : true;
|
||||||
const state = getState() as RootState;
|
const state = getState() as RootState;
|
||||||
const document = state.document[docId];
|
const document = state[DOCUMENT_NAME][docId];
|
||||||
const documentRange = state.documentRange[docId];
|
const documentRange = state[RANGE_NAME][docId];
|
||||||
const { ranges } = documentRange;
|
const { ranges } = documentRange;
|
||||||
|
|
||||||
const toggle = (delta: Delta, format: TextAction, value: string | boolean | undefined) => {
|
const toggle = (delta: Delta, format: TextAction, value: string | boolean | undefined) => {
|
||||||
@ -58,11 +63,13 @@ export const toggleFormatThunk = createAsyncThunk(
|
|||||||
...op.attributes,
|
...op.attributes,
|
||||||
[format]: value,
|
[format]: value,
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
insert: op.insert,
|
insert: op.insert,
|
||||||
attributes: attributes,
|
attributes: attributes,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
return new Delta(newOps);
|
return new Delta(newOps);
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -85,6 +92,7 @@ export const toggleFormatThunk = createAsyncThunk(
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
await controller.applyActions(actions);
|
await controller.applyActions(actions);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
@ -17,6 +17,8 @@ import { rangeActions } from '$app_reducers/document/slice';
|
|||||||
import { RootState } from '$app/stores/store';
|
import { RootState } from '$app/stores/store';
|
||||||
import { blockConfig } from '$app/constants/document/config';
|
import { blockConfig } from '$app/constants/document/config';
|
||||||
import { Keyboard } from '$app/constants/document/keyboard';
|
import { Keyboard } from '$app/constants/document/keyboard';
|
||||||
|
import { DOCUMENT_NAME, RANGE_NAME } from '$app/constants/document/name';
|
||||||
|
import { getPreviousWordIndex } from '$app/utils/document/delta';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Delete a block by backspace or delete key
|
* Delete a block by backspace or delete key
|
||||||
@ -33,20 +35,25 @@ export const backspaceDeleteActionForBlockThunk = createAsyncThunk(
|
|||||||
const { dispatch, getState } = thunkAPI;
|
const { dispatch, getState } = thunkAPI;
|
||||||
const state = (getState() as RootState).document[docId];
|
const state = (getState() as RootState).document[docId];
|
||||||
const node = state.nodes[id];
|
const node = state.nodes[id];
|
||||||
|
|
||||||
if (!node.parent) return;
|
if (!node.parent) return;
|
||||||
const parent = state.nodes[node.parent];
|
const parent = state.nodes[node.parent];
|
||||||
const children = state.children[parent.children];
|
const children = state.children[parent.children];
|
||||||
const index = children.indexOf(id);
|
const index = children.indexOf(id);
|
||||||
const nextNodeId = children[index + 1];
|
const nextNodeId = children[index + 1];
|
||||||
|
|
||||||
// turn to text block
|
// turn to text block
|
||||||
if (node.type !== BlockType.TextBlock) {
|
if (node.type !== BlockType.TextBlock) {
|
||||||
await dispatch(turnToTextBlockThunk({ id, controller }));
|
await dispatch(turnToTextBlockThunk({ id, controller }));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const isTopLevel = parent.type === BlockType.PageBlock;
|
const isTopLevel = parent.type === BlockType.PageBlock;
|
||||||
|
|
||||||
if (isTopLevel || nextNodeId) {
|
if (isTopLevel || nextNodeId) {
|
||||||
// merge to previous line
|
// merge to previous line
|
||||||
const prevLine = findPrevHasDeltaNode(state, id);
|
const prevLine = findPrevHasDeltaNode(state, id);
|
||||||
|
|
||||||
if (!prevLine) return;
|
if (!prevLine) return;
|
||||||
const caretIndex = new Delta(prevLine.data.delta).length();
|
const caretIndex = new Delta(prevLine.data.delta).length();
|
||||||
const caret = {
|
const caret = {
|
||||||
@ -54,6 +61,7 @@ export const backspaceDeleteActionForBlockThunk = createAsyncThunk(
|
|||||||
index: caretIndex,
|
index: caretIndex,
|
||||||
length: 0,
|
length: 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
await dispatch(
|
await dispatch(
|
||||||
mergeDeltaThunk({
|
mergeDeltaThunk({
|
||||||
sourceId: id,
|
sourceId: id,
|
||||||
@ -70,6 +78,7 @@ export const backspaceDeleteActionForBlockThunk = createAsyncThunk(
|
|||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// outdent
|
// outdent
|
||||||
await dispatch(outdentNodeThunk({ id, controller }));
|
await dispatch(outdentNodeThunk({ id, controller }));
|
||||||
}
|
}
|
||||||
@ -88,21 +97,25 @@ export const enterActionForBlockThunk = createAsyncThunk(
|
|||||||
const { getState, dispatch } = thunkAPI;
|
const { getState, dispatch } = thunkAPI;
|
||||||
const state = getState() as RootState;
|
const state = getState() as RootState;
|
||||||
const docId = controller.documentId;
|
const docId = controller.documentId;
|
||||||
const documentState = state.document[docId];
|
const documentState = state[DOCUMENT_NAME][docId];
|
||||||
const node = documentState.nodes[id];
|
const node = documentState.nodes[id];
|
||||||
const caret = state.documentRange[docId]?.caret;
|
const caret = state[RANGE_NAME][docId]?.caret;
|
||||||
|
|
||||||
if (!node || !caret || caret.id !== id) return;
|
if (!node || !caret || caret.id !== id) return;
|
||||||
const delta = new Delta(node.data.delta);
|
const delta = new Delta(node.data.delta);
|
||||||
|
|
||||||
if (delta.length() === 0 && node.type !== BlockType.TextBlock) {
|
if (delta.length() === 0 && node.type !== BlockType.TextBlock) {
|
||||||
// If the node is not a text block, turn it to a text block
|
// If the node is not a text block, turn it to a text block
|
||||||
await dispatch(turnToTextBlockThunk({ id, controller }));
|
await dispatch(turnToTextBlockThunk({ id, controller }));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const nodeDelta = delta.slice(0, caret.index);
|
const nodeDelta = delta.slice(0, caret.index);
|
||||||
|
|
||||||
const insertNodeDelta = new Delta(node.data.delta).slice(caret.index + caret.length);
|
const insertNodeDelta = new Delta(node.data.delta).slice(caret.index + caret.length);
|
||||||
|
|
||||||
const insertNodeAction = getInsertEnterNodeAction(node, insertNodeDelta, controller);
|
const insertNodeAction = getInsertEnterNodeAction(node, insertNodeDelta, controller);
|
||||||
|
|
||||||
if (!insertNodeAction) return;
|
if (!insertNodeAction) return;
|
||||||
const updateNode = {
|
const updateNode = {
|
||||||
...node,
|
...node,
|
||||||
@ -122,6 +135,7 @@ export const enterActionForBlockThunk = createAsyncThunk(
|
|||||||
)
|
)
|
||||||
: [];
|
: [];
|
||||||
const actions = [insertNodeAction.action, controller.getUpdateAction(updateNode), ...moveChildrenAction];
|
const actions = [insertNodeAction.action, controller.getUpdateAction(updateNode), ...moveChildrenAction];
|
||||||
|
|
||||||
await controller.applyActions(actions);
|
await controller.applyActions(actions);
|
||||||
|
|
||||||
dispatch(rangeActions.initialState(docId));
|
dispatch(rangeActions.initialState(docId));
|
||||||
@ -142,6 +156,7 @@ export const tabActionForBlockThunk = createAsyncThunk(
|
|||||||
'document/tabActionForBlock',
|
'document/tabActionForBlock',
|
||||||
async (payload: { id: string; controller: DocumentController }, thunkAPI) => {
|
async (payload: { id: string; controller: DocumentController }, thunkAPI) => {
|
||||||
const { dispatch } = thunkAPI;
|
const { dispatch } = thunkAPI;
|
||||||
|
|
||||||
return dispatch(indentNodeThunk(payload));
|
return dispatch(indentNodeThunk(payload));
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
@ -152,10 +167,11 @@ export const upDownActionForBlockThunk = createAsyncThunk(
|
|||||||
const { docId, id, down } = payload;
|
const { docId, id, down } = payload;
|
||||||
const { dispatch, getState } = thunkAPI;
|
const { dispatch, getState } = thunkAPI;
|
||||||
const state = getState() as RootState;
|
const state = getState() as RootState;
|
||||||
const documentState = state.document[docId];
|
const documentState = state[DOCUMENT_NAME][docId];
|
||||||
const rangeState = state.documentRange[docId];
|
const rangeState = state[RANGE_NAME][docId];
|
||||||
const caret = rangeState.caret;
|
const caret = rangeState.caret;
|
||||||
const node = documentState.nodes[id];
|
const node = documentState.nodes[id];
|
||||||
|
|
||||||
if (!node || !caret || id !== caret.id) return;
|
if (!node || !caret || id !== caret.id) return;
|
||||||
|
|
||||||
let newCaret;
|
let newCaret;
|
||||||
@ -165,9 +181,11 @@ export const upDownActionForBlockThunk = createAsyncThunk(
|
|||||||
} else {
|
} else {
|
||||||
newCaret = transformToPrevLineCaret(documentState, caret);
|
newCaret = transformToPrevLineCaret(documentState, caret);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!newCaret) {
|
if (!newCaret) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
dispatch(rangeActions.initialState(docId));
|
dispatch(rangeActions.initialState(docId));
|
||||||
dispatch(
|
dispatch(
|
||||||
rangeActions.setCaret({
|
rangeActions.setCaret({
|
||||||
@ -184,12 +202,14 @@ export const leftActionForBlockThunk = createAsyncThunk(
|
|||||||
const { id, docId } = payload;
|
const { id, docId } = payload;
|
||||||
const { dispatch, getState } = thunkAPI;
|
const { dispatch, getState } = thunkAPI;
|
||||||
const state = getState() as RootState;
|
const state = getState() as RootState;
|
||||||
const documentState = state.document[docId];
|
const documentState = state[DOCUMENT_NAME][docId];
|
||||||
const rangeState = state.documentRange[docId];
|
const rangeState = state[RANGE_NAME][docId];
|
||||||
const caret = rangeState.caret;
|
const caret = rangeState.caret;
|
||||||
const node = documentState.nodes[id];
|
const node = documentState.nodes[id];
|
||||||
|
|
||||||
if (!node || !caret || id !== caret.id) return;
|
if (!node || !caret || id !== caret.id) return;
|
||||||
let newCaret: RangeStatic;
|
let newCaret: RangeStatic;
|
||||||
|
|
||||||
if (caret.length > 0) {
|
if (caret.length > 0) {
|
||||||
newCaret = {
|
newCaret = {
|
||||||
id,
|
id,
|
||||||
@ -198,15 +218,20 @@ export const leftActionForBlockThunk = createAsyncThunk(
|
|||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
if (caret.index > 0) {
|
if (caret.index > 0) {
|
||||||
|
const delta = new Delta(node.data.delta);
|
||||||
|
const newIndex = getPreviousWordIndex(delta, caret.index);
|
||||||
|
|
||||||
newCaret = {
|
newCaret = {
|
||||||
id,
|
id,
|
||||||
index: caret.index - 1,
|
index: newIndex,
|
||||||
length: 0,
|
length: 0,
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
const prevNode = findPrevHasDeltaNode(documentState, id);
|
const prevNode = findPrevHasDeltaNode(documentState, id);
|
||||||
|
|
||||||
if (!prevNode) return;
|
if (!prevNode) return;
|
||||||
const prevDelta = new Delta(prevNode.data.delta);
|
const prevDelta = new Delta(prevNode.data.delta);
|
||||||
|
|
||||||
newCaret = {
|
newCaret = {
|
||||||
id: prevNode.id,
|
id: prevNode.id,
|
||||||
index: prevDelta.length(),
|
index: prevDelta.length(),
|
||||||
@ -218,6 +243,7 @@ export const leftActionForBlockThunk = createAsyncThunk(
|
|||||||
if (!newCaret) {
|
if (!newCaret) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
dispatch(rangeActions.initialState(docId));
|
dispatch(rangeActions.initialState(docId));
|
||||||
dispatch(
|
dispatch(
|
||||||
rangeActions.setCaret({
|
rangeActions.setCaret({
|
||||||
@ -234,14 +260,16 @@ export const rightActionForBlockThunk = createAsyncThunk(
|
|||||||
const { id, docId } = payload;
|
const { id, docId } = payload;
|
||||||
const { dispatch, getState } = thunkAPI;
|
const { dispatch, getState } = thunkAPI;
|
||||||
const state = getState() as RootState;
|
const state = getState() as RootState;
|
||||||
const documentState = state.document[docId];
|
const documentState = state[DOCUMENT_NAME][docId];
|
||||||
const rangeState = state.documentRange[docId];
|
const rangeState = state[RANGE_NAME][docId];
|
||||||
const caret = rangeState.caret;
|
const caret = rangeState.caret;
|
||||||
const node = documentState.nodes[id];
|
const node = documentState.nodes[id];
|
||||||
|
|
||||||
if (!node || !caret || id !== caret.id) return;
|
if (!node || !caret || id !== caret.id) return;
|
||||||
let newCaret: RangeStatic;
|
let newCaret: RangeStatic;
|
||||||
const delta = new Delta(node.data.delta);
|
const delta = new Delta(node.data.delta);
|
||||||
const deltaLength = delta.length();
|
const deltaLength = delta.length();
|
||||||
|
|
||||||
if (caret.length > 0) {
|
if (caret.length > 0) {
|
||||||
newCaret = {
|
newCaret = {
|
||||||
id,
|
id,
|
||||||
@ -251,6 +279,7 @@ export const rightActionForBlockThunk = createAsyncThunk(
|
|||||||
} else {
|
} else {
|
||||||
if (caret.index < deltaLength) {
|
if (caret.index < deltaLength) {
|
||||||
const newIndex = caret.index + caret.length + 1;
|
const newIndex = caret.index + caret.length + 1;
|
||||||
|
|
||||||
newCaret = {
|
newCaret = {
|
||||||
id,
|
id,
|
||||||
index: newIndex > deltaLength ? deltaLength : newIndex,
|
index: newIndex > deltaLength ? deltaLength : newIndex,
|
||||||
@ -258,6 +287,7 @@ export const rightActionForBlockThunk = createAsyncThunk(
|
|||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
const nextNode = findNextHasDeltaNode(documentState, id);
|
const nextNode = findNextHasDeltaNode(documentState, id);
|
||||||
|
|
||||||
if (!nextNode) return;
|
if (!nextNode) return;
|
||||||
newCaret = {
|
newCaret = {
|
||||||
id: nextNode.id,
|
id: nextNode.id,
|
||||||
@ -270,6 +300,7 @@ export const rightActionForBlockThunk = createAsyncThunk(
|
|||||||
if (!newCaret) {
|
if (!newCaret) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
dispatch(rangeActions.initialState(docId));
|
dispatch(rangeActions.initialState(docId));
|
||||||
|
|
||||||
dispatch(
|
dispatch(
|
||||||
@ -285,6 +316,7 @@ export const shiftTabActionForBlockThunk = createAsyncThunk(
|
|||||||
'document/shiftTabActionForBlock',
|
'document/shiftTabActionForBlock',
|
||||||
async (payload: { id: string; controller: DocumentController }, thunkAPI) => {
|
async (payload: { id: string; controller: DocumentController }, thunkAPI) => {
|
||||||
const { dispatch } = thunkAPI;
|
const { dispatch } = thunkAPI;
|
||||||
|
|
||||||
return dispatch(outdentNodeThunk(payload));
|
return dispatch(outdentNodeThunk(payload));
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
@ -301,8 +333,8 @@ export const arrowActionForRangeThunk = createAsyncThunk(
|
|||||||
const { dispatch, getState } = thunkAPI;
|
const { dispatch, getState } = thunkAPI;
|
||||||
const { key, docId } = payload;
|
const { key, docId } = payload;
|
||||||
const state = getState() as RootState;
|
const state = getState() as RootState;
|
||||||
const documentState = state.document[docId];
|
const documentState = state[DOCUMENT_NAME][docId];
|
||||||
const rangeState = state.documentRange[docId];
|
const rangeState = state[RANGE_NAME][docId];
|
||||||
let caret;
|
let caret;
|
||||||
const leftCaret = getLeftCaretByRange(rangeState);
|
const leftCaret = getLeftCaretByRange(rangeState);
|
||||||
const rightCaret = getRightCaretByRange(rangeState);
|
const rightCaret = getRightCaretByRange(rangeState);
|
||||||
@ -323,6 +355,7 @@ export const arrowActionForRangeThunk = createAsyncThunk(
|
|||||||
caret = transformToNextLineCaret(documentState, rightCaret);
|
caret = transformToNextLineCaret(documentState, rightCaret);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!caret) return;
|
if (!caret) return;
|
||||||
dispatch(rangeActions.initialState(docId));
|
dispatch(rangeActions.initialState(docId));
|
||||||
dispatch(
|
dispatch(
|
||||||
|
@ -3,6 +3,7 @@ import { DocumentController } from '$app/stores/effects/document/document_contro
|
|||||||
import Delta from 'quill-delta';
|
import Delta from 'quill-delta';
|
||||||
import { linkPopoverActions, rangeActions } from '$app_reducers/document/slice';
|
import { linkPopoverActions, rangeActions } from '$app_reducers/document/slice';
|
||||||
import { RootState } from '$app/stores/store';
|
import { RootState } from '$app/stores/store';
|
||||||
|
import { DOCUMENT_NAME, RANGE_NAME, TEXT_LINK_NAME } from '$app/constants/document/name';
|
||||||
|
|
||||||
export const formatLinkThunk = createAsyncThunk<
|
export const formatLinkThunk = createAsyncThunk<
|
||||||
boolean,
|
boolean,
|
||||||
@ -14,16 +15,19 @@ export const formatLinkThunk = createAsyncThunk<
|
|||||||
const { getState } = thunkAPI;
|
const { getState } = thunkAPI;
|
||||||
const docId = controller.documentId;
|
const docId = controller.documentId;
|
||||||
const state = getState() as RootState;
|
const state = getState() as RootState;
|
||||||
const documentState = state.document[docId];
|
const documentState = state[DOCUMENT_NAME][docId];
|
||||||
const linkPopover = state.documentLinkPopover[docId];
|
const linkPopover = state[TEXT_LINK_NAME][docId];
|
||||||
|
|
||||||
if (!linkPopover) return false;
|
if (!linkPopover) return false;
|
||||||
const { selection, id, href, title = '' } = linkPopover;
|
const { selection, id, href, title = '' } = linkPopover;
|
||||||
|
|
||||||
if (!selection || !id) return false;
|
if (!selection || !id) return false;
|
||||||
const node = documentState.nodes[id];
|
const node = documentState.nodes[id];
|
||||||
const nodeDelta = new Delta(node.data?.delta);
|
const nodeDelta = new Delta(node.data?.delta);
|
||||||
const index = selection.index || 0;
|
const index = selection.index || 0;
|
||||||
const length = selection.length || 0;
|
const length = selection.length || 0;
|
||||||
const regex = new RegExp(/^(https?:\/\/)?([\da-z.-]+)\.([a-z.]{2,6})([/\w .-]*)*\/?$/);
|
const regex = new RegExp(/^(https?:\/\/)?([\da-z.-]+)\.([a-z.]{2,6})([/\w .-]*)*\/?$/);
|
||||||
|
|
||||||
if (href !== undefined && !regex.test(href)) {
|
if (href !== undefined && !regex.test(href)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@ -41,6 +45,7 @@ export const formatLinkThunk = createAsyncThunk<
|
|||||||
delta: newDelta.ops,
|
delta: newDelta.ops,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
await controller.applyActions([updateAction]);
|
await controller.applyActions([updateAction]);
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
@ -53,10 +58,11 @@ export const newLinkThunk = createAsyncThunk<
|
|||||||
>('document/newLink', async ({ docId }, thunkAPI) => {
|
>('document/newLink', async ({ docId }, thunkAPI) => {
|
||||||
const { getState, dispatch } = thunkAPI;
|
const { getState, dispatch } = thunkAPI;
|
||||||
const state = getState() as RootState;
|
const state = getState() as RootState;
|
||||||
const documentState = state.document[docId];
|
const documentState = state[DOCUMENT_NAME][docId];
|
||||||
const documentRange = state.documentRange[docId];
|
const documentRange = state[RANGE_NAME][docId];
|
||||||
|
|
||||||
const { caret } = documentRange;
|
const { caret } = documentRange;
|
||||||
|
|
||||||
if (!caret) return;
|
if (!caret) return;
|
||||||
const { index, length, id } = caret;
|
const { index, length, id } = caret;
|
||||||
|
|
||||||
@ -66,11 +72,14 @@ export const newLinkThunk = createAsyncThunk<
|
|||||||
const href = op?.attributes?.href as string;
|
const href = op?.attributes?.href as string;
|
||||||
|
|
||||||
const domSelection = window.getSelection();
|
const domSelection = window.getSelection();
|
||||||
|
|
||||||
if (!domSelection) return;
|
if (!domSelection) return;
|
||||||
const domRange = domSelection.rangeCount > 0 ? domSelection.getRangeAt(0) : null;
|
const domRange = domSelection.rangeCount > 0 ? domSelection.getRangeAt(0) : null;
|
||||||
|
|
||||||
if (!domRange) return;
|
if (!domRange) return;
|
||||||
const title = domSelection.toString();
|
const title = domSelection.toString();
|
||||||
const { top, left, height, width } = domRange.getBoundingClientRect();
|
const { top, left, height, width } = domRange.getBoundingClientRect();
|
||||||
|
|
||||||
dispatch(rangeActions.initialState(docId));
|
dispatch(rangeActions.initialState(docId));
|
||||||
dispatch(
|
dispatch(
|
||||||
linkPopoverActions.setLinkPopover({
|
linkPopoverActions.setLinkPopover({
|
||||||
|
@ -8,6 +8,7 @@ import { blockConfig } from '$app/constants/document/config';
|
|||||||
import Delta, { Op } from 'quill-delta';
|
import Delta, { Op } from 'quill-delta';
|
||||||
import { getDeltaText } from '$app/utils/document/delta';
|
import { getDeltaText } from '$app/utils/document/delta';
|
||||||
import { RootState } from '$app/stores/store';
|
import { RootState } from '$app/stores/store';
|
||||||
|
import { DOCUMENT_NAME } from '$app/constants/document/name';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* add block below click
|
* add block below click
|
||||||
@ -22,6 +23,7 @@ export const addBlockBelowClickThunk = createAsyncThunk(
|
|||||||
const { dispatch, getState } = thunkAPI;
|
const { dispatch, getState } = thunkAPI;
|
||||||
const state = (getState() as RootState).document[docId];
|
const state = (getState() as RootState).document[docId];
|
||||||
const node = state.nodes[id];
|
const node = state.nodes[id];
|
||||||
|
|
||||||
if (!node) return;
|
if (!node) return;
|
||||||
const delta = (node.data.delta as Op[]) || [];
|
const delta = (node.data.delta as Op[]) || [];
|
||||||
const text = delta.map((d) => d.insert).join('');
|
const text = delta.map((d) => d.insert).join('');
|
||||||
@ -31,6 +33,7 @@ export const addBlockBelowClickThunk = createAsyncThunk(
|
|||||||
const { payload: newBlockId } = await dispatch(
|
const { payload: newBlockId } = await dispatch(
|
||||||
insertAfterNodeThunk({ id: id, type: BlockType.TextBlock, controller, data: { delta: [] } })
|
insertAfterNodeThunk({ id: id, type: BlockType.TextBlock, controller, data: { delta: [] } })
|
||||||
);
|
);
|
||||||
|
|
||||||
if (newBlockId) {
|
if (newBlockId) {
|
||||||
dispatch(
|
dispatch(
|
||||||
rangeActions.setCaret({
|
rangeActions.setCaret({
|
||||||
@ -40,8 +43,10 @@ export const addBlockBelowClickThunk = createAsyncThunk(
|
|||||||
);
|
);
|
||||||
dispatch(slashCommandActions.openSlashCommand({ docId, blockId: newBlockId as string }));
|
dispatch(slashCommandActions.openSlashCommand({ docId, blockId: newBlockId as string }));
|
||||||
}
|
}
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// if current block is empty, open slash command
|
// if current block is empty, open slash command
|
||||||
dispatch(
|
dispatch(
|
||||||
rangeActions.setCaret({
|
rangeActions.setCaret({
|
||||||
@ -76,8 +81,9 @@ export const triggerSlashCommandActionThunk = createAsyncThunk(
|
|||||||
const { dispatch, getState } = thunkAPI;
|
const { dispatch, getState } = thunkAPI;
|
||||||
const docId = controller.documentId;
|
const docId = controller.documentId;
|
||||||
const state = getState() as RootState;
|
const state = getState() as RootState;
|
||||||
const document = state.document[docId];
|
const document = state[DOCUMENT_NAME][docId];
|
||||||
const node = document.nodes[id];
|
const node = document.nodes[id];
|
||||||
|
|
||||||
if (!node) return;
|
if (!node) return;
|
||||||
const delta = new Delta(node.data.delta);
|
const delta = new Delta(node.data.delta);
|
||||||
const text = getDeltaText(delta);
|
const text = getDeltaText(delta);
|
||||||
@ -107,6 +113,7 @@ export const triggerSlashCommandActionThunk = createAsyncThunk(
|
|||||||
delta: delta.slice(1, delta.length()).ops,
|
delta: delta.slice(1, delta.length()).ops,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
await controller.applyActions([controller.getUpdateAction(updateNode)]);
|
await controller.applyActions([controller.getUpdateAction(updateNode)]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -13,6 +13,7 @@ import {
|
|||||||
} from '$app/utils/document/action';
|
} from '$app/utils/document/action';
|
||||||
import { RangeState, SplitRelationship } from '$app/interfaces/document';
|
import { RangeState, SplitRelationship } from '$app/interfaces/document';
|
||||||
import { blockConfig } from '$app/constants/document/config';
|
import { blockConfig } from '$app/constants/document/config';
|
||||||
|
import { DOCUMENT_NAME, RANGE_NAME } from '$app/constants/document/name';
|
||||||
|
|
||||||
interface storeRangeThunkPayload {
|
interface storeRangeThunkPayload {
|
||||||
docId: string;
|
docId: string;
|
||||||
@ -32,17 +33,20 @@ export const storeRangeThunk = createAsyncThunk('document/storeRange', (payload:
|
|||||||
const { docId, id, range } = payload;
|
const { docId, id, range } = payload;
|
||||||
const { dispatch, getState } = thunkAPI;
|
const { dispatch, getState } = thunkAPI;
|
||||||
const state = getState() as RootState;
|
const state = getState() as RootState;
|
||||||
const rangeState = state.documentRange[docId];
|
const rangeState = state[RANGE_NAME][docId];
|
||||||
const documentState = state.document[docId];
|
const documentState = state[DOCUMENT_NAME][docId];
|
||||||
// we need amend range between anchor and focus
|
// we need amend range between anchor and focus
|
||||||
const { anchor, focus, isDragging } = rangeState;
|
const { anchor, focus, isDragging } = rangeState;
|
||||||
|
|
||||||
if (!isDragging || !anchor || !focus) return;
|
if (!isDragging || !anchor || !focus) return;
|
||||||
|
|
||||||
const ranges: RangeState['ranges'] = {};
|
const ranges: RangeState['ranges'] = {};
|
||||||
|
|
||||||
ranges[id] = range;
|
ranges[id] = range;
|
||||||
// pin anchor index
|
// pin anchor index
|
||||||
let anchorIndex = anchor.point.index;
|
let anchorIndex = anchor.point.index;
|
||||||
let anchorLength = anchor.point.length;
|
let anchorLength = anchor.point.length;
|
||||||
|
|
||||||
if (anchorIndex === undefined || anchorLength === undefined) {
|
if (anchorIndex === undefined || anchorLength === undefined) {
|
||||||
dispatch(
|
dispatch(
|
||||||
rangeActions.setAnchorPointRange({
|
rangeActions.setAnchorPointRange({
|
||||||
@ -68,14 +72,17 @@ export const storeRangeThunk = createAsyncThunk('document/storeRange', (payload:
|
|||||||
// amend anchor range because slatejs will stop update selection when dragging quickly
|
// amend anchor range because slatejs will stop update selection when dragging quickly
|
||||||
const isForward = anchor.point.y < focus.point.y;
|
const isForward = anchor.point.y < focus.point.y;
|
||||||
const anchorDelta = new Delta(documentState.nodes[anchor.id].data.delta);
|
const anchorDelta = new Delta(documentState.nodes[anchor.id].data.delta);
|
||||||
|
|
||||||
if (isForward) {
|
if (isForward) {
|
||||||
const selectedDelta = anchorDelta.slice(anchorIndex);
|
const selectedDelta = anchorDelta.slice(anchorIndex);
|
||||||
|
|
||||||
ranges[anchor.id] = {
|
ranges[anchor.id] = {
|
||||||
index: anchorIndex,
|
index: anchorIndex,
|
||||||
length: selectedDelta.length(),
|
length: selectedDelta.length(),
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
const selectedDelta = anchorDelta.slice(0, anchorIndex + anchorLength);
|
const selectedDelta = anchorDelta.slice(0, anchorIndex + anchorLength);
|
||||||
|
|
||||||
ranges[anchor.id] = {
|
ranges[anchor.id] = {
|
||||||
index: 0,
|
index: 0,
|
||||||
length: selectedDelta.length(),
|
length: selectedDelta.length(),
|
||||||
@ -87,6 +94,7 @@ export const storeRangeThunk = createAsyncThunk('document/storeRange', (payload:
|
|||||||
const endId = isForward ? focus.id : anchor.id;
|
const endId = isForward ? focus.id : anchor.id;
|
||||||
|
|
||||||
const middleIds = getMiddleIds(documentState, startId, endId);
|
const middleIds = getMiddleIds(documentState, startId, endId);
|
||||||
|
|
||||||
middleIds.forEach((id) => {
|
middleIds.forEach((id) => {
|
||||||
const node = documentState.nodes[id];
|
const node = documentState.nodes[id];
|
||||||
|
|
||||||
@ -121,19 +129,22 @@ export const deleteRangeAndInsertThunk = createAsyncThunk(
|
|||||||
const docId = controller.documentId;
|
const docId = controller.documentId;
|
||||||
const { getState, dispatch } = thunkAPI;
|
const { getState, dispatch } = thunkAPI;
|
||||||
const state = getState() as RootState;
|
const state = getState() as RootState;
|
||||||
const rangeState = state.documentRange[docId];
|
const rangeState = state[RANGE_NAME][docId];
|
||||||
const documentState = state.document[docId];
|
const documentState = state[DOCUMENT_NAME][docId];
|
||||||
|
|
||||||
const actions = [];
|
const actions = [];
|
||||||
// get merge actions
|
// get merge actions
|
||||||
const mergeActions = getMergeEndDeltaToStartActionsByRange(state, controller, insertDelta);
|
const mergeActions = getMergeEndDeltaToStartActionsByRange(state, controller, insertDelta);
|
||||||
|
|
||||||
if (mergeActions) {
|
if (mergeActions) {
|
||||||
actions.push(...mergeActions);
|
actions.push(...mergeActions);
|
||||||
}
|
}
|
||||||
|
|
||||||
// get middle nodes
|
// get middle nodes
|
||||||
const middleIds = getMiddleIdsByRange(rangeState, documentState);
|
const middleIds = getMiddleIdsByRange(rangeState, documentState);
|
||||||
// delete middle nodes
|
// delete middle nodes
|
||||||
const deleteMiddleNodesActions = middleIds?.map((id) => controller.getDeleteAction(documentState.nodes[id])) || [];
|
const deleteMiddleNodesActions = middleIds?.map((id) => controller.getDeleteAction(documentState.nodes[id])) || [];
|
||||||
|
|
||||||
actions.push(...deleteMiddleNodesActions);
|
actions.push(...deleteMiddleNodesActions);
|
||||||
|
|
||||||
const caret = getAfterMergeCaretByRange(rangeState, insertDelta);
|
const caret = getAfterMergeCaretByRange(rangeState, insertDelta);
|
||||||
@ -170,11 +181,12 @@ export const deleteRangeAndInsertEnterThunk = createAsyncThunk(
|
|||||||
const { getState, dispatch } = thunkAPI;
|
const { getState, dispatch } = thunkAPI;
|
||||||
const docId = controller.documentId;
|
const docId = controller.documentId;
|
||||||
const state = getState() as RootState;
|
const state = getState() as RootState;
|
||||||
const rangeState = state.documentRange[docId];
|
const rangeState = state[RANGE_NAME][docId];
|
||||||
const documentState = state.document[docId];
|
const documentState = state[DOCUMENT_NAME][docId];
|
||||||
const actions = [];
|
const actions = [];
|
||||||
|
|
||||||
const { startDelta, endDelta, endNode, startNode } = getStartAndEndExtentDelta(documentState, rangeState) || {};
|
const { startDelta, endDelta, endNode, startNode } = getStartAndEndExtentDelta(documentState, rangeState) || {};
|
||||||
|
|
||||||
if (!startDelta || !endDelta || !endNode || !startNode) return;
|
if (!startDelta || !endDelta || !endNode || !startNode) return;
|
||||||
|
|
||||||
// get middle nodes
|
// get middle nodes
|
||||||
@ -182,12 +194,14 @@ export const deleteRangeAndInsertEnterThunk = createAsyncThunk(
|
|||||||
|
|
||||||
let newStartDelta = new Delta(startDelta);
|
let newStartDelta = new Delta(startDelta);
|
||||||
let caret = null;
|
let caret = null;
|
||||||
|
|
||||||
if (shiftKey) {
|
if (shiftKey) {
|
||||||
newStartDelta = newStartDelta.insert('\n').concat(endDelta);
|
newStartDelta = newStartDelta.insert('\n').concat(endDelta);
|
||||||
caret = getAfterMergeCaretByRange(rangeState, new Delta().insert('\n'));
|
caret = getAfterMergeCaretByRange(rangeState, new Delta().insert('\n'));
|
||||||
} else {
|
} else {
|
||||||
const insertNodeDelta = new Delta(endDelta);
|
const insertNodeDelta = new Delta(endDelta);
|
||||||
const insertNodeAction = getInsertEnterNodeAction(startNode, insertNodeDelta, controller);
|
const insertNodeAction = getInsertEnterNodeAction(startNode, insertNodeDelta, controller);
|
||||||
|
|
||||||
if (!insertNodeAction) return;
|
if (!insertNodeAction) return;
|
||||||
actions.push(insertNodeAction.action);
|
actions.push(insertNodeAction.action);
|
||||||
caret = {
|
caret = {
|
||||||
@ -198,6 +212,7 @@ export const deleteRangeAndInsertEnterThunk = createAsyncThunk(
|
|||||||
// move start node children to insert node
|
// move start node children to insert node
|
||||||
const needMoveChildren =
|
const needMoveChildren =
|
||||||
blockConfig[startNode.type].splitProps?.nextLineRelationShip === SplitRelationship.NextSibling;
|
blockConfig[startNode.type].splitProps?.nextLineRelationShip === SplitRelationship.NextSibling;
|
||||||
|
|
||||||
if (needMoveChildren) {
|
if (needMoveChildren) {
|
||||||
// filter children by delete middle ids
|
// filter children by delete middle ids
|
||||||
const children = documentState.children[startNode.children].filter((id) => middleIds?.includes(id));
|
const children = documentState.children[startNode.children].filter((id) => middleIds?.includes(id));
|
||||||
@ -208,6 +223,7 @@ export const deleteRangeAndInsertEnterThunk = createAsyncThunk(
|
|||||||
''
|
''
|
||||||
)
|
)
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
actions.push(...moveChildrenAction);
|
actions.push(...moveChildrenAction);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -220,14 +236,17 @@ export const deleteRangeAndInsertEnterThunk = createAsyncThunk(
|
|||||||
delta: newStartDelta.ops,
|
delta: newStartDelta.ops,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (endNode.id !== startNode.id) {
|
if (endNode.id !== startNode.id) {
|
||||||
// delete end node
|
// delete end node
|
||||||
const deleteAction = controller.getDeleteAction(endNode);
|
const deleteAction = controller.getDeleteAction(endNode);
|
||||||
|
|
||||||
actions.push(updateAction, deleteAction);
|
actions.push(updateAction, deleteAction);
|
||||||
}
|
}
|
||||||
|
|
||||||
// delete middle nodes
|
// delete middle nodes
|
||||||
const deleteMiddleNodesActions = middleIds?.map((id) => controller.getDeleteAction(documentState.nodes[id])) || [];
|
const deleteMiddleNodesActions = middleIds?.map((id) => controller.getDeleteAction(documentState.nodes[id])) || [];
|
||||||
|
|
||||||
actions.push(...deleteMiddleNodesActions);
|
actions.push(...deleteMiddleNodesActions);
|
||||||
|
|
||||||
// apply actions
|
// apply actions
|
||||||
|
@ -0,0 +1,115 @@
|
|||||||
|
import { createAsyncThunk } from '@reduxjs/toolkit';
|
||||||
|
import { RootState } from '$app/stores/store';
|
||||||
|
import { DOCUMENT_NAME, EQUATION_PLACEHOLDER, RANGE_NAME, TEMPORARY_NAME } from '$app/constants/document/name';
|
||||||
|
import { getDeltaByRange, getDeltaText } from '$app/utils/document/delta';
|
||||||
|
import Delta from 'quill-delta';
|
||||||
|
import { TemporaryState, TemporaryType } from '$app/interfaces/document';
|
||||||
|
import { temporaryActions } from '$app_reducers/document/temporary_slice';
|
||||||
|
import { DocumentController } from '$app/stores/effects/document/document_controller';
|
||||||
|
import { rangeActions } from '$app_reducers/document/slice';
|
||||||
|
|
||||||
|
export const createTemporary = createAsyncThunk(
|
||||||
|
'document/temporary/create',
|
||||||
|
async (payload: { docId: string; type?: TemporaryType; state?: TemporaryState }, thunkAPI) => {
|
||||||
|
const { docId, type } = payload;
|
||||||
|
const { dispatch, getState } = thunkAPI;
|
||||||
|
const state = getState() as RootState;
|
||||||
|
let temporaryState = payload.state;
|
||||||
|
|
||||||
|
if (!temporaryState && type) {
|
||||||
|
const caret = state[RANGE_NAME][docId].caret;
|
||||||
|
|
||||||
|
if (!caret) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id, index, length } = caret;
|
||||||
|
const selection = {
|
||||||
|
index,
|
||||||
|
length,
|
||||||
|
};
|
||||||
|
const node = state[DOCUMENT_NAME][docId].nodes[id];
|
||||||
|
const nodeDelta = new Delta(node.data?.delta);
|
||||||
|
const rangeDelta = getDeltaByRange(nodeDelta, selection);
|
||||||
|
const text = getDeltaText(rangeDelta);
|
||||||
|
|
||||||
|
temporaryState = {
|
||||||
|
id,
|
||||||
|
selection,
|
||||||
|
selectedText: text,
|
||||||
|
type,
|
||||||
|
data: {
|
||||||
|
latex: text,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!temporaryState) return;
|
||||||
|
dispatch(rangeActions.initialState(docId));
|
||||||
|
|
||||||
|
dispatch(temporaryActions.setTemporaryState({ id: docId, state: temporaryState }));
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export const formatTemporary = createAsyncThunk(
|
||||||
|
'document/temporary/format',
|
||||||
|
async (payload: { controller: DocumentController }, thunkAPI) => {
|
||||||
|
const { controller } = payload;
|
||||||
|
const docId = controller.documentId;
|
||||||
|
const { dispatch, getState } = thunkAPI;
|
||||||
|
const state = getState() as RootState;
|
||||||
|
const temporaryState = state[TEMPORARY_NAME][docId];
|
||||||
|
|
||||||
|
if (!temporaryState) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id, selection, type, data } = temporaryState;
|
||||||
|
const node = state[DOCUMENT_NAME][docId].nodes[id];
|
||||||
|
const nodeDelta = new Delta(node.data?.delta);
|
||||||
|
const { index, length } = selection;
|
||||||
|
const diffDelta: Delta = new Delta();
|
||||||
|
let newSelection;
|
||||||
|
|
||||||
|
switch (type) {
|
||||||
|
case TemporaryType.Equation: {
|
||||||
|
if (data.latex) {
|
||||||
|
newSelection = {
|
||||||
|
index: selection.index,
|
||||||
|
length: 1,
|
||||||
|
};
|
||||||
|
diffDelta.retain(index).delete(length).insert(EQUATION_PLACEHOLDER, {
|
||||||
|
formula: data.latex,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
newSelection = {
|
||||||
|
index: selection.index,
|
||||||
|
length: 0,
|
||||||
|
};
|
||||||
|
diffDelta.retain(index).delete(length);
|
||||||
|
}
|
||||||
|
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const newDelta = nodeDelta.compose(diffDelta);
|
||||||
|
|
||||||
|
const updateAction = controller.getUpdateAction({
|
||||||
|
...node,
|
||||||
|
data: {
|
||||||
|
...node.data,
|
||||||
|
delta: newDelta.ops,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await controller.applyActions([updateAction]);
|
||||||
|
return {
|
||||||
|
...temporaryState,
|
||||||
|
selection: newSelection,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
@ -11,6 +11,14 @@ import {
|
|||||||
import { BlockEventPayloadPB } from '@/services/backend';
|
import { BlockEventPayloadPB } from '@/services/backend';
|
||||||
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
|
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
|
||||||
import { parseValue, matchChange } from '$app/utils/document/subscribe';
|
import { parseValue, matchChange } from '$app/utils/document/subscribe';
|
||||||
|
import { temporarySlice } from '$app_reducers/document/temporary_slice';
|
||||||
|
import {
|
||||||
|
DOCUMENT_NAME,
|
||||||
|
RANGE_NAME,
|
||||||
|
RECT_RANGE_NAME,
|
||||||
|
SLASH_COMMAND_NAME,
|
||||||
|
TEXT_LINK_NAME,
|
||||||
|
} from '$app/constants/document/name';
|
||||||
|
|
||||||
const initialState: Record<string, DocumentState> = {};
|
const initialState: Record<string, DocumentState> = {};
|
||||||
|
|
||||||
@ -23,7 +31,7 @@ const slashCommandInitialState: Record<string, SlashCommandState> = {};
|
|||||||
const linkPopoverState: Record<string, LinkPopoverState> = {};
|
const linkPopoverState: Record<string, LinkPopoverState> = {};
|
||||||
|
|
||||||
export const documentSlice = createSlice({
|
export const documentSlice = createSlice({
|
||||||
name: 'document',
|
name: DOCUMENT_NAME,
|
||||||
initialState: initialState,
|
initialState: initialState,
|
||||||
// Here we can't offer actions to update the document state.
|
// Here we can't offer actions to update the document state.
|
||||||
// Because the document state is updated by the `onDataChange`
|
// Because the document state is updated by the `onDataChange`
|
||||||
@ -91,7 +99,7 @@ export const documentSlice = createSlice({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const rectSelectionSlice = createSlice({
|
export const rectSelectionSlice = createSlice({
|
||||||
name: 'documentRectSelection',
|
name: RECT_RANGE_NAME,
|
||||||
initialState: rectSelectionInitialState,
|
initialState: rectSelectionInitialState,
|
||||||
reducers: {
|
reducers: {
|
||||||
initialState: (state, action: PayloadAction<string>) => {
|
initialState: (state, action: PayloadAction<string>) => {
|
||||||
@ -150,7 +158,7 @@ export const rectSelectionSlice = createSlice({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const rangeSlice = createSlice({
|
export const rangeSlice = createSlice({
|
||||||
name: 'documentRange',
|
name: RANGE_NAME,
|
||||||
initialState: rangeInitialState,
|
initialState: rangeInitialState,
|
||||||
reducers: {
|
reducers: {
|
||||||
initialState: (state, action: PayloadAction<string>) => {
|
initialState: (state, action: PayloadAction<string>) => {
|
||||||
@ -208,16 +216,19 @@ export const rangeSlice = createSlice({
|
|||||||
state,
|
state,
|
||||||
action: PayloadAction<{
|
action: PayloadAction<{
|
||||||
docId: string;
|
docId: string;
|
||||||
id: string;
|
anchorPoint?: {
|
||||||
point: { x: number; y: number };
|
id: string;
|
||||||
|
point: { x: number; y: number };
|
||||||
|
};
|
||||||
}>
|
}>
|
||||||
) => {
|
) => {
|
||||||
const { docId, id, point } = action.payload;
|
const { docId, anchorPoint } = action.payload;
|
||||||
|
|
||||||
state[docId].anchor = {
|
if (anchorPoint) {
|
||||||
id,
|
state[docId].anchor = { ...anchorPoint };
|
||||||
point,
|
} else {
|
||||||
};
|
delete state[docId].anchor;
|
||||||
|
}
|
||||||
},
|
},
|
||||||
setAnchorPointRange: (
|
setAnchorPointRange: (
|
||||||
state,
|
state,
|
||||||
@ -241,17 +252,21 @@ export const rangeSlice = createSlice({
|
|||||||
state,
|
state,
|
||||||
action: PayloadAction<{
|
action: PayloadAction<{
|
||||||
docId: string;
|
docId: string;
|
||||||
id: string;
|
focusPoint?: {
|
||||||
point: { x: number; y: number };
|
id: string;
|
||||||
|
point: { x: number; y: number };
|
||||||
|
};
|
||||||
}>
|
}>
|
||||||
) => {
|
) => {
|
||||||
const { docId, id, point } = action.payload;
|
const { docId, focusPoint } = action.payload;
|
||||||
|
|
||||||
state[docId].focus = {
|
if (focusPoint) {
|
||||||
id,
|
state[docId].focus = { ...focusPoint };
|
||||||
point,
|
} else {
|
||||||
};
|
delete state[docId].focus;
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
setDragging: (
|
setDragging: (
|
||||||
state,
|
state,
|
||||||
action: PayloadAction<{
|
action: PayloadAction<{
|
||||||
@ -295,6 +310,12 @@ export const rangeSlice = createSlice({
|
|||||||
) => {
|
) => {
|
||||||
const { docId, exclude } = action.payload;
|
const { docId, exclude } = action.payload;
|
||||||
const ranges = state[docId].ranges;
|
const ranges = state[docId].ranges;
|
||||||
|
|
||||||
|
if (!exclude) {
|
||||||
|
state[docId].ranges = {};
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const newRanges = Object.keys(ranges).reduce((acc, id) => {
|
const newRanges = Object.keys(ranges).reduce((acc, id) => {
|
||||||
if (id !== exclude) return { ...acc };
|
if (id !== exclude) return { ...acc };
|
||||||
return {
|
return {
|
||||||
@ -309,7 +330,7 @@ export const rangeSlice = createSlice({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const slashCommandSlice = createSlice({
|
export const slashCommandSlice = createSlice({
|
||||||
name: 'documentSlashCommand',
|
name: SLASH_COMMAND_NAME,
|
||||||
initialState: slashCommandInitialState,
|
initialState: slashCommandInitialState,
|
||||||
reducers: {
|
reducers: {
|
||||||
initialState: (state, action: PayloadAction<string>) => {
|
initialState: (state, action: PayloadAction<string>) => {
|
||||||
@ -365,7 +386,7 @@ export const slashCommandSlice = createSlice({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const linkPopoverSlice = createSlice({
|
export const linkPopoverSlice = createSlice({
|
||||||
name: 'documentLinkPopover',
|
name: TEXT_LINK_NAME,
|
||||||
initialState: linkPopoverState,
|
initialState: linkPopoverState,
|
||||||
reducers: {
|
reducers: {
|
||||||
initialState: (state, action: PayloadAction<string>) => {
|
initialState: (state, action: PayloadAction<string>) => {
|
||||||
@ -418,6 +439,7 @@ export const documentReducers = {
|
|||||||
[rangeSlice.name]: rangeSlice.reducer,
|
[rangeSlice.name]: rangeSlice.reducer,
|
||||||
[slashCommandSlice.name]: slashCommandSlice.reducer,
|
[slashCommandSlice.name]: slashCommandSlice.reducer,
|
||||||
[linkPopoverSlice.name]: linkPopoverSlice.reducer,
|
[linkPopoverSlice.name]: linkPopoverSlice.reducer,
|
||||||
|
[temporarySlice.name]: temporarySlice.reducer,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const documentActions = documentSlice.actions;
|
export const documentActions = documentSlice.actions;
|
||||||
|
@ -0,0 +1,37 @@
|
|||||||
|
import { TemporaryState } from '$app/interfaces/document';
|
||||||
|
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
|
||||||
|
import { TEMPORARY_NAME } from '$app/constants/document/name';
|
||||||
|
|
||||||
|
const initialState: Record<string, TemporaryState> = {};
|
||||||
|
|
||||||
|
export const temporarySlice = createSlice({
|
||||||
|
name: TEMPORARY_NAME,
|
||||||
|
initialState,
|
||||||
|
reducers: {
|
||||||
|
setTemporaryState: (state, action: PayloadAction<{ id: string; state: TemporaryState }>) => {
|
||||||
|
const { id, state: temporaryState } = action.payload;
|
||||||
|
|
||||||
|
state[id] = temporaryState;
|
||||||
|
},
|
||||||
|
updateTemporaryState: (state, action: PayloadAction<{ id: string; state: Partial<TemporaryState> }>) => {
|
||||||
|
const { id, state: temporaryState } = action.payload;
|
||||||
|
|
||||||
|
if (!state[id]) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (temporaryState.id !== state[id].id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
state[id] = { ...state[id], ...temporaryState };
|
||||||
|
},
|
||||||
|
deleteTemporaryState: (state, action: PayloadAction<string>) => {
|
||||||
|
const id = action.payload;
|
||||||
|
|
||||||
|
delete state[id];
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const temporaryActions = temporarySlice.actions;
|
@ -23,6 +23,7 @@ import {
|
|||||||
transformIndexToNextLine,
|
transformIndexToNextLine,
|
||||||
transformIndexToPrevLine,
|
transformIndexToPrevLine,
|
||||||
} from '$app/utils/document/delta';
|
} from '$app/utils/document/delta';
|
||||||
|
import { DOCUMENT_NAME, RANGE_NAME } from '$app/constants/document/name';
|
||||||
|
|
||||||
export function getMiddleIds(document: DocumentState, startId: string, endId: string) {
|
export function getMiddleIds(document: DocumentState, startId: string, endId: string) {
|
||||||
const middleIds = [];
|
const middleIds = [];
|
||||||
@ -116,8 +117,8 @@ export function getMergeEndDeltaToStartActionsByRange(
|
|||||||
) {
|
) {
|
||||||
const actions = [];
|
const actions = [];
|
||||||
const docId = controller.documentId;
|
const docId = controller.documentId;
|
||||||
const documentState = state.document[docId];
|
const documentState = state[DOCUMENT_NAME][docId];
|
||||||
const rangeState = state.documentRange[docId];
|
const rangeState = state[RANGE_NAME][docId];
|
||||||
const { startDelta, endDelta, endNode, startNode } = getStartAndEndExtentDelta(documentState, rangeState) || {};
|
const { startDelta, endDelta, endNode, startNode } = getStartAndEndExtentDelta(documentState, rangeState) || {};
|
||||||
|
|
||||||
if (!startDelta || !endDelta || !endNode || !startNode) return;
|
if (!startDelta || !endDelta || !endNode || !startNode) return;
|
||||||
|
@ -1,18 +1,22 @@
|
|||||||
import Delta from 'quill-delta';
|
import Delta from 'quill-delta';
|
||||||
|
import emojiRegex from 'emoji-regex';
|
||||||
|
|
||||||
export function getDeltaText(delta: Delta) {
|
export function getDeltaText(delta: Delta) {
|
||||||
const text = delta
|
const text = delta
|
||||||
.filter((op) => typeof op.insert === 'string')
|
.filter((op) => typeof op.insert === 'string')
|
||||||
.map((op) => op.insert)
|
.map((op) => op.insert)
|
||||||
.join('');
|
.join('');
|
||||||
|
|
||||||
return text;
|
return text;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function caretInTopEdgeByDelta(delta: Delta, index: number) {
|
export function caretInTopEdgeByDelta(delta: Delta, index: number) {
|
||||||
const text = getDeltaText(delta.slice(0, index));
|
const text = getDeltaText(delta.slice(0, index));
|
||||||
|
|
||||||
if (!text) return true;
|
if (!text) return true;
|
||||||
|
|
||||||
const firstLine = text.split('\n')[0];
|
const firstLine = text.split('\n')[0];
|
||||||
|
|
||||||
return index <= firstLine.length;
|
return index <= firstLine.length;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -31,6 +35,7 @@ export function getLineByIndex(delta: Delta, index: number) {
|
|||||||
|
|
||||||
const startLineText = beforeLines[beforeLines.length - 1];
|
const startLineText = beforeLines[beforeLines.length - 1];
|
||||||
const currentLineText = startLineText + afterLines[0];
|
const currentLineText = startLineText + afterLines[0];
|
||||||
|
|
||||||
return {
|
return {
|
||||||
text: currentLineText,
|
text: currentLineText,
|
||||||
index: beforeText.length - startLineText.length,
|
index: beforeText.length - startLineText.length,
|
||||||
@ -40,9 +45,11 @@ export function getLineByIndex(delta: Delta, index: number) {
|
|||||||
export function transformIndexToPrevLine(delta: Delta, index: number) {
|
export function transformIndexToPrevLine(delta: Delta, index: number) {
|
||||||
const text = getDeltaText(delta.slice(0, index));
|
const text = getDeltaText(delta.slice(0, index));
|
||||||
const lines = text.split('\n');
|
const lines = text.split('\n');
|
||||||
|
|
||||||
if (lines.length < 2) return 0;
|
if (lines.length < 2) return 0;
|
||||||
const prevLineText = lines[lines.length - 2];
|
const prevLineText = lines[lines.length - 2];
|
||||||
const transformedIndex = index - prevLineText.length - 1;
|
const transformedIndex = index - prevLineText.length - 1;
|
||||||
|
|
||||||
return transformedIndex > 0 ? transformedIndex : 0;
|
return transformedIndex > 0 ? transformedIndex : 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -54,6 +61,7 @@ export function transformIndexToNextLine(delta: Delta, index: number) {
|
|||||||
const text = getDeltaText(delta);
|
const text = getDeltaText(delta);
|
||||||
const currentLineText = getCurrentLineText(delta, index);
|
const currentLineText = getCurrentLineText(delta, index);
|
||||||
const transformedIndex = index + currentLineText.length + 1;
|
const transformedIndex = index + currentLineText.length + 1;
|
||||||
|
|
||||||
return transformedIndex > text.length ? text.length : transformedIndex;
|
return transformedIndex > text.length ? text.length : transformedIndex;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -61,12 +69,14 @@ export function getIndexRelativeEnter(delta: Delta, index: number) {
|
|||||||
const text = getDeltaText(delta.slice(0, index));
|
const text = getDeltaText(delta.slice(0, index));
|
||||||
const beforeLines = text.split('\n');
|
const beforeLines = text.split('\n');
|
||||||
const beforeLineText = beforeLines[beforeLines.length - 1];
|
const beforeLineText = beforeLines[beforeLines.length - 1];
|
||||||
|
|
||||||
return beforeLineText.length;
|
return beforeLineText.length;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getLastLineIndex(delta: Delta) {
|
export function getLastLineIndex(delta: Delta) {
|
||||||
const text = getDeltaText(delta);
|
const text = getDeltaText(delta);
|
||||||
const lastIndex = text.lastIndexOf('\n');
|
const lastIndex = text.lastIndexOf('\n');
|
||||||
|
|
||||||
return lastIndex === -1 ? 0 : lastIndex + 1;
|
return lastIndex === -1 ? 0 : lastIndex + 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -79,6 +89,7 @@ export function getDeltaByRange(
|
|||||||
) {
|
) {
|
||||||
const start = range.index;
|
const start = range.index;
|
||||||
const end = range.index + range.length;
|
const end = range.index + range.length;
|
||||||
|
|
||||||
return new Delta(delta.slice(start, end));
|
return new Delta(delta.slice(start, end));
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -90,6 +101,7 @@ export function getBeofreExtentDeltaByRange(
|
|||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
const start = range.index;
|
const start = range.index;
|
||||||
|
|
||||||
return new Delta(delta.slice(0, start));
|
return new Delta(delta.slice(0, start));
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -101,5 +113,46 @@ export function getAfterExtentDeltaByRange(
|
|||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
const start = range.index + range.length;
|
const start = range.index + range.length;
|
||||||
|
|
||||||
return new Delta(delta.slice(start));
|
return new Delta(delta.slice(start));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getPreviousWordIndex(delta: Delta, index: number) {
|
||||||
|
if (index === 0) return 0;
|
||||||
|
const text = getDeltaText(delta.slice(0, index));
|
||||||
|
const prevChar = text.charAt(index - 1);
|
||||||
|
|
||||||
|
if (!prevChar) return index;
|
||||||
|
|
||||||
|
if (isEmojiTail(prevChar)) {
|
||||||
|
// the char is emoji tail
|
||||||
|
// get all emojis from 0 to index
|
||||||
|
const emojis = getEmojis(text.substring(0, index));
|
||||||
|
|
||||||
|
if (emojis && emojis.length > 0) {
|
||||||
|
// get the last emoji
|
||||||
|
const lastEmoji = emojis[emojis.length - 1];
|
||||||
|
// move the index to the last emoji head
|
||||||
|
const distance = lastEmoji.length;
|
||||||
|
|
||||||
|
return index - distance;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// default return the index - 1
|
||||||
|
return index - 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const regex = emojiRegex();
|
||||||
|
|
||||||
|
function getEmojis(text: string) {
|
||||||
|
const emojis = text.match(regex);
|
||||||
|
|
||||||
|
return emojis;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isEmojiTail(character: string) {
|
||||||
|
const codepoint = character.charCodeAt(0);
|
||||||
|
|
||||||
|
return 0xdc00 <= codepoint && codepoint <= 0xdfff;
|
||||||
|
}
|
||||||
|
@ -4,11 +4,13 @@ function isTextNode(node: Node): boolean {
|
|||||||
|
|
||||||
export function exclude(node: Element) {
|
export function exclude(node: Element) {
|
||||||
let isPlaceholder = false;
|
let isPlaceholder = false;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
isPlaceholder = !!node.getAttribute('data-slate-placeholder');
|
isPlaceholder = !!node.getAttribute('data-slate-placeholder');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
|
|
||||||
return isPlaceholder;
|
return isPlaceholder;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -16,13 +18,16 @@ export function findFirstTextNode(node: Node): Node | null {
|
|||||||
if (isTextNode(node)) {
|
if (isTextNode(node)) {
|
||||||
return node;
|
return node;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (exclude && exclude(node as Element)) {
|
if (exclude && exclude(node as Element)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const children = node.childNodes;
|
const children = node.childNodes;
|
||||||
|
|
||||||
for (let i = 0; i < children.length; i++) {
|
for (let i = 0; i < children.length; i++) {
|
||||||
const textNode = findFirstTextNode(children[i]);
|
const textNode = findFirstTextNode(children[i]);
|
||||||
|
|
||||||
if (textNode) {
|
if (textNode) {
|
||||||
return textNode;
|
return textNode;
|
||||||
}
|
}
|
||||||
@ -41,6 +46,7 @@ export function setCursorAtStartOfNode(node: Node): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const selection = window.getSelection();
|
const selection = window.getSelection();
|
||||||
|
|
||||||
selection?.removeAllRanges();
|
selection?.removeAllRanges();
|
||||||
selection?.addRange(range);
|
selection?.addRange(range);
|
||||||
}
|
}
|
||||||
@ -55,8 +61,10 @@ export function findLastTextNode(node: Node): Node | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const children = node.childNodes;
|
const children = node.childNodes;
|
||||||
|
|
||||||
for (let i = children.length - 1; i >= 0; i--) {
|
for (let i = children.length - 1; i >= 0; i--) {
|
||||||
const textNode = findLastTextNode(children[i]);
|
const textNode = findLastTextNode(children[i]);
|
||||||
|
|
||||||
if (textNode) {
|
if (textNode) {
|
||||||
return textNode;
|
return textNode;
|
||||||
}
|
}
|
||||||
@ -71,11 +79,13 @@ export function setCursorAtEndOfNode(node: Node): void {
|
|||||||
|
|
||||||
if (textNode) {
|
if (textNode) {
|
||||||
const textLength = textNode.textContent?.length || 0;
|
const textLength = textNode.textContent?.length || 0;
|
||||||
|
|
||||||
range.setStart(textNode, textLength);
|
range.setStart(textNode, textLength);
|
||||||
range.setEnd(textNode, textLength);
|
range.setEnd(textNode, textLength);
|
||||||
}
|
}
|
||||||
|
|
||||||
const selection = window.getSelection();
|
const selection = window.getSelection();
|
||||||
|
|
||||||
selection?.removeAllRanges();
|
selection?.removeAllRanges();
|
||||||
selection?.addRange(range);
|
selection?.addRange(range);
|
||||||
}
|
}
|
||||||
@ -84,47 +94,60 @@ export function setFullRangeAtNode(node: Node): void {
|
|||||||
const range = document.createRange();
|
const range = document.createRange();
|
||||||
const firstTextNode = findFirstTextNode(node);
|
const firstTextNode = findFirstTextNode(node);
|
||||||
const lastTextNode = findLastTextNode(node);
|
const lastTextNode = findLastTextNode(node);
|
||||||
|
|
||||||
if (!firstTextNode || !lastTextNode) return;
|
if (!firstTextNode || !lastTextNode) return;
|
||||||
range.setStart(firstTextNode, 0);
|
range.setStart(firstTextNode, 0);
|
||||||
range.setEnd(lastTextNode, lastTextNode.textContent?.length || 0);
|
range.setEnd(lastTextNode, lastTextNode.textContent?.length || 0);
|
||||||
const selection = window.getSelection();
|
const selection = window.getSelection();
|
||||||
|
|
||||||
selection?.removeAllRanges();
|
selection?.removeAllRanges();
|
||||||
selection?.addRange(range);
|
selection?.addRange(range);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getBlockIdByPoint(target: HTMLElement | null) {
|
export function getBlockIdByPoint(target: HTMLElement | null) {
|
||||||
let node = target;
|
let node = target;
|
||||||
|
|
||||||
while (node) {
|
while (node) {
|
||||||
const id = node.getAttribute('data-block-id');
|
const id = node.getAttribute('data-block-id');
|
||||||
|
|
||||||
if (id) {
|
if (id) {
|
||||||
return id;
|
return id;
|
||||||
}
|
}
|
||||||
|
|
||||||
node = node.parentElement;
|
node = node.parentElement;
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function findTextBoxParent(target: HTMLElement | null) {
|
export function findTextBoxParent(target: HTMLElement | null) {
|
||||||
let node = target;
|
let node = target;
|
||||||
|
|
||||||
while (node) {
|
while (node) {
|
||||||
if (node.getAttribute('role') === 'textbox') {
|
if (node.getAttribute('role') === 'textbox') {
|
||||||
return node;
|
return node;
|
||||||
}
|
}
|
||||||
|
|
||||||
node = node.parentElement;
|
node = node.parentElement;
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isFocused(blockId: string) {
|
export function isFocused(blockId: string) {
|
||||||
const selection = window.getSelection();
|
const selection = window.getSelection();
|
||||||
|
|
||||||
if (!selection) return false;
|
if (!selection) return false;
|
||||||
const { anchorNode, focusNode } = selection;
|
const { anchorNode, focusNode } = selection;
|
||||||
|
|
||||||
if (!anchorNode || !focusNode) return false;
|
if (!anchorNode || !focusNode) return false;
|
||||||
const anchorElement = anchorNode.parentElement;
|
const anchorElement = anchorNode.parentElement;
|
||||||
const focusElement = focusNode.parentElement;
|
const focusElement = focusNode.parentElement;
|
||||||
|
|
||||||
if (!anchorElement || !focusElement) return false;
|
if (!anchorElement || !focusElement) return false;
|
||||||
const anchorBlockId = getBlockIdByPoint(anchorElement);
|
const anchorBlockId = getBlockIdByPoint(anchorElement);
|
||||||
const focusBlockId = getBlockIdByPoint(focusElement);
|
const focusBlockId = getBlockIdByPoint(focusElement);
|
||||||
|
|
||||||
return anchorBlockId === blockId || focusBlockId === blockId;
|
return anchorBlockId === blockId || focusBlockId === blockId;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -134,12 +157,15 @@ export function getNode(id: string) {
|
|||||||
|
|
||||||
export function isPointInBlock(target: HTMLElement | null) {
|
export function isPointInBlock(target: HTMLElement | null) {
|
||||||
let node = target;
|
let node = target;
|
||||||
|
|
||||||
while (node) {
|
while (node) {
|
||||||
if (node.getAttribute('data-block-id')) {
|
if (node.getAttribute('data-block-id')) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
node = node.parentElement;
|
node = node.parentElement;
|
||||||
}
|
}
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -153,21 +179,27 @@ export function findTextNode(
|
|||||||
} {
|
} {
|
||||||
if (isTextNode(node)) {
|
if (isTextNode(node)) {
|
||||||
const textLength = node.textContent?.length || 0;
|
const textLength = node.textContent?.length || 0;
|
||||||
|
|
||||||
if (index <= textLength) {
|
if (index <= textLength) {
|
||||||
return { node, offset: index };
|
return { node, offset: index };
|
||||||
}
|
}
|
||||||
|
|
||||||
return { remainingIndex: index - textLength };
|
return { remainingIndex: index - textLength };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (exclude && exclude(node)) {
|
if (exclude && exclude(node)) {
|
||||||
return { remainingIndex: index };
|
return { remainingIndex: index };
|
||||||
}
|
}
|
||||||
|
|
||||||
let remainingIndex = index;
|
let remainingIndex = index;
|
||||||
|
|
||||||
for (const childNode of node.childNodes) {
|
for (const childNode of node.childNodes) {
|
||||||
const result = findTextNode(childNode as Element, remainingIndex);
|
const result = findTextNode(childNode as Element, remainingIndex);
|
||||||
|
|
||||||
if (result.node) {
|
if (result.node) {
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
remainingIndex = result.remainingIndex || index;
|
remainingIndex = result.remainingIndex || index;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -176,6 +208,7 @@ export function findTextNode(
|
|||||||
|
|
||||||
export function getRangeByIndex(node: Element, index: number, length: number) {
|
export function getRangeByIndex(node: Element, index: number, length: number) {
|
||||||
const textBoxNode = node.querySelector(`[role="textbox"]`);
|
const textBoxNode = node.querySelector(`[role="textbox"]`);
|
||||||
|
|
||||||
if (!textBoxNode) return;
|
if (!textBoxNode) return;
|
||||||
const anchorNode = findTextNode(textBoxNode, index);
|
const anchorNode = findTextNode(textBoxNode, index);
|
||||||
const focusNode = findTextNode(textBoxNode, index + length);
|
const focusNode = findTextNode(textBoxNode, index + length);
|
||||||
@ -183,6 +216,7 @@ export function getRangeByIndex(node: Element, index: number, length: number) {
|
|||||||
if (!anchorNode?.node || !focusNode?.node) return;
|
if (!anchorNode?.node || !focusNode?.node) return;
|
||||||
|
|
||||||
const range = document.createRange();
|
const range = document.createRange();
|
||||||
|
|
||||||
range.setStart(anchorNode.node, anchorNode.offset || 0);
|
range.setStart(anchorNode.node, anchorNode.offset || 0);
|
||||||
range.setEnd(focusNode.node, focusNode.offset || 0);
|
range.setEnd(focusNode.node, focusNode.offset || 0);
|
||||||
return range;
|
return range;
|
||||||
@ -190,19 +224,25 @@ export function getRangeByIndex(node: Element, index: number, length: number) {
|
|||||||
|
|
||||||
export function focusNodeByIndex(node: Element, index: number, length: number) {
|
export function focusNodeByIndex(node: Element, index: number, length: number) {
|
||||||
const range = getRangeByIndex(node, index, length);
|
const range = getRangeByIndex(node, index, length);
|
||||||
|
|
||||||
if (!range) return false;
|
if (!range) return false;
|
||||||
const selection = window.getSelection();
|
const selection = window.getSelection();
|
||||||
|
|
||||||
selection?.removeAllRanges();
|
selection?.removeAllRanges();
|
||||||
|
|
||||||
selection?.addRange(range);
|
selection?.addRange(range);
|
||||||
const focusNode = selection?.focusNode;
|
const focusNode = selection?.focusNode;
|
||||||
|
|
||||||
if (!focusNode) return false;
|
if (!focusNode) return false;
|
||||||
|
|
||||||
const parent = findParent(focusNode as Element, node);
|
const parent = findParent(focusNode as Element, node);
|
||||||
|
|
||||||
return Boolean(parent);
|
return Boolean(parent);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getNodeTextBoxByBlockId(blockId: string) {
|
export function getNodeTextBoxByBlockId(blockId: string) {
|
||||||
const node = getNode(blockId);
|
const node = getNode(blockId);
|
||||||
|
|
||||||
return node?.querySelector(`[role="textbox"]`);
|
return node?.querySelector(`[role="textbox"]`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -210,13 +250,17 @@ export function getNodeText(node: Element) {
|
|||||||
if (isTextNode(node)) {
|
if (isTextNode(node)) {
|
||||||
return node.textContent || '';
|
return node.textContent || '';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (exclude && exclude(node)) {
|
if (exclude && exclude(node)) {
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
let text = '';
|
let text = '';
|
||||||
|
|
||||||
for (const childNode of node.childNodes) {
|
for (const childNode of node.childNodes) {
|
||||||
text += getNodeText(childNode as Element);
|
text += getNodeText(childNode as Element);
|
||||||
}
|
}
|
||||||
|
|
||||||
return replaceZeroWidthSpace(text);
|
return replaceZeroWidthSpace(text);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -231,14 +275,18 @@ export function replaceZeroWidthSpace(text: string) {
|
|||||||
|
|
||||||
export function findParent(node: Element, parentSelector: string | Element) {
|
export function findParent(node: Element, parentSelector: string | Element) {
|
||||||
let parentNode: Element | null = node;
|
let parentNode: Element | null = node;
|
||||||
|
|
||||||
while (parentNode) {
|
while (parentNode) {
|
||||||
if (typeof parentSelector === 'string' && parentNode.matches(parentSelector)) {
|
if (typeof parentSelector === 'string' && parentNode.matches(parentSelector)) {
|
||||||
return parentNode;
|
return parentNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (parentNode === parentSelector) {
|
if (parentNode === parentSelector) {
|
||||||
return parentNode;
|
return parentNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
parentNode = parentNode.parentElement;
|
parentNode = parentNode.parentElement;
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
@ -1,8 +1,28 @@
|
|||||||
import { BaseElement, BasePoint, Descendant, Editor, Element, Selection, Text } from "slate";
|
import { BaseElement, BasePoint, Descendant, Editor, Element, Selection, Text } from 'slate';
|
||||||
import Delta from "quill-delta";
|
import Delta from 'quill-delta';
|
||||||
import { getLineByIndex } from "$app/utils/document/delta";
|
import { getLineByIndex } from '$app/utils/document/delta';
|
||||||
|
|
||||||
export function convertToSlateSelection(index: number, length: number, slateValue: Descendant[]){
|
export function converToSlatePoint(editor: Editor, index: number) {
|
||||||
|
const children = editor.children;
|
||||||
|
const texts = (children[0] as BaseElement).children.map((child) => (child as Text).text);
|
||||||
|
let path = [0, 0];
|
||||||
|
let offset = 0;
|
||||||
|
let charCount = 0;
|
||||||
|
|
||||||
|
texts.forEach((text, i) => {
|
||||||
|
const endOffset = charCount + text.length;
|
||||||
|
|
||||||
|
if (index >= charCount && index <= endOffset) {
|
||||||
|
path = [0, i];
|
||||||
|
offset = index - charCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
charCount += text.length;
|
||||||
|
});
|
||||||
|
return { path, offset };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function convertToSlateSelection(index: number, length: number, slateValue: Descendant[]) {
|
||||||
if (!slateValue || slateValue.length === 0) return null;
|
if (!slateValue || slateValue.length === 0) return null;
|
||||||
const texts = (slateValue[0] as BaseElement).children.map((child) => (child as Text).text);
|
const texts = (slateValue[0] as BaseElement).children.map((child) => (child as Text).text);
|
||||||
const anchorIndex = index;
|
const anchorIndex = index;
|
||||||
@ -12,16 +32,20 @@ export function convertToSlateSelection(index: number, length: number, slateValu
|
|||||||
let anchorOffset = 0;
|
let anchorOffset = 0;
|
||||||
let focusOffset = 0;
|
let focusOffset = 0;
|
||||||
let charCount = 0;
|
let charCount = 0;
|
||||||
|
|
||||||
texts.forEach((text, i) => {
|
texts.forEach((text, i) => {
|
||||||
const endOffset = charCount + text.length;
|
const endOffset = charCount + text.length;
|
||||||
|
|
||||||
if (anchorIndex >= charCount && anchorIndex <= endOffset) {
|
if (anchorIndex >= charCount && anchorIndex <= endOffset) {
|
||||||
anchorPath = [0, i];
|
anchorPath = [0, i];
|
||||||
anchorOffset = anchorIndex - charCount;
|
anchorOffset = anchorIndex - charCount;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (focusIndex >= charCount && focusIndex <= endOffset) {
|
if (focusIndex >= charCount && focusIndex <= endOffset) {
|
||||||
focusPath = [0, i];
|
focusPath = [0, i];
|
||||||
focusOffset = focusIndex - charCount;
|
focusOffset = focusIndex - charCount;
|
||||||
}
|
}
|
||||||
|
|
||||||
charCount += text.length;
|
charCount += text.length;
|
||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
@ -50,6 +74,7 @@ export function converToIndexLength(editor: Editor, range: Selection) {
|
|||||||
focus: after,
|
focus: after,
|
||||||
}).length;
|
}).length;
|
||||||
const length = focusIndex - index;
|
const length = focusIndex - index;
|
||||||
|
|
||||||
return { index, length };
|
return { index, length };
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -82,53 +107,63 @@ export function convertToSlateValue(delta: Delta): Descendant[] {
|
|||||||
export function convertToDelta(slateValue: Descendant[]) {
|
export function convertToDelta(slateValue: Descendant[]) {
|
||||||
const ops = (slateValue[0] as Element).children.map((child) => {
|
const ops = (slateValue[0] as Element).children.map((child) => {
|
||||||
const { text, ...attributes } = child as Text;
|
const { text, ...attributes } = child as Text;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
insert: text,
|
insert: text,
|
||||||
attributes,
|
attributes,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
return new Delta(ops);
|
return new Delta(ops);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getBreakLineBeginPoint(editor: Editor, at: Selection): BasePoint | undefined {
|
function getBreakLineBeginPoint(editor: Editor, at: Selection): BasePoint | undefined {
|
||||||
const delta = convertToDelta(editor.children);
|
const delta = convertToDelta(editor.children);
|
||||||
const currentSelection = converToIndexLength(editor, at);
|
const currentSelection = converToIndexLength(editor, at);
|
||||||
|
|
||||||
if (!currentSelection) return;
|
if (!currentSelection) return;
|
||||||
const { index } = getLineByIndex(delta, currentSelection.index);
|
const { index } = getLineByIndex(delta, currentSelection.index);
|
||||||
const selection = convertToSlateSelection(index, 0, editor.children);
|
const selection = convertToSlateSelection(index, 0, editor.children);
|
||||||
|
|
||||||
return selection?.anchor;
|
return selection?.anchor;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function indent(editor: Editor, distance: number) {
|
export function indent(editor: Editor, distance: number) {
|
||||||
const beginPoint = getBreakLineBeginPoint(editor, editor.selection);
|
const beginPoint = getBreakLineBeginPoint(editor, editor.selection);
|
||||||
|
|
||||||
if (!beginPoint) return;
|
if (!beginPoint) return;
|
||||||
const emptyStr = "".padStart(distance);
|
const emptyStr = ''.padStart(distance);
|
||||||
|
|
||||||
editor.insertText(emptyStr, {
|
editor.insertText(emptyStr, {
|
||||||
at: beginPoint
|
at: beginPoint,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function outdent(editor: Editor, distance: number) {
|
export function outdent(editor: Editor, distance: number) {
|
||||||
const beginPoint = getBreakLineBeginPoint(editor, editor.selection);
|
const beginPoint = getBreakLineBeginPoint(editor, editor.selection);
|
||||||
|
|
||||||
if (!beginPoint) return;
|
if (!beginPoint) return;
|
||||||
const afterBeginPoint = Editor.after(editor, beginPoint, {
|
const afterBeginPoint = Editor.after(editor, beginPoint, {
|
||||||
distance
|
distance,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!afterBeginPoint) return;
|
if (!afterBeginPoint) return;
|
||||||
const deleteChar = Editor.string(editor, {
|
const deleteChar = Editor.string(editor, {
|
||||||
anchor: beginPoint,
|
anchor: beginPoint,
|
||||||
focus: afterBeginPoint
|
focus: afterBeginPoint,
|
||||||
});
|
});
|
||||||
const emptyStr = "".padStart(distance);
|
const emptyStr = ''.padStart(distance);
|
||||||
|
|
||||||
if (deleteChar !== emptyStr) {
|
if (deleteChar !== emptyStr) {
|
||||||
if (distance > 1) {
|
if (distance > 1) {
|
||||||
outdent(editor, distance - 1);
|
outdent(editor, distance - 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
editor.delete({
|
editor.delete({
|
||||||
at: beginPoint,
|
at: beginPoint,
|
||||||
distance
|
distance,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@ -1,8 +1,8 @@
|
|||||||
import { DeltaTypePB } from '@/services/backend/models/flowy-document2';
|
import { DeltaTypePB } from '@/services/backend/models/flowy-document2';
|
||||||
import { BlockPBValue, BlockType, ChangeType, DocumentState, NestedBlock } from '$app/interfaces/document';
|
import { BlockPBValue, BlockType, ChangeType, DocumentState, NestedBlock } from '$app/interfaces/document';
|
||||||
import { Log } from '../log';
|
import { Log } from '../log';
|
||||||
import { BLOCK_MAP_NAME, CHILDREN_MAP_NAME, META_NAME } from '$app/constants/document/block';
|
|
||||||
import { isEqual } from '$app/utils/tool';
|
import { isEqual } from '$app/utils/tool';
|
||||||
|
import { BLOCK_MAP_NAME, CHILDREN_MAP_NAME, META_NAME } from '$app/constants/document/name';
|
||||||
|
|
||||||
// This is a list of all the possible changes that can happen to document data
|
// This is a list of all the possible changes that can happen to document data
|
||||||
const matchCases = [
|
const matchCases = [
|
||||||
|
@ -0,0 +1,14 @@
|
|||||||
|
export function isOverlappingPrefix(first: string, second: string): boolean {
|
||||||
|
if (first.length === 0 || second.length === 0) return false;
|
||||||
|
let i = 0;
|
||||||
|
|
||||||
|
while (i < first.length) {
|
||||||
|
const chars = first.substring(i);
|
||||||
|
|
||||||
|
if (chars.length > second.length) return false;
|
||||||
|
if (second.startsWith(chars)) return true;
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
3
frontend/appflowy_tauri/src/appflowy_app/utils/env.ts
Normal file
3
frontend/appflowy_tauri/src/appflowy_app/utils/env.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
export function isApple() {
|
||||||
|
return typeof navigator !== 'undefined' && /Mac OS X/.test(navigator.userAgent);
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user