diff --git a/frontend/appflowy_tauri/package.json b/frontend/appflowy_tauri/package.json
index bfe25ad27f..a6befd4733 100644
--- a/frontend/appflowy_tauri/package.json
+++ b/frontend/appflowy_tauri/package.json
@@ -27,12 +27,14 @@
"@tauri-apps/api": "^1.2.0",
"dayjs": "^1.11.7",
"emoji-mart": "^5.5.2",
+ "emoji-regex": "^10.2.1",
"events": "^3.3.0",
"google-protobuf": "^3.21.2",
"i18next": "^22.4.10",
"i18next-browser-languagedetector": "^7.0.1",
"is-hotkey": "^0.2.0",
"jest": "^29.5.0",
+ "katex": "^0.16.7",
"nanoid": "^4.0.0",
"prismjs": "^1.29.0",
"protoc-gen-ts": "^0.8.5",
@@ -44,6 +46,7 @@
"react-dom": "^18.2.0",
"react-error-boundary": "^3.1.4",
"react-i18next": "^12.2.0",
+ "react-katex": "^3.0.1",
"react-redux": "^8.0.5",
"react-router-dom": "^6.8.0",
"react18-input-otp": "^1.1.2",
@@ -60,12 +63,14 @@
"@tauri-apps/cli": "^1.2.2",
"@types/google-protobuf": "^3.15.6",
"@types/is-hotkey": "^0.1.7",
+ "@types/katex": "^0.16.0",
"@types/node": "^18.7.10",
"@types/prismjs": "^1.26.0",
"@types/quill": "^2.0.10",
"@types/react": "^18.0.15",
"@types/react-beautiful-dnd": "^13.1.3",
"@types/react-dom": "^18.0.6",
+ "@types/react-katex": "^3.0.0",
"@types/utf8": "^3.0.1",
"@types/uuid": "^9.0.1",
"@typescript-eslint/eslint-plugin": "^5.51.0",
diff --git a/frontend/appflowy_tauri/pnpm-lock.yaml b/frontend/appflowy_tauri/pnpm-lock.yaml
index d725aacd21..043a661188 100644
--- a/frontend/appflowy_tauri/pnpm-lock.yaml
+++ b/frontend/appflowy_tauri/pnpm-lock.yaml
@@ -37,6 +37,9 @@ dependencies:
emoji-mart:
specifier: ^5.5.2
version: 5.5.2
+ emoji-regex:
+ specifier: ^10.2.1
+ version: 10.2.1
events:
specifier: ^3.3.0
version: 3.3.0
@@ -55,6 +58,9 @@ dependencies:
jest:
specifier: ^29.5.0
version: 29.5.0(@types/node@18.16.9)
+ katex:
+ specifier: ^0.16.7
+ version: 0.16.7
nanoid:
specifier: ^4.0.0
version: 4.0.2
@@ -88,6 +94,9 @@ dependencies:
react-i18next:
specifier: ^12.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:
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)
@@ -132,6 +141,9 @@ devDependencies:
'@types/is-hotkey':
specifier: ^0.1.7
version: 0.1.7
+ '@types/katex':
+ specifier: ^0.16.0
+ version: 0.16.0
'@types/node':
specifier: ^18.7.10
version: 18.16.9
@@ -150,6 +162,9 @@ devDependencies:
'@types/react-dom':
specifier: ^18.0.6
version: 18.2.4
+ '@types/react-katex':
+ specifier: ^3.0.0
+ version: 3.0.0
'@types/utf8':
specifier: ^3.0.1
version: 3.0.1
@@ -1632,6 +1647,10 @@ packages:
resolution: {integrity: sha512-wOuvG1SN4Us4rez+tylwwwCV1psiNVOkJeM3AUWUNWg/jDQY2+HE/444y5gc+jBmRqASOm2Oeh5c1axHobwRKQ==}
dev: true
+ /@types/katex@0.16.0:
+ resolution: {integrity: sha512-hz+S3nV6Mym5xPbT9fnO8dDhBFQguMYpY0Ipxv06JMi1ORgnEM4M1ymWDUhUNer3ElLmT583opRo4RzxKmh9jw==}
+ dev: true
+
/@types/lodash.memoize@4.1.7:
resolution: {integrity: sha512-lGN7WeO4vO6sICVpf041Q7BX/9k1Y24Zo3FY0aUezr1QlKznpjzsDk3T3wvH8ofYzoK0QupN9TWcFAFZlyPwQQ==}
dependencies:
@@ -1684,6 +1703,12 @@ packages:
'@types/react': 17.0.59
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:
resolution: {integrity: sha512-bAGh4e+w5D8dajd6InASVIyCo4pZLJ66oLb80F9OBLO1gKESbZcRCJpTT6uLXX+HAB57zw1WTdwJdAsewuTweg==}
dependencies:
@@ -2280,6 +2305,11 @@ packages:
engines: {node: '>= 6'}
dev: true
+ /commander@8.3.0:
+ resolution: {integrity: sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==}
+ engines: {node: '>= 12'}
+ dev: false
+
/compute-scroll-into-view@1.0.20:
resolution: {integrity: sha512-UCB0ioiyj8CRjtrvaceBLqqhZCVP+1B8+NWQhmdsm0VXOJtobBCf1dBQmebCCo34qZmUwZfIH2MZLqNHazrfjg==}
dev: false
@@ -2437,6 +2467,10 @@ packages:
resolution: {integrity: sha512-Sqc/nso4cjxhOwWJsp9xkVm8OF5c+mJLZJFoFfzRuKO+yWiN7K8c96xmtughYb0d/fZ8UC6cLIQ/p4BR6Pv3/A==}
dev: false
+ /emoji-regex@10.2.1:
+ resolution: {integrity: sha512-97g6QgOk8zlDRdgq1WxwgTMgEWGVAQvB5Fdpgc1MkNy56la5SKP9GsMXKDOdqwn90/41a8yPwIGk1Y6WVbeMQA==}
+ dev: false
+
/emoji-regex@8.0.0:
resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==}
dev: false
@@ -3799,6 +3833,13 @@ packages:
object.assign: 4.1.4
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:
resolution: {integrity: sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==}
engines: {node: '>=6'}
@@ -4483,6 +4524,17 @@ packages:
resolution: {integrity: sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==}
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):
resolution: {integrity: sha512-Gx4L3uM182jEEayZfRbI/G11ZpYdNAnBs70lFVMNdHJI76XYtR+7m0MN+eAs7UHBPhWXcnFPaS+9owSCJQHNpQ==}
peerDependencies:
diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSelection/BlockRangeSelection.hooks.ts b/frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSelection/BlockRangeSelection.hooks.ts
index 68aa2cd356..0ff2256b6d 100644
--- a/frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSelection/BlockRangeSelection.hooks.ts
+++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSelection/BlockRangeSelection.hooks.ts
@@ -11,8 +11,13 @@ import {
import { useRangeKeyDown } from '$app/components/document/BlockSelection/RangeKeyDown.hooks';
import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks';
import { useSubscribeRanges } from '$app/components/document/_shared/SubscribeSelection.hooks';
+import { isApple } from '$app/utils/env';
+
+const onFrameTime = 1000 / 60;
export function useBlockRangeSelection(container: HTMLDivElement) {
+ const timeStampRef = useRef(0);
+
const dispatch = useAppDispatch();
const onKeyDown = useRangeKeyDown();
const { docId } = useSubscribeDocument();
@@ -36,10 +41,12 @@ export function useBlockRangeSelection(container: HTMLDivElement) {
useEffect(() => {
if (!range) return;
const { anchor, focus } = range;
+
if (!anchor || !focus) {
container.classList.remove('caret-transparent');
return;
}
+
// if the focus block is different from the anchor block, we need to set the caret transparent
if (focus.id !== anchor.id) {
container.classList.add('caret-transparent');
@@ -50,18 +57,21 @@ export function useBlockRangeSelection(container: HTMLDivElement) {
useEffect(() => {
const anchor = anchorRef.current;
+
if (!anchor || !focus) return;
const selection = window.getSelection();
+
if (!selection) return;
// update focus point
dispatch(
rangeActions.setFocusPoint({
- ...focus,
+ focusPoint: focus,
docId,
})
);
const focused = isFocused(focus.id);
+
// if the focus block is not focused, we need to set the cursor position
if (!focused) {
// if the focus block is the same as the anchor block, we just update the anchor's range
@@ -70,14 +80,17 @@ export function useBlockRangeSelection(container: HTMLDivElement) {
anchor.point.x - container.scrollLeft,
anchor.point.y - container.scrollTop
);
+
if (!range) return;
const selection = window.getSelection();
+
selection?.removeAllRanges();
selection?.addRange(range);
return;
}
const node = getNodeTextBoxByBlockId(focus.id);
+
if (!node) return;
// if the selection is forward, we set the cursor position to the start of the focus block
if (isForward) {
@@ -89,15 +102,33 @@ export function useBlockRangeSelection(container: HTMLDivElement) {
}
}, [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) => {
- setForward(true);
+ const isTapToClick = isApple() && timeStampRef.current > 0 && Date.now() - timeStampRef.current < onFrameTime;
+
// skip if the target is not a block
const blockId = getBlockIdByPoint(e.target as HTMLElement);
+
if (!blockId) {
dispatch(rangeActions.initialState(docId));
return;
}
+
+ setForward(true);
+
dispatch(rangeActions.clearRanges({ docId, exclude: blockId }));
const startX = e.clientX + container.scrollLeft;
const startY = e.clientY + container.scrollTop;
@@ -113,18 +144,25 @@ export function useBlockRangeSelection(container: HTMLDivElement) {
anchorRef.current = {
...anchor,
};
+
// set the anchor point and focus point
- dispatch(rangeActions.setAnchorPoint({ ...anchor, docId }));
- dispatch(rangeActions.setFocusPoint({ ...anchor, docId }));
+ dispatch(rangeActions.setAnchorPoint({ docId, anchorPoint: anchor }));
+ dispatch(rangeActions.setFocusPoint({ docId, focusPoint: anchor }));
+
+ // This is a workaround for a bug in Safari where the mouseup event is not fired
+ if (isTapToClick) {
+ handleDragEnd();
+ return;
+ }
+
dispatch(
rangeActions.setDragging({
isDragging: true,
docId,
})
);
- return;
},
- [container.scrollLeft, container.scrollTop, dispatch, docId]
+ [container.scrollLeft, container.scrollTop, dispatch, docId, handleDragEnd]
);
const handleDraging = useCallback(
@@ -133,12 +171,14 @@ export function useBlockRangeSelection(container: HTMLDivElement) {
// skip if the target is not a block
const blockId = getBlockIdByPoint(e.target as HTMLElement);
+
if (!blockId) {
return;
}
const endX = e.clientX + container.scrollLeft;
const endY = e.clientY + container.scrollTop;
+
// set the focus point
setFocus({
id: blockId,
@@ -149,42 +189,35 @@ export function useBlockRangeSelection(container: HTMLDivElement) {
});
// set forward
const anchorId = anchorRef.current.id;
+
if (anchorId === blockId) {
const startX = anchorRef.current.point.x;
+
setForward(startX < endX);
return;
}
+
const startY = anchorRef.current.point.y;
+
setForward(startY < endY);
},
[container.scrollLeft, container.scrollTop, isDragging]
);
- const handleDragEnd = useCallback(() => {
- if (!isDragging) return;
- setFocus(null);
- anchorRef.current = null;
- dispatch(
- rangeActions.setDragging({
- isDragging: false,
- docId,
- })
- );
- }, [docId, dispatch, isDragging]);
-
useEffect(() => {
- document.addEventListener('mousedown', handleDragStart);
+ document.addEventListener('mousedown', handleMouseDown);
document.addEventListener('mousemove', handleDraging);
document.addEventListener('mouseup', handleDragEnd);
+
container.addEventListener('keydown', onKeyDown, true);
return () => {
- document.removeEventListener('mousedown', handleDragStart);
+ document.removeEventListener('mousedown', handleMouseDown);
document.removeEventListener('mousemove', handleDraging);
document.removeEventListener('mouseup', handleDragEnd);
container.removeEventListener('keydown', onKeyDown, true);
};
- }, [handleDragStart, handleDragEnd, handleDraging, container, onKeyDown]);
+ }, [handleMouseDown, handleDragEnd, handleDraging, container, onKeyDown]);
return null;
}
diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSideToolbar/index.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSideToolbar/index.tsx
index 74e0e74047..50569b6413 100644
--- a/frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSideToolbar/index.tsx
+++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSideToolbar/index.tsx
@@ -10,6 +10,7 @@ import ToolbarButton from './ToolbarButton';
import { rectSelectionActions } from '$app_reducers/document/slice';
import { addBlockBelowClickThunk } from '$app_reducers/document/async-actions/menu';
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 }) {
const dispatch = useAppDispatch();
@@ -17,7 +18,7 @@ export default function BlockSideToolbar({ container }: { container: HTMLDivElem
const { nodeId, style, ref } = useBlockSideToolbar({ container });
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();
diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/Overlay/index.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/document/Overlay/index.tsx
index b1bd9a1183..e9c51f76c7 100644
--- a/frontend/appflowy_tauri/src/appflowy_app/components/document/Overlay/index.tsx
+++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/Overlay/index.tsx
@@ -7,6 +7,7 @@ import { useCopy } from '$app/components/document/_shared/CopyPasteHooks/useCopy
import { usePaste } from '$app/components/document/_shared/CopyPasteHooks/usePaste';
import LinkEditPopover from '$app/components/document/_shared/TextLink/LinkEditPopover';
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 }) {
useCopy(container);
@@ -19,6 +20,7 @@ export default function Overlay({ container }: { container: HTMLDivElement }) {
+
>
);
}
diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/TextActionMenu/config.ts b/frontend/appflowy_tauri/src/appflowy_app/components/document/TextActionMenu/config.ts
new file mode 100644
index 0000000000..cfdfe64b71
--- /dev/null
+++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/TextActionMenu/config.ts
@@ -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];
diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/TextActionMenu/index.hooks.ts b/frontend/appflowy_tauri/src/appflowy_app/components/document/TextActionMenu/index.hooks.ts
index 69919662ce..f432643d35 100644
--- a/frontend/appflowy_tauri/src/appflowy_app/components/document/TextActionMenu/index.hooks.ts
+++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/TextActionMenu/index.hooks.ts
@@ -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 { useAppSelector } from '$app/stores/store';
import { getNode } from '$app/utils/document/node';
import { debounce } from '$app/utils/tool';
import { useSubscribeCaret } from '$app/components/document/_shared/SubscribeSelection.hooks';
@@ -15,9 +14,11 @@ export function useMenuStyle(container: HTMLDivElement) {
const reCalculatePosition = useCallback(() => {
const el = ref.current;
+
if (!el || !id) return;
const node = getNode(id);
+
if (!node) return;
const position = calcToolbarPosition(el, node, container);
@@ -50,6 +51,7 @@ export function useMenuStyle(container: HTMLDivElement) {
setIsScrolling(true);
debounceScrollEnd();
};
+
container.addEventListener('scroll', handleScroll);
return () => {
debounceScrollEnd.cancel();
diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/TextActionMenu/index.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/document/TextActionMenu/index.tsx
index 62078a1472..44a8581349 100644
--- a/frontend/appflowy_tauri/src/appflowy_app/components/document/TextActionMenu/index.tsx
+++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/TextActionMenu/index.tsx
@@ -27,10 +27,12 @@ const TextActionComponent = ({ container }: { container: HTMLDivElement }) => {
);
};
+
const TextActionMenu = ({ container }: { container: HTMLDivElement }) => {
const range = useSubscribeRanges();
const canShow = useMemo(() => {
const { isDragging, focus, anchor, ranges, caret } = range;
+
// don't show if dragging
if (isDragging) return false;
// don't show if no focus or anchor
@@ -39,9 +41,10 @@ const TextActionMenu = ({ container }: { container: HTMLDivElement }) => {
// show toolbar if range has multiple nodes
if (!isSameLine) return true;
- const caretRange = ranges[caret.id];
- // don't show if no caret range
+ const caretRange = ranges?.[caret.id];
+
if (!caretRange) return false;
+
// show toolbar if range is not collapsed
return caretRange.length > 0;
}, [range]);
diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/TextActionMenu/menu/FormatButton.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/document/TextActionMenu/menu/FormatButton.tsx
index 6c22e56c20..2c80cf31c5 100644
--- a/frontend/appflowy_tauri/src/appflowy_app/components/document/TextActionMenu/menu/FormatButton.tsx
+++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/TextActionMenu/menu/FormatButton.tsx
@@ -1,37 +1,36 @@
import IconButton from '@mui/material/IconButton';
-import FormatIcon from './FormatIcon';
-import React, { useCallback, useEffect, useMemo, useContext } from 'react';
-import { TextAction } from '$app/interfaces/document';
+import React, { useCallback, useEffect, useMemo } from 'react';
+import { TemporaryType, TextAction } from '$app/interfaces/document';
import MenuTooltip from '$app/components/document/TextActionMenu/menu/MenuTooltip';
import { getFormatActiveThunk, toggleFormatThunk } from '$app_reducers/document/async-actions/format';
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 { newLinkThunk } from '$app_reducers/document/async-actions/link';
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 dispatch = useAppDispatch();
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 [isActive, setIsActive] = React.useState(false);
const color = useMemo(() => (isActive ? '#00BCF0' : 'white'), [isActive]);
- const formatTooltips: Record = 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 () => {
if (!focusNode) return false;
const { payload: isActive } = await dispatch(
@@ -40,6 +39,7 @@ const FormatButton = ({ format, icon }: { format: TextAction; icon: string }) =>
docId,
})
);
+
return !!isActive;
}, [docId, dispatch, format, focusNode]);
@@ -65,6 +65,34 @@ const FormatButton = ({ format, icon }: { format: TextAction; icon: string }) =>
);
}, [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 = 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(
(format: TextAction) => {
switch (format) {
@@ -76,22 +104,48 @@ const FormatButton = ({ format, icon }: { format: TextAction; icon: string }) =>
return toggleFormat(format);
case TextAction.Link:
return addLink();
+ case TextAction.Equation:
+ return addTemporaryInput(TemporaryType.Equation);
}
},
- [addLink, toggleFormat]
+ [addLink, addTemporaryInput, toggleFormat]
);
- useEffect(() => {
- void (async () => {
- const isActive = await isFormatActive();
- setIsActive(isActive);
- })();
- }, [isFormatActive]);
+ const formatIcon = useMemo(() => {
+ switch (icon) {
+ case TextAction.Bold:
+ return ;
+ case TextAction.Underline:
+ return ;
+ case TextAction.Italic:
+ return ;
+ case TextAction.Code:
+ return ;
+ case TextAction.Strikethrough:
+ return ;
+ case TextAction.Link:
+ return (
+
+ );
+ case TextAction.Equation:
+ return ;
+ default:
+ return null;
+ }
+ }, [icon]);
return (
formatClick(format)}>
-
+ {formatIcon}
);
diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/TextActionMenu/menu/FormatIcon.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/document/TextActionMenu/menu/FormatIcon.tsx
deleted file mode 100644
index 89ac65768b..0000000000
--- a/frontend/appflowy_tauri/src/appflowy_app/components/document/TextActionMenu/menu/FormatIcon.tsx
+++ /dev/null
@@ -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 ;
- case TextAction.Underline:
- return ;
- case TextAction.Italic:
- return ;
- case TextAction.Code:
- return ;
- case TextAction.Strikethrough:
- return ;
- case TextAction.Link:
- return (
-
- );
- default:
- return null;
- }
-}
diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/TextActionMenu/menu/index.hooks.ts b/frontend/appflowy_tauri/src/appflowy_app/components/document/TextActionMenu/menu/index.hooks.ts
index 48ecb1d8d3..a1098e99c2 100644
--- a/frontend/appflowy_tauri/src/appflowy_app/components/document/TextActionMenu/menu/index.hooks.ts
+++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/TextActionMenu/menu/index.hooks.ts
@@ -1,14 +1,13 @@
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 {
- blockConfig,
- defaultTextActionProps,
+ defaultTextActionItems,
multiLineTextActionGroups,
multiLineTextActionProps,
textActionGroups,
-} from '$app/constants/document/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';
+} from '$app/components/document/TextActionMenu/config';
export function useTextActionMenu() {
const range = useSubscribeRanges();
@@ -22,12 +21,9 @@ export function useTextActionMenu() {
const items = useMemo(() => {
if (!node) return [];
if (isSingleLine) {
- const config = blockConfig[node.type];
- const { customItems, excludeItems } = {
- ...defaultTextActionProps,
- ...config.textActionMenuProps,
- };
- return customItems?.filter((item) => !excludeItems?.includes(item)) || [];
+ const excludeItems = node.type === BlockType.CodeBlock ? [TextAction.Code] : [];
+
+ return defaultTextActionItems?.filter((item) => !excludeItems?.includes(item)) || [];
} else {
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
const groupItems: TextAction[][] = useMemo(() => {
const groups = node ? textActionGroups : multiLineTextActionGroups;
+
return groups.map((group) => {
return group.filter((item) => items.includes(item));
});
diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/TextActionMenu/menu/index.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/document/TextActionMenu/menu/index.tsx
index 07ae25151e..64c89ddc1b 100644
--- a/frontend/appflowy_tauri/src/appflowy_app/components/document/TextActionMenu/menu/index.tsx
+++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/TextActionMenu/menu/index.tsx
@@ -17,6 +17,7 @@ function TextActionMenuList() {
case TextAction.Underline:
case TextAction.Strikethrough:
case TextAction.Code:
+ case TextAction.Equation:
return ;
default:
return null;
diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/TextBlock/shortchut.ts b/frontend/appflowy_tauri/src/appflowy_app/components/document/TextBlock/shortchut.ts
new file mode 100644
index 0000000000..ba0b40b50f
--- /dev/null
+++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/TextBlock/shortchut.ts
@@ -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,})$/,
+ },
+ ],
+};
diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/TextBlock/useKeyDown.ts b/frontend/appflowy_tauri/src/appflowy_app/components/document/TextBlock/useKeyDown.ts
index 753a088894..e2c5d5d514 100644
--- a/frontend/appflowy_tauri/src/appflowy_app/components/document/TextBlock/useKeyDown.ts
+++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/TextBlock/useKeyDown.ts
@@ -1,8 +1,7 @@
-import { useCallback, useContext, useMemo } from 'react';
+import { useCallback, useMemo } from 'react';
import { Keyboard } from '$app/constants/document/keyboard';
import isHotkey from 'is-hotkey';
import { useAppDispatch } from '@/appflowy_app/stores/store';
-import { DocumentControllerContext } from '$app/stores/effects/document/document_controller';
import {
enterActionForBlockThunk,
tabActionForBlockThunk,
@@ -90,6 +89,7 @@ export function useKeyDown(id: string) {
const onKeyDown = useCallback(
(e: React.KeyboardEvent) => {
const filteredEvents = interceptEvents.filter((event) => event.canHandle(e));
+
filteredEvents.forEach((event) => {
e.stopPropagation();
event.handler(e);
diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/TextBlock/useTurnIntoBlockEvents.ts b/frontend/appflowy_tauri/src/appflowy_app/components/document/TextBlock/useTurnIntoBlockEvents.ts
index 73be34d7d7..2ba5417808 100644
--- a/frontend/appflowy_tauri/src/appflowy_app/components/document/TextBlock/useTurnIntoBlockEvents.ts
+++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/TextBlock/useTurnIntoBlockEvents.ts
@@ -1,7 +1,6 @@
-import { useCallback, useContext, useMemo } from 'react';
+import { useCallback, useMemo } from 'react';
import { BlockType } from '$app/interfaces/document';
import { useAppDispatch } from '$app/stores/store';
-import { DocumentControllerContext } from '$app/stores/effects/document/document_controller';
import { turnToBlockThunk } from '$app_reducers/document/async-actions';
import { blockConfig } from '$app/constants/document/config';
@@ -10,9 +9,9 @@ import { useRangeRef } from '$app/components/document/_shared/SubscribeSelection
import { getBlock } from '$app/components/document/_shared/SubscribeNode.hooks';
import isHotkey from 'is-hotkey';
import { slashCommandActions } from '$app_reducers/document/slice';
-import { Keyboard } from '$app/constants/document/keyboard';
import { getDeltaText } from '$app/utils/document/delta';
import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks';
+import { turnIntoShortcuts } from './shortchut';
export function useTurnIntoBlockEvents(id: string) {
const { docId, controller } = useSubscribeDocument();
@@ -22,27 +21,35 @@ export function useTurnIntoBlockEvents(id: string) {
const getFlag = useCallback(() => {
const range = rangeRef.current?.caret;
+
if (!range || range.id !== id) return;
const node = getBlock(docId, id);
const delta = new Delta(node.data.delta || []);
+
return getDeltaText(delta.slice(0, range.index));
}, [docId, id, rangeRef]);
const getDeltaContent = useCallback(() => {
const range = rangeRef.current?.caret;
+
if (!range || range.id !== id) return;
const node = getBlock(docId, id);
const delta = new Delta(node.data.delta || []);
const content = delta.slice(range.index);
+
return new Delta(content);
}, [docId, id, rangeRef]);
const canHandle = useCallback(
- (event: React.KeyboardEvent, type: BlockType, triggerKey: string) => {
+ (event: React.KeyboardEvent, 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
if (!regex) {
throw new Error(`canHandle: block type ${type} is not supported`);
@@ -53,10 +60,12 @@ export function useTurnIntoBlockEvents(id: string) {
if (!isTrigger) {
return false;
}
+
const flag = getFlag();
+
if (!flag) return false;
- return regex.some((r) => r.test(`${flag}${triggerKey}`));
+ return regex.test(`${flag}${triggerKey}`);
}
},
[getFlag]
@@ -64,6 +73,7 @@ export function useTurnIntoBlockEvents(id: string) {
const getTurnIntoBlockDelta = useCallback(() => {
const content = getDeltaContent();
+
if (!content) return;
return {
delta: content.ops,
@@ -74,8 +84,10 @@ export function useTurnIntoBlockEvents(id: string) {
return {
[BlockType.HeadingBlock]: () => {
const flag = getFlag();
+
if (!flag) return;
const level = flag.match(/#/g)?.length;
+
if (!level || level > 3) return;
return {
level,
@@ -84,6 +96,7 @@ export function useTurnIntoBlockEvents(id: string) {
},
[BlockType.TodoListBlock]: () => {
const flag = getFlag();
+
if (!flag) return;
return {
@@ -97,8 +110,10 @@ export function useTurnIntoBlockEvents(id: string) {
[BlockType.ToggleListBlock]: getTurnIntoBlockDelta,
[BlockType.CalloutBlock]: () => {
const flag = getFlag();
+
if (!flag) return;
const tag = flag.match(/(TIP|INFO|WARNING|DANGER)/g)?.[0];
+
if (!tag) return;
const iconMap: Record = {
TIP: '💡',
@@ -106,6 +121,7 @@ export function useTurnIntoBlockEvents(id: string) {
WARNING: '⚠️',
DANGER: '‼️',
};
+
return {
icon: iconMap[tag],
...getTurnIntoBlockDelta(),
@@ -117,24 +133,24 @@ export function useTurnIntoBlockEvents(id: string) {
const turnIntoBlockEvents = useMemo(() => {
const spaceTriggerEvents = Object.entries(spaceTriggerMap).map(([type, getData]) => {
const blockType = type as BlockType;
- const triggerKey = Keyboard.keys.Space;
return {
- canHandle: (e: React.KeyboardEvent) => canHandle(e, blockType, triggerKey),
+ canHandle: (e: React.KeyboardEvent) => canHandle(e, blockType),
handler: (e: React.KeyboardEvent) => {
e.preventDefault();
if (!controller) return;
const data = getData();
+
if (!data) return;
dispatch(turnToBlockThunk({ id, data, type: blockType, controller }));
},
};
});
+
return [
...spaceTriggerEvents,
{
- canHandle: (e: React.KeyboardEvent) =>
- canHandle(e, BlockType.DividerBlock, Keyboard.keys.Reduce),
+ canHandle: (e: React.KeyboardEvent) => canHandle(e, BlockType.DividerBlock),
handler: (e: React.KeyboardEvent) => {
e.preventDefault();
if (!controller) return;
@@ -153,8 +169,7 @@ export function useTurnIntoBlockEvents(id: string) {
},
},
{
- canHandle: (e: React.KeyboardEvent) =>
- canHandle(e, BlockType.CodeBlock, Keyboard.keys.BackQuote),
+ canHandle: (e: React.KeyboardEvent) => canHandle(e, BlockType.CodeBlock),
handler: (e: React.KeyboardEvent) => {
e.preventDefault();
if (!controller) return;
@@ -163,6 +178,7 @@ export function useTurnIntoBlockEvents(id: string) {
...defaultData,
delta: getDeltaContent()?.ops as Op[],
};
+
dispatch(turnToBlockThunk({ id, data, type: BlockType.CodeBlock, controller }));
},
},
@@ -170,6 +186,7 @@ export function useTurnIntoBlockEvents(id: string) {
// Here custom slash key event for TextBlock
canHandle: (e: React.KeyboardEvent) => {
const flag = getFlag();
+
return isHotkey('/', e) && flag === '';
},
handler: (_: React.KeyboardEvent) => {
diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/EditorHooks/useCommonKeyEvents.ts b/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/EditorHooks/useCommonKeyEvents.ts
index 32229f3672..84a95e5bd9 100644
--- a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/EditorHooks/useCommonKeyEvents.ts
+++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/EditorHooks/useCommonKeyEvents.ts
@@ -61,19 +61,18 @@ export function useCommonKeyEvents(id: string) {
{
// handle left arrow key and no other key is pressed
canHandle: (e: React.KeyboardEvent) => {
- return isHotkey(Keyboard.keys.LEFT, e) && caretRef.current?.index === 0 && caretRef.current?.length === 0;
+ return isHotkey(Keyboard.keys.LEFT, e);
},
handler: (e: React.KeyboardEvent) => {
e.preventDefault();
+ e.stopPropagation();
dispatch(leftActionForBlockThunk({ docId, id }));
},
},
{
// handle right arrow key and no other key is pressed
canHandle: (e: React.KeyboardEvent) => {
- const block = getBlock(docId, id);
- const isEndOfBlock = caretRef.current?.index === new Delta(block.data.delta).length();
- return isHotkey(Keyboard.keys.RIGHT, e) && isEndOfBlock && caretRef.current?.length === 0;
+ return isHotkey(Keyboard.keys.RIGHT, e);
},
handler: (e: React.KeyboardEvent) => {
e.preventDefault();
@@ -86,6 +85,7 @@ export function useCommonKeyEvents(id: string) {
handler: (e: React.KeyboardEvent) => {
if (!controller) return;
const format = parseFormat(e);
+
if (!format) return;
dispatch(
toggleFormatThunk({
@@ -97,5 +97,6 @@ export function useCommonKeyEvents(id: string) {
},
];
}, [docId, caretRef, controller, dispatch, focused, id]);
+
return commonKeyEvents;
}
diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/EditorHooks/useSelection.ts b/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/EditorHooks/useSelection.ts
index 793e612196..92517f5720 100644
--- a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/EditorHooks/useSelection.ts
+++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/EditorHooks/useSelection.ts
@@ -1,4 +1,4 @@
-import { useCallback, useContext, useEffect, useState } from 'react';
+import { useCallback, useEffect, useState } from 'react';
import { RangeStatic } from 'quill';
import { useAppDispatch } from '$app/stores/store';
import { rangeActions } from '$app_reducers/document/slice';
@@ -44,7 +44,15 @@ export function useSelection(id: string) {
);
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) {
setSelection(undefined);
return;
diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/InlineBlock/InlineContainer.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/InlineBlock/InlineContainer.tsx
new file mode 100644
index 0000000000..2b43e392d0
--- /dev/null
+++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/InlineBlock/InlineContainer.tsx
@@ -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(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 ;
+ 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 (
+ onClick(ref.current!)}>
+
+ {children}
+
+
+ {renderNode()}
+
+ {isLast && }
+
+ );
+}
+
+export default InlineContainer;
diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/InlineBlock/inline.css b/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/InlineBlock/inline.css
new file mode 100644
index 0000000000..8106b25450
--- /dev/null
+++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/InlineBlock/inline.css
@@ -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; }
+}
\ No newline at end of file
diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/KatexMath/index.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/KatexMath/index.tsx
new file mode 100644
index 0000000000..c759ad057a
--- /dev/null
+++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/KatexMath/index.tsx
@@ -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 ? : ;
+}
+
+export default KatexMath;
diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/MenuItem.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/MenuItem.tsx
index 854f89f4ae..e674681b9f 100644
--- a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/MenuItem.tsx
+++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/MenuItem.tsx
@@ -49,7 +49,11 @@ const MenuItem = forwardRef(function (
}}
>
{icon}
diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SlateEditor/TextLeaf.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SlateEditor/TextLeaf.tsx
index d06a87e083..526452dcd4 100644
--- a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SlateEditor/TextLeaf.tsx
+++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SlateEditor/TextLeaf.tsx
@@ -4,6 +4,9 @@ import { useCallback, useRef } from 'react';
import TextLink from '../TextLink';
import { converToIndexLength } from '$app/utils/document/slate_editor';
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 {
bold?: boolean;
@@ -16,6 +19,8 @@ interface Attributes {
prism_token?: string;
link_selection_lighted?: boolean;
link_placeholder?: string;
+ temporary?: boolean;
+ formula?: string;
}
interface TextLeafProps extends RenderLeafProps {
leaf: BaseText & Attributes;
@@ -27,6 +32,9 @@ const TextLeaf = (props: TextLeafProps) => {
const { attributes, children, leaf, isCodeBlock, editor } = props;
const ref = useRef(null);
+ const customAttributes = {
+ ...attributes,
+ };
let newChildren = children;
if (leaf.code) {
@@ -51,6 +59,7 @@ const TextLeaf = (props: TextLeafProps) => {
anchor: { path, offset: 0 },
focus: { path, offset: leaf.text.length },
});
+
return selection;
},
[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 = (
+
+ {newChildren}
+
+ );
+ }
+
const className = [
isCodeBlock && 'token',
leaf.prism_token && leaf.prism_token,
@@ -83,8 +112,13 @@ const TextLeaf = (props: TextLeafProps) => {
);
}
+
+ if (leaf.temporary) {
+ newChildren = {newChildren};
+ }
+
return (
-
+
{newChildren}
);
diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SlateEditor/markdown.ts b/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SlateEditor/markdown.ts
new file mode 100644
index 0000000000..e0d92d3a96
--- /dev/null
+++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SlateEditor/markdown.ts
@@ -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 | 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,
+ }
+ );
+}
diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SlateEditor/useEditor.ts b/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SlateEditor/useEditor.ts
index 061154fad8..fbb77beb60 100644
--- a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SlateEditor/useEditor.ts
+++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SlateEditor/useEditor.ts
@@ -15,6 +15,8 @@ import Delta from 'quill-delta';
import isHotkey from 'is-hotkey';
import { useSlateYjs } from '$app/components/document/_shared/SlateEditor/useSlateYjs';
+const AFTER_RENDER_DELAY = 100;
+
export function useEditor({
onChange,
onSelectionChange,
@@ -24,6 +26,7 @@ export function useEditor({
onKeyDown,
isCodeBlock,
linkDecorateSelection,
+ temporarySelection,
}: EditorProps) {
const { editor } = useSlateYjs({ delta });
const ref = useRef(null);
@@ -31,6 +34,7 @@ export function useEditor({
const onSelectionChangeHandler = useCallback(
(slateSelection: Selection) => {
const rangeStatic = converToIndexLength(editor, slateSelection);
+
onSelectionChange?.(rangeStatic, null);
},
[editor, onSelectionChange]
@@ -39,6 +43,7 @@ export function useEditor({
const onChangeHandler = useCallback(
(slateValue: Descendant[]) => {
const oldContents = delta || new Delta();
+
onChange?.(convertToDelta(slateValue), oldContents);
onSelectionChangeHandler(editor.selection);
},
@@ -67,8 +72,10 @@ export function useEditor({
) => {
if (!selection) return null;
const range = convertToSlateSelection(selection.index, selection.length, editor.children) as BaseRange;
+
if (range && !Range.isCollapsed(range)) {
const intersection = Range.intersection(range, Editor.range(editor, path));
+
if (intersection) {
return {
...intersection,
@@ -76,6 +83,7 @@ export function useEditor({
};
}
}
+
return null;
},
[editor]
@@ -93,11 +101,14 @@ export function useEditor({
link_selection_lighted: true,
link_placeholder: linkDecorateSelection?.placeholder,
}),
+ getDecorateRange(path, temporarySelection, {
+ temporary: true,
+ }),
].filter((range) => range !== null) as Range[];
return ranges;
},
- [decorateSelection, linkDecorateSelection, getDecorateRange]
+ [temporarySelection, decorateSelection, linkDecorateSelection, getDecorateRange]
);
const onKeyDownRewrite = useCallback(
@@ -107,6 +118,7 @@ export function useEditor({
event.preventDefault();
editor.insertText('\n');
};
+
// There is different behavior for code block and normal text
// In code block, we press enter to insert a new line
// In normal text, we press shift + enter to insert a new line
@@ -115,11 +127,13 @@ export function useEditor({
insertBreak();
return;
}
+
if (isHotkey(Keyboard.keys.TAB, event)) {
event.preventDefault();
indent(editor, 2);
return;
}
+
if (isHotkey(Keyboard.keys.SHIFT_TAB, event)) {
event.preventDefault();
outdent(editor, 2);
@@ -141,13 +155,16 @@ export function useEditor({
useEffect(() => {
if (!ref.current) return;
+
const isFocused = ReactEditor.isFocused(editor);
+
if (!selection) {
isFocused && editor.deselect();
return;
}
const slateSelection = convertToSlateSelection(selection.index, selection.length, editor.children);
+
if (!slateSelection) 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,
// but then it will trigger selection change, and the selection is not what we want
const isSuccess = focusNodeByIndex(ref.current, selection.index, selection.length);
+
if (!isSuccess) {
Transforms.select(editor, slateSelection);
} else {
// Fix: the slate is possible to lose focus in next tick after focusNodeByIndex
- requestAnimationFrame(() => {
+ setTimeout(() => {
if (window.getSelection()?.type === 'None' && !editor.selection) {
Transforms.select(editor, slateSelection);
}
- });
+ }, AFTER_RENDER_DELAY);
}
}, [editor, selection]);
diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SlateEditor/useSlateYjs.ts b/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SlateEditor/useSlateYjs.ts
index 68d06db45c..50473c59b2 100644
--- a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SlateEditor/useSlateYjs.ts
+++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SlateEditor/useSlateYjs.ts
@@ -5,6 +5,7 @@ import { convertToSlateValue } from '$app/utils/document/slate_editor';
import { slateNodesToInsertDelta, withYjs, YjsEditor } from '@slate-yjs/core';
import { withReact } from 'slate-react';
import { createEditor } from 'slate';
+import { withMarkdown } from '$app/components/document/_shared/SlateEditor/markdown';
export function useSlateYjs({ delta }: { delta?: Delta }) {
const [yText, setYText] = useState(undefined);
@@ -13,13 +14,14 @@ export function useSlateYjs({ delta }: { delta?: Delta }) {
const sharedType = yDoc.get('content', Y.XmlText) as Y.XmlText;
const value = convertToSlateValue(delta || new Delta());
const insertDelta = slateNodesToInsertDelta(value);
+
sharedType.applyDelta(insertDelta);
setYText(insertDelta[0].insert as Y.Text);
return sharedType;
// 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.
useEffect(() => {
@@ -33,6 +35,7 @@ export function useSlateYjs({ delta }: { delta?: Delta }) {
if (!yText) return;
const oldContents = new Delta(yText.toDelta());
const diffDelta = oldContents.diff(delta || new Delta());
+
if (diffDelta.ops.length === 0) return;
yText.applyDelta(diffDelta.ops);
}, [delta, editor, yText]);
diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SubscribeDoc.hooks.ts b/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SubscribeDoc.hooks.ts
index f06bd6ca43..11eceaaeca 100644
--- a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SubscribeDoc.hooks.ts
+++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SubscribeDoc.hooks.ts
@@ -1,10 +1,12 @@
import { DocumentControllerContext } from '$app/stores/effects/document/document_controller';
import { useContext } from 'react';
import { useAppSelector } from '$app/stores/store';
+import { DOCUMENT_NAME } from '$app/constants/document/name';
export function useSubscribeDocument() {
const controller = useContext(DocumentControllerContext);
const docId = controller.documentId;
+
return {
docId,
controller,
@@ -14,7 +16,8 @@ export function useSubscribeDocument() {
export function useSubscribeDocumentData() {
const { docId } = useSubscribeDocument();
const data = useAppSelector((state) => {
- return state.document[docId];
+ return state[DOCUMENT_NAME][docId];
});
+
return data;
}
diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SubscribeLinkPopover.hooks.ts b/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SubscribeLinkPopover.hooks.ts
index 9333fad48f..210b8daaf1 100644
--- a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SubscribeLinkPopover.hooks.ts
+++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SubscribeLinkPopover.hooks.ts
@@ -1,11 +1,12 @@
import { useAppSelector } from '$app/stores/store';
import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks';
+import { TEXT_LINK_NAME } from '$app/constants/document/name';
export function useSubscribeLinkPopover() {
const { docId } = useSubscribeDocument();
const linkPopover = useAppSelector((state) => {
- return state.documentLinkPopover[docId];
+ return state[TEXT_LINK_NAME][docId];
});
return linkPopover;
diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SubscribeNode.hooks.ts b/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SubscribeNode.hooks.ts
index 702c45c421..909c76c0eb 100644
--- a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SubscribeNode.hooks.ts
+++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SubscribeNode.hooks.ts
@@ -2,6 +2,7 @@ import { store, useAppSelector } from '@/appflowy_app/stores/store';
import { createContext, useMemo } from 'react';
import { Node } from '$app/interfaces/document';
import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks';
+import { DOCUMENT_NAME, RECT_RANGE_NAME } from '$app/constants/document/name';
/**
* Subscribe node information
@@ -11,20 +12,23 @@ export function useSubscribeNode(id: string) {
const { docId } = useSubscribeDocument();
const node = useAppSelector((state) => {
- const documentState = state.document[docId];
+ const documentState = state[DOCUMENT_NAME][docId];
+
return documentState?.nodes[id];
});
const childIds = useAppSelector((state) => {
- const documentState = state.document[docId];
+ const documentState = state[DOCUMENT_NAME][docId];
+
if (!documentState) return;
const childrenId = documentState.nodes[id]?.children;
+
if (!childrenId) return;
return documentState.children[childrenId];
});
const isSelected = useAppSelector((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
diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SubscribeRectRange.hooks.ts b/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SubscribeRectRange.hooks.ts
index 50ce6ad061..c5394878c5 100644
--- a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SubscribeRectRange.hooks.ts
+++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SubscribeRectRange.hooks.ts
@@ -1,10 +1,12 @@
import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks';
import { useAppSelector } from '$app/stores/store';
+import { RECT_RANGE_NAME } from '$app/constants/document/name';
export function useSubscribeRectRange() {
const { docId } = useSubscribeDocument();
const rectRange = useAppSelector((state) => {
- return state.documentRectSelection[docId];
+ return state[RECT_RANGE_NAME][docId];
});
+
return rectRange;
}
diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SubscribeSelection.hooks.ts b/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SubscribeSelection.hooks.ts
index 649824d3b5..299ef1bfa2 100644
--- a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SubscribeSelection.hooks.ts
+++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SubscribeSelection.hooks.ts
@@ -2,16 +2,25 @@ import { useAppSelector } from '$app/stores/store';
import { RangeState, RangeStatic } from '$app/interfaces/document';
import { useMemo, useRef } from 'react';
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) {
const { docId } = useSubscribeDocument();
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 linkPopoverState = state.documentLinkPopover[docId];
+ const linkPopoverState = state[TEXT_LINK_NAME][docId];
+
if (!linkPopoverState?.open || linkPopoverState?.id !== id) return;
return {
selection: linkPopoverState.selection,
@@ -22,18 +31,22 @@ export function useSubscribeDecorate(id: string) {
return {
decorateSelection,
linkDecorateSelection,
+ temporarySelection,
};
}
+
export function useFocused(id: string) {
const { docId } = useSubscribeDocument();
const caretRef = useRef();
const focusCaret = useAppSelector((state) => {
- const currentCaret = state.documentRange[docId]?.caret;
+ const currentCaret = state[RANGE_NAME][docId]?.caret;
+
caretRef.current = currentCaret;
if (currentCaret?.id === id) {
return currentCaret;
}
+
return null;
});
@@ -52,8 +65,10 @@ export function useRangeRef() {
const { docId, controller } = useSubscribeDocument();
const rangeRef = useRef();
+
useAppSelector((state) => {
- const currentRange = state.documentRange[docId];
+ const currentRange = state[RANGE_NAME][docId];
+
rangeRef.current = currentRange;
});
return rangeRef;
@@ -63,7 +78,7 @@ export function useSubscribeRanges() {
const { docId } = useSubscribeDocument();
const rangeState = useAppSelector((state) => {
- return state.documentRange[docId];
+ return state[RANGE_NAME][docId];
});
return rangeState;
@@ -73,7 +88,7 @@ export function useSubscribeCaret() {
const { docId } = useSubscribeDocument();
const caret = useAppSelector((state) => {
- return state.documentRange[docId]?.caret;
+ return state[RANGE_NAME][docId]?.caret;
});
return caret;
diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SubscribeSlash.hooks.ts b/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SubscribeSlash.hooks.ts
index 2fe1216096..94fca0f2f1 100644
--- a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SubscribeSlash.hooks.ts
+++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SubscribeSlash.hooks.ts
@@ -1,11 +1,12 @@
import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks';
import { useAppSelector } from '$app/stores/store';
+import { SLASH_COMMAND_NAME } from '$app/constants/document/name';
export function useSubscribeSlashState() {
const { docId } = useSubscribeDocument();
const slashCommandState = useAppSelector((state) => {
- return state.documentSlashCommand[docId];
+ return state[SLASH_COMMAND_NAME][docId];
});
return slashCommandState;
diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SubscribeTemporary.hooks.ts b/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SubscribeTemporary.hooks.ts
new file mode 100644
index 0000000000..1b3d0f69a8
--- /dev/null
+++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SubscribeTemporary.hooks.ts
@@ -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;
+}
diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/TemporaryInput/EquationEditContent.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/TemporaryInput/EquationEditContent.tsx
new file mode 100644
index 0000000000..ce5eee7143
--- /dev/null
+++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/TemporaryInput/EquationEditContent.tsx
@@ -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 (
+
+
{
+ if (e.key === 'Enter') {
+ onConfirm();
+ }
+ }}
+ InputProps={{
+ startAdornment: (
+
+
+
+ ),
+ }}
+ variant='standard'
+ value={value}
+ onChange={(e) => {
+ const newVal = e.target.value;
+
+ if (newVal === value) return;
+ onChange(newVal);
+ }}
+ />
+
+
+
+
+
+ );
+}
+
+export default EquationEditContent;
diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/TemporaryInput/TemporaryEquation.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/TemporaryInput/TemporaryEquation.tsx
new file mode 100644
index 0000000000..6808caa4bb
--- /dev/null
+++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/TemporaryInput/TemporaryEquation.tsx
@@ -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 (
+
+ {latex ? (
+
+ ) : (
+
+ {'New equation'}
+
+ )}
+
+ );
+}
+
+export default TemporaryEquation;
diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/TemporaryInput/TemporaryPopover.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/TemporaryInput/TemporaryPopover.tsx
new file mode 100644
index 0000000000..e597923b70
--- /dev/null
+++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/TemporaryInput/TemporaryPopover.tsx
@@ -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 (
+
+ onChangeData({
+ latex,
+ })
+ }
+ onConfirm={onConfirm}
+ />
+ );
+ }
+ }, [onChangeData, onConfirm, temporaryState]);
+
+ return (
+ e.stopPropagation()}
+ disableAutoFocus={true}
+ disableRestoreFocus={true}
+ anchorReference={'anchorPosition'}
+ anchorOrigin={{
+ vertical: 'bottom',
+ horizontal: 'center',
+ }}
+ transformOrigin={{
+ vertical: 'top',
+ horizontal: 'center',
+ }}
+ >
+ {renderPopoverContent()}
+
+ );
+}
+
+export default TemporaryPopover;
diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/TemporaryInput/index.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/TemporaryInput/index.tsx
new file mode 100644
index 0000000000..a93eb5c9dd
--- /dev/null
+++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/TemporaryInput/index.tsx
@@ -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(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 ;
+ 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 (
+
+ {match ? renderPlaceholder() : null}
+ {children}
+
+ );
+}
+
+export default TemporaryInput;
diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/TextLink/LinkHighLight.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/TextLink/LinkHighLight.tsx
index 65f618a71b..fae19cadcf 100644
--- a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/TextLink/LinkHighLight.tsx
+++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/TextLink/LinkHighLight.tsx
@@ -1,4 +1,5 @@
import React from 'react';
+import { isOverlappingPrefix } from '$app/utils/document/temporary';
function LinkHighLight({ children, leaf, title }: { leaf: { text: string }; title: string; children: React.ReactNode }) {
return (
@@ -19,15 +20,3 @@ function LinkHighLight({ children, leaf, title }: { leaf: { text: string }; titl
}
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;
-}
diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/TurnInto/index.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/TurnInto/index.tsx
index 9627f3eaeb..96e3a85158 100644
--- a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/TurnInto/index.tsx
+++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/TurnInto/index.tsx
@@ -122,7 +122,7 @@ const TurnIntoPopover = ({
},
// {
// type: BlockType.EquationBlock,
- // title: 'Block Equation',
+ // title: 'Block KatexMath',
// icon: ,
// },
],
diff --git a/frontend/appflowy_tauri/src/appflowy_app/constants/document/block.ts b/frontend/appflowy_tauri/src/appflowy_app/constants/document/block.ts
deleted file mode 100644
index 78dfceaf6d..0000000000
--- a/frontend/appflowy_tauri/src/appflowy_app/constants/document/block.ts
+++ /dev/null
@@ -1,3 +0,0 @@
-export const BLOCK_MAP_NAME = 'blocks';
-export const META_NAME = 'meta';
-export const CHILDREN_MAP_NAME = 'children_map';
diff --git a/frontend/appflowy_tauri/src/appflowy_app/constants/document/config.ts b/frontend/appflowy_tauri/src/appflowy_app/constants/document/config.ts
index 2e868927df..97708293c7 100644
--- a/frontend/appflowy_tauri/src/appflowy_app/constants/document/config.ts
+++ b/frontend/appflowy_tauri/src/appflowy_app/constants/document/config.ts
@@ -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
@@ -20,10 +20,6 @@ export const blockConfig: Record = {
nextLineRelationShip: SplitRelationship.NextSibling,
nextLineBlockType: BlockType.TextBlock,
},
- /**
- * # or ## or ###
- */
- markdownRegexps: [/^(#{1,3})(\s)+$/],
},
[BlockType.TodoListBlock]: {
canAddChild: true,
@@ -35,10 +31,6 @@ export const blockConfig: Record = {
nextLineRelationShip: SplitRelationship.NextSibling,
nextLineBlockType: BlockType.TodoListBlock,
},
- /**
- * -[] or -[x] or -[ ] or [] or [x] or [ ]
- */
- markdownRegexps: [/^((-)?\[(x|\s)?\])(\s)+$/],
},
[BlockType.BulletedListBlock]: {
canAddChild: true,
@@ -50,10 +42,6 @@ export const blockConfig: Record = {
nextLineRelationShip: SplitRelationship.NextSibling,
nextLineBlockType: BlockType.BulletedListBlock,
},
- /**
- * - or + or *
- */
- markdownRegexps: [/^(\s*[-+*])(\s)+$/],
},
[BlockType.NumberedListBlock]: {
canAddChild: true,
@@ -65,11 +53,6 @@ export const blockConfig: Record = {
nextLineRelationShip: SplitRelationship.NextSibling,
nextLineBlockType: BlockType.NumberedListBlock,
},
- /**
- * 1. or 2. or 3.
- * a. or b. or c.
- */
- markdownRegexps: [/^(\s*[\d|a-zA-Z]+\.)(\s)+$/],
},
[BlockType.QuoteBlock]: {
canAddChild: true,
@@ -81,10 +64,6 @@ export const blockConfig: Record = {
nextLineRelationShip: SplitRelationship.NextSibling,
nextLineBlockType: BlockType.TextBlock,
},
- /**
- * " or “ or ”
- */
- markdownRegexps: [/^("|“|”)(\s)+$/],
},
[BlockType.CalloutBlock]: {
canAddChild: true,
@@ -96,10 +75,6 @@ export const blockConfig: Record = {
nextLineRelationShip: SplitRelationship.NextSibling,
nextLineBlockType: BlockType.TextBlock,
},
- /**
- * [!TIP] or [!INFO] or [!WARNING] or [!DANGER]
- */
- markdownRegexps: [/^(\[!)(TIP|INFO|WARNING|DANGER)(\])(\s)+$/],
},
[BlockType.ToggleListBlock]: {
canAddChild: true,
@@ -111,17 +86,6 @@ export const blockConfig: Record = {
nextLineRelationShip: SplitRelationship.FirstChild,
nextLineBlockType: BlockType.TextBlock,
},
- /**
- * >
- */
- markdownRegexps: [/^(>)(\s)+$/],
- },
- [BlockType.DividerBlock]: {
- canAddChild: false,
- /**
- * ---
- */
- markdownRegexps: [/^(-{3,})$/],
},
[BlockType.CodeBlock]: {
@@ -130,49 +94,8 @@ export const blockConfig: Record = {
delta: [],
language: 'javascript',
},
- /**
- * ```
- */
- markdownRegexps: [/^(```)$/],
-
- textActionMenuProps: {
- excludeItems: [TextAction.Code],
- },
+ },
+ [BlockType.DividerBlock]: {
+ canAddChild: false,
},
};
-
-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];
diff --git a/frontend/appflowy_tauri/src/appflowy_app/constants/document/keyboard.ts b/frontend/appflowy_tauri/src/appflowy_app/constants/document/keyboard.ts
index 8c7da65393..75faab8c12 100644
--- a/frontend/appflowy_tauri/src/appflowy_app/constants/document/keyboard.ts
+++ b/frontend/appflowy_tauri/src/appflowy_app/constants/document/keyboard.ts
@@ -24,10 +24,13 @@ export const Keyboard = {
DELETE: 'Delete',
SHIFT_ENTER: 'Shift+Enter',
SHIFT_TAB: 'Shift+Tab',
- Slash: '/',
- Space: ' ',
- Reduce: '-',
- BackQuote: '`',
+ SLASH: '/',
+ REDUCE: '-',
+ BACK_QUOTE: '`',
+ UNDER_SCORE: '_',
+ ASTERISK: '*',
+ TILDE: '~',
+ DOLLAR: '$',
FORMAT: {
BOLD: 'Mod+b',
ITALIC: 'Mod+i',
diff --git a/frontend/appflowy_tauri/src/appflowy_app/constants/document/name.ts b/frontend/appflowy_tauri/src/appflowy_app/constants/document/name.ts
new file mode 100644
index 0000000000..d4b8715bce
--- /dev/null
+++ b/frontend/appflowy_tauri/src/appflowy_app/constants/document/name.ts
@@ -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 = '$';
diff --git a/frontend/appflowy_tauri/src/appflowy_app/interfaces/document.ts b/frontend/appflowy_tauri/src/appflowy_app/interfaces/document.ts
index 475e7602a9..d7f03df6a8 100644
--- a/frontend/appflowy_tauri/src/appflowy_app/interfaces/document.ts
+++ b/frontend/appflowy_tauri/src/appflowy_app/interfaces/document.ts
@@ -213,7 +213,7 @@ export enum TextAction {
Underline = 'underline',
Strikethrough = 'strikethrough',
Code = 'code',
- Equation = 'equation',
+ Equation = 'formula',
Link = 'href',
}
export interface TextActionMenuProps {
@@ -232,10 +232,6 @@ export interface BlockConfig {
* Whether the block can have children
*/
canAddChild: boolean;
- /**
- * The regexps that will be used to match the markdown flag
- */
- markdownRegexps?: RegExp[];
/**
* The default data of the block
@@ -255,11 +251,6 @@ export interface BlockConfig {
*/
nextLineBlockType: BlockType;
};
-
- /**
- * The props that will be passed to the text action menu
- */
- textActionMenuProps?: TextActionMenuProps;
}
export interface ControllerAction {
@@ -286,12 +277,10 @@ export interface EditorProps {
selection?: RangeStaticNoId;
decorateSelection?: RangeStaticNoId;
linkDecorateSelection?: {
- selection?: {
- index: number;
- length: number;
- };
+ selection?: RangeStaticNoId;
placeholder?: string;
};
+ temporarySelection?: RangeStaticNoId;
onSelectionChange?: (range: RangeStaticNoId | null, oldRange: RangeStaticNoId | null, source?: Sources) => void;
onChange?: (delta: Delta, oldDelta: Delta, source?: Sources) => void;
onKeyDown?: (event: React.KeyboardEvent) => void;
@@ -306,11 +295,27 @@ export interface BlockCopyData {
export interface LinkPopoverState {
anchorPosition?: { top: number; left: number };
id?: string;
- selection?: {
- index: number;
- length: number;
- };
+ selection?: RangeStaticNoId;
open?: boolean;
href?: 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;
+}
diff --git a/frontend/appflowy_tauri/src/appflowy_app/stores/effects/document/document_controller.ts b/frontend/appflowy_tauri/src/appflowy_app/stores/effects/document/document_controller.ts
index cb2c752fd8..8f302501f7 100644
--- a/frontend/appflowy_tauri/src/appflowy_app/stores/effects/document/document_controller.ts
+++ b/frontend/appflowy_tauri/src/appflowy_app/stores/effects/document/document_controller.ts
@@ -11,10 +11,10 @@ import {
} from '@/services/backend';
import { DocumentObserver } from './document_observer';
import * as Y from 'yjs';
-import { BLOCK_MAP_NAME, CHILDREN_MAP_NAME, META_NAME } from '$app/constants/document/block';
import { get } from '@/appflowy_app/utils/tool';
import { blockPB2Node } from '$app/utils/document/block';
import { Log } from '$app/utils/log';
+import { BLOCK_MAP_NAME, CHILDREN_MAP_NAME, META_NAME } from '$app/constants/document/name';
export class DocumentController {
private readonly backendService: DocumentBackendService;
diff --git a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/delete.ts b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/delete.ts
index 003965ce16..bf625503ef 100644
--- a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/delete.ts
+++ b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/delete.ts
@@ -2,6 +2,7 @@ import { DocumentController } from '$app/stores/effects/document/document_contro
import { createAsyncThunk } from '@reduxjs/toolkit';
import { RootState } from '$app/stores/store';
+import { DOCUMENT_NAME } from '$app/constants/document/name';
export const deleteNodeThunk = createAsyncThunk(
'document/deleteNode',
@@ -10,8 +11,9 @@ export const deleteNodeThunk = createAsyncThunk(
const { getState } = thunkAPI;
const state = getState() as RootState;
const docId = controller.documentId;
- const docState = state.document[docId];
+ const docState = state[DOCUMENT_NAME][docId];
const node = docState.nodes[id];
+
if (!node) return;
await controller.applyActions([controller.getDeleteAction(node)]);
}
diff --git a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/duplicate.ts b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/duplicate.ts
index 4dddd0ce18..2af62fe738 100644
--- a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/duplicate.ts
+++ b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/duplicate.ts
@@ -3,6 +3,7 @@ import { createAsyncThunk } from '@reduxjs/toolkit';
import { rectSelectionActions } from '$app_reducers/document/slice';
import { getDuplicateActions } from '$app/utils/document/action';
import { RootState } from '$app/stores/store';
+import { DOCUMENT_NAME } from '$app/constants/document/name';
export const duplicateBelowNodeThunk = createAsyncThunk(
'document/duplicateBelowNode',
@@ -11,8 +12,9 @@ export const duplicateBelowNodeThunk = createAsyncThunk(
const { getState, dispatch } = thunkAPI;
const state = getState() as RootState;
const docId = controller.documentId;
- const docState = state.document[docId];
+ const docState = state[DOCUMENT_NAME][docId];
const node = docState.nodes[id];
+
if (!node || !node.parent) return;
const duplicateActions = getDuplicateActions(id, node.parent, docState, controller);
diff --git a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/indent.ts b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/indent.ts
index 08eaffb9ca..c43195ee3b 100644
--- a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/indent.ts
+++ b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/indent.ts
@@ -4,6 +4,7 @@ import { createAsyncThunk } from '@reduxjs/toolkit';
import { blockConfig } from '$app/constants/document/config';
import { getPrevNodeId } from '$app/utils/document/block';
import { RootState } from '$app/stores/store';
+import { DOCUMENT_NAME } from '$app/constants/document/name';
/**
* indent node
@@ -19,16 +20,19 @@ export const indentNodeThunk = createAsyncThunk(
const { getState } = thunkAPI;
const state = getState() as RootState;
const docId = controller.documentId;
- const docState = state.document[docId];
+ const docState = state[DOCUMENT_NAME][docId];
const node = docState.nodes[id];
+
if (!node.parent) return;
// get prev node
const prevNodeId = getPrevNodeId(docState, id);
+
if (!prevNodeId) return;
const newParentNode = docState.nodes[prevNodeId];
// check if prev node is allowed to have children
const config = blockConfig[newParentNode.type];
+
if (!config.canAddChild) return;
// check if prev node has children and get last child for new prev node
diff --git a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/insert.ts b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/insert.ts
index 9f88a0335b..e068e7c9c2 100644
--- a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/insert.ts
+++ b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/insert.ts
@@ -3,6 +3,7 @@ import { DocumentController } from '$app/stores/effects/document/document_contro
import { createAsyncThunk } from '@reduxjs/toolkit';
import { newBlock } from '$app/utils/document/block';
import { RootState } from '$app/stores/store';
+import { DOCUMENT_NAME } from '$app/constants/document/name';
export const insertAfterNodeThunk = createAsyncThunk(
'document/insertAfterNode',
@@ -18,22 +19,27 @@ export const insertAfterNodeThunk = createAsyncThunk(
const { getState } = thunkAPI;
const state = getState() as RootState;
const docId = controller.documentId;
- const docState = state.document[docId];
+ const docState = state[DOCUMENT_NAME][docId];
const node = docState.nodes[id];
+
if (!node) return;
const parentId = node.parent;
+
if (!parentId) return;
// create new node
const newNode = newBlock(type, parentId, data);
let nodeId = newNode.id;
const actions = [controller.getInsertAction(newNode, node.id)];
+
if (type === BlockType.DividerBlock) {
const newTextNode = newBlock(BlockType.TextBlock, parentId, {
delta: [],
});
+
nodeId = newTextNode.id;
actions.push(controller.getInsertAction(newTextNode, newNode.id));
}
+
await controller.applyActions(actions);
return nodeId;
diff --git a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/merge.ts b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/merge.ts
index 067bf64b86..fc3b6cf661 100644
--- a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/merge.ts
+++ b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/merge.ts
@@ -5,6 +5,7 @@ import Delta from 'quill-delta';
import { blockConfig } from '$app/constants/document/config';
import { getMoveChildrenActions } from '$app/utils/document/action';
import { RootState } from '$app/stores/store';
+import { DOCUMENT_NAME } from '$app/constants/document/name';
/**
* Merge two blocks
@@ -19,9 +20,10 @@ export const mergeDeltaThunk = createAsyncThunk(
const { getState } = thunkAPI;
const state = getState() as RootState;
const docId = controller.documentId;
- const docState = state.document[docId];
+ const docState = state[DOCUMENT_NAME][docId];
const target = docState.nodes[targetId];
const source = docState.nodes[sourceId];
+
if (!target || !source) return;
const targetDelta = new Delta(target.data.delta);
const sourceDelta = new Delta(source.data.delta);
@@ -43,9 +45,11 @@ export const mergeDeltaThunk = createAsyncThunk(
children,
target,
});
+
actions.push(...moveActions);
// delete current block
const deleteAction = controller.getDeleteAction(source);
+
actions.push(deleteAction);
await controller.applyActions(actions);
diff --git a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/outdent.ts b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/outdent.ts
index 4e02ba70b8..06332ac62c 100644
--- a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/outdent.ts
+++ b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/outdent.ts
@@ -2,6 +2,7 @@ import { DocumentController } from '$app/stores/effects/document/document_contro
import { createAsyncThunk } from '@reduxjs/toolkit';
import { blockConfig } from '$app/constants/document/config';
import { RootState } from '$app/stores/store';
+import { DOCUMENT_NAME } from '$app/constants/document/name';
/**
* outdent node
@@ -19,11 +20,13 @@ export const outdentNodeThunk = createAsyncThunk(
const { getState } = thunkAPI;
const state = getState() as RootState;
const docId = controller.documentId;
- const docState = state.document[docId];
+ const docState = state[DOCUMENT_NAME][docId];
const node = docState.nodes[id];
const parentId = node.parent;
+
if (!parentId) return;
const ancestorId = docState.nodes[parentId].parent;
+
if (!ancestorId) return;
const parent = docState.nodes[parentId];
@@ -32,25 +35,31 @@ export const outdentNodeThunk = createAsyncThunk(
const actions = [];
const moveAction = controller.getMoveAction(node, ancestorId, parentId);
+
actions.push(moveAction);
const config = blockConfig[node.type];
+
if (nextSiblingIds.length > 0) {
if (config.canAddChild) {
const children = docState.children[node.children];
let lastChildId: string | null = null;
const lastIndex = children.length - 1;
+
if (lastIndex >= 0) {
lastChildId = children[lastIndex];
}
+
const moveChildrenActions = nextSiblingIds
.reverse()
.map((id) => controller.getMoveAction(docState.nodes[id], node.id, lastChildId));
+
actions.push(...moveChildrenActions);
} else {
const moveChildrenActions = nextSiblingIds
.reverse()
.map((id) => controller.getMoveAction(docState.nodes[id], ancestorId, node.id));
+
actions.push(...moveChildrenActions);
}
}
diff --git a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/update.ts b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/update.ts
index 92b9c6739a..0c3c1965be 100644
--- a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/update.ts
+++ b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/update.ts
@@ -3,6 +3,7 @@ import { DocumentController } from '$app/stores/effects/document/document_contro
import { createAsyncThunk } from '@reduxjs/toolkit';
import Delta, { Op } from 'quill-delta';
import { RootState } from '$app/stores/store';
+import { DOCUMENT_NAME } from '$app/constants/document/name';
export const updateNodeDeltaThunk = createAsyncThunk(
'document/updateNodeDelta',
@@ -11,9 +12,10 @@ export const updateNodeDeltaThunk = createAsyncThunk(
const { getState } = thunkAPI;
const state = getState() as RootState;
const docId = controller.documentId;
- const docState = state.document[docId];
+ const docState = state[DOCUMENT_NAME][docId];
const node = docState.nodes[id];
const diffDelta = new Delta(delta).diff(new Delta(node.data.delta || []));
+
if (diffDelta.ops.length === 0) return;
const newData = { ...node.data, delta };
@@ -39,7 +41,7 @@ export const updateNodeDataThunk = createAsyncThunk<
const { getState } = thunkAPI;
const state = getState() as RootState;
const docId = controller.documentId;
- const docState = state.document[docId];
+ const docState = state[DOCUMENT_NAME][docId];
const node = docState.nodes[id];
const newData = { ...node.data, ...data };
diff --git a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/copy_paste.ts b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/copy_paste.ts
index 4c2dccb2a3..7d3b2a61f7 100644
--- a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/copy_paste.ts
+++ b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/copy_paste.ts
@@ -13,6 +13,7 @@ import {
getInsertBlockActions,
} from '$app/utils/document/copy_paste';
import { rangeActions } from '$app_reducers/document/slice';
+import { DOCUMENT_NAME, RANGE_NAME } from '$app/constants/document/name';
export const copyThunk = createAsyncThunk<
void,
@@ -26,11 +27,13 @@ export const copyThunk = createAsyncThunk<
const { setClipboardData, isCut = false, controller } = payload;
const docId = controller.documentId;
const state = getState() as RootState;
- const document = state.document[docId];
- const documentRange = state.documentRange[docId];
+ const document = state[DOCUMENT_NAME][docId];
+ const documentRange = state[RANGE_NAME][docId];
const startAndEndIds = getStartAndEndIdsByRange(documentRange);
+
if (startAndEndIds.length === 0) return;
const result: DocumentBlockJSON[] = [];
+
if (startAndEndIds.length === 1) {
// copy single block
const id = startAndEndIds[0];
@@ -38,6 +41,7 @@ export const copyThunk = createAsyncThunk<
const nodeDelta = new Delta(node.data.delta);
const range = documentRange.ranges[id] || { index: 0, length: 0 };
const isFull = range.index === 0 && range.length === nodeDelta.length();
+
if (isFull) {
result.push(getCopyBlock(id, document, documentRange));
} else {
@@ -54,13 +58,17 @@ export const copyThunk = createAsyncThunk<
const copyIds: string[] = [];
const [startId, endId] = startAndEndIds;
const middleIds = getMiddleIds(document, startId, endId);
+
copyIds.push(startId, ...middleIds, endId);
const map = new Map();
+
copyIds.forEach((id) => {
const block = getCopyBlock(id, document, documentRange);
+
map.set(id, block);
const node = document.nodes[id];
const parent = node.parent;
+
if (parent && map.has(parent)) {
map.get(parent)!.children.push(block);
} else {
@@ -68,6 +76,7 @@ export const copyThunk = createAsyncThunk<
}
});
}
+
setClipboardData({
json: JSON.stringify(result),
// TODO: implement plain text and html
@@ -99,15 +108,17 @@ export const pasteThunk = createAsyncThunk<
>('document/paste', async (payload, thunkAPI) => {
const { getState, dispatch } = thunkAPI;
const { data, controller } = payload;
+
// delete range blocks
await dispatch(deleteRangeAndInsertThunk({ controller }));
const state = getState() as RootState;
const docId = controller.documentId;
- const document = state.document[docId];
- const documentRange = state.documentRange[docId];
+ const document = state[DOCUMENT_NAME][docId];
+ const documentRange = state[RANGE_NAME][docId];
let pasteData;
+
if (data.json) {
pasteData = JSON.parse(data.json) as DocumentBlockJSON[];
} else if (data.text) {
@@ -115,10 +126,13 @@ export const pasteThunk = createAsyncThunk<
} else if (data.html) {
// TODO: implement html
}
+
if (!pasteData) return;
const { caret } = documentRange;
+
if (!caret) return;
const currentBlock = document.nodes[caret.id];
+
if (!currentBlock.parent) return;
const pasteBlocks = generateBlocks(pasteData, currentBlock.parent);
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 lastPasteBlock = pasteBlocks[pasteBlocks.length - 1];
+
if (type === BlockType.TextBlock && currentBlockDelta.length() === 0) {
// move current block children to first paste block
const children = document.children[currentBlock.children].map((id) => document.nodes[id]);
@@ -140,6 +155,7 @@ export const pasteThunk = createAsyncThunk<
controller,
prevId,
});
+
actions.push(...moveChildrenActions);
// delete current block
actions.push(controller.getDeleteAction(currentBlock));
@@ -173,6 +189,7 @@ export const pasteThunk = createAsyncThunk<
const firstPasteBlockDelta = new Delta(firstPasteBlock.data.delta);
const lastPasteBlockDelta = new Delta(lastPasteBlock.data.delta);
let mergeDelta = new Delta(currentBeforeDelta.ops).concat(firstPasteBlockDelta);
+
if (firstPasteBlock.id !== lastPasteBlock.id) {
// update the last block of paste data
actions.push(getAppendBlockDeltaAction(lastPasteBlock, currentAfterDelta, false, controller));
@@ -208,6 +225,7 @@ export const pasteThunk = createAsyncThunk<
children: firstPasteBlockChildren,
controller,
});
+
actions.push(...moveChildrenActions);
}
diff --git a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/format.ts b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/format.ts
index fe1802ebd9..885103f46a 100644
--- a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/format.ts
+++ b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/format.ts
@@ -3,6 +3,7 @@ import { RootState } from '$app/stores/store';
import { TextAction } from '$app/interfaces/document';
import { DocumentController } from '$app/stores/effects/document/document_controller';
import Delta from 'quill-delta';
+import { DOCUMENT_NAME, RANGE_NAME } from '$app/constants/document/name';
export const getFormatActiveThunk = createAsyncThunk<
boolean,
@@ -13,12 +14,13 @@ export const getFormatActiveThunk = createAsyncThunk<
>('document/getFormatActive', async ({ format, docId }, thunkAPI) => {
const { getState } = thunkAPI;
const state = getState() as RootState;
- const document = state.document[docId];
- const documentRange = state.documentRange[docId];
+ const document = state[DOCUMENT_NAME][docId];
+ const documentRange = state[RANGE_NAME][docId];
const { ranges } = documentRange;
const match = (delta: Delta, format: TextAction) => {
return delta.ops.every((op) => op.attributes?.[format]);
};
+
return Object.entries(ranges).every(([id, range]) => {
const node = document.nodes[id];
const delta = new Delta(node.data?.delta);
@@ -37,6 +39,7 @@ export const toggleFormatThunk = createAsyncThunk(
const { format, controller } = payload;
const docId = controller.documentId;
let isActive = payload.isActive;
+
if (isActive === undefined) {
const { payload: active } = await dispatch(
getFormatActiveThunk({
@@ -44,12 +47,14 @@ export const toggleFormatThunk = createAsyncThunk(
docId,
})
);
+
isActive = !!active;
}
+
const formatValue = isActive ? undefined : true;
const state = getState() as RootState;
- const document = state.document[docId];
- const documentRange = state.documentRange[docId];
+ const document = state[DOCUMENT_NAME][docId];
+ const documentRange = state[RANGE_NAME][docId];
const { ranges } = documentRange;
const toggle = (delta: Delta, format: TextAction, value: string | boolean | undefined) => {
@@ -58,11 +63,13 @@ export const toggleFormatThunk = createAsyncThunk(
...op.attributes,
[format]: value,
};
+
return {
insert: op.insert,
attributes: attributes,
};
});
+
return new Delta(newOps);
};
@@ -85,6 +92,7 @@ export const toggleFormatThunk = createAsyncThunk(
},
});
});
+
await controller.applyActions(actions);
}
);
diff --git a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/keydown.ts b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/keydown.ts
index 4d3b30ace0..dc1f32f545 100644
--- a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/keydown.ts
+++ b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/keydown.ts
@@ -17,6 +17,8 @@ import { rangeActions } from '$app_reducers/document/slice';
import { RootState } from '$app/stores/store';
import { blockConfig } from '$app/constants/document/config';
import { Keyboard } from '$app/constants/document/keyboard';
+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
@@ -33,20 +35,25 @@ export const backspaceDeleteActionForBlockThunk = createAsyncThunk(
const { dispatch, getState } = thunkAPI;
const state = (getState() as RootState).document[docId];
const node = state.nodes[id];
+
if (!node.parent) return;
const parent = state.nodes[node.parent];
const children = state.children[parent.children];
const index = children.indexOf(id);
const nextNodeId = children[index + 1];
+
// turn to text block
if (node.type !== BlockType.TextBlock) {
await dispatch(turnToTextBlockThunk({ id, controller }));
return;
}
+
const isTopLevel = parent.type === BlockType.PageBlock;
+
if (isTopLevel || nextNodeId) {
// merge to previous line
const prevLine = findPrevHasDeltaNode(state, id);
+
if (!prevLine) return;
const caretIndex = new Delta(prevLine.data.delta).length();
const caret = {
@@ -54,6 +61,7 @@ export const backspaceDeleteActionForBlockThunk = createAsyncThunk(
index: caretIndex,
length: 0,
};
+
await dispatch(
mergeDeltaThunk({
sourceId: id,
@@ -70,6 +78,7 @@ export const backspaceDeleteActionForBlockThunk = createAsyncThunk(
);
return;
}
+
// outdent
await dispatch(outdentNodeThunk({ id, controller }));
}
@@ -88,21 +97,25 @@ export const enterActionForBlockThunk = createAsyncThunk(
const { getState, dispatch } = thunkAPI;
const state = getState() as RootState;
const docId = controller.documentId;
- const documentState = state.document[docId];
+ const documentState = state[DOCUMENT_NAME][docId];
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;
const delta = new Delta(node.data.delta);
+
if (delta.length() === 0 && node.type !== BlockType.TextBlock) {
// If the node is not a text block, turn it to a text block
await dispatch(turnToTextBlockThunk({ id, controller }));
return;
}
+
const nodeDelta = delta.slice(0, caret.index);
const insertNodeDelta = new Delta(node.data.delta).slice(caret.index + caret.length);
const insertNodeAction = getInsertEnterNodeAction(node, insertNodeDelta, controller);
+
if (!insertNodeAction) return;
const updateNode = {
...node,
@@ -122,6 +135,7 @@ export const enterActionForBlockThunk = createAsyncThunk(
)
: [];
const actions = [insertNodeAction.action, controller.getUpdateAction(updateNode), ...moveChildrenAction];
+
await controller.applyActions(actions);
dispatch(rangeActions.initialState(docId));
@@ -142,6 +156,7 @@ export const tabActionForBlockThunk = createAsyncThunk(
'document/tabActionForBlock',
async (payload: { id: string; controller: DocumentController }, thunkAPI) => {
const { dispatch } = thunkAPI;
+
return dispatch(indentNodeThunk(payload));
}
);
@@ -152,10 +167,11 @@ export const upDownActionForBlockThunk = createAsyncThunk(
const { docId, id, down } = payload;
const { dispatch, getState } = thunkAPI;
const state = getState() as RootState;
- const documentState = state.document[docId];
- const rangeState = state.documentRange[docId];
+ const documentState = state[DOCUMENT_NAME][docId];
+ const rangeState = state[RANGE_NAME][docId];
const caret = rangeState.caret;
const node = documentState.nodes[id];
+
if (!node || !caret || id !== caret.id) return;
let newCaret;
@@ -165,9 +181,11 @@ export const upDownActionForBlockThunk = createAsyncThunk(
} else {
newCaret = transformToPrevLineCaret(documentState, caret);
}
+
if (!newCaret) {
return;
}
+
dispatch(rangeActions.initialState(docId));
dispatch(
rangeActions.setCaret({
@@ -184,12 +202,14 @@ export const leftActionForBlockThunk = createAsyncThunk(
const { id, docId } = payload;
const { dispatch, getState } = thunkAPI;
const state = getState() as RootState;
- const documentState = state.document[docId];
- const rangeState = state.documentRange[docId];
+ const documentState = state[DOCUMENT_NAME][docId];
+ const rangeState = state[RANGE_NAME][docId];
const caret = rangeState.caret;
const node = documentState.nodes[id];
+
if (!node || !caret || id !== caret.id) return;
let newCaret: RangeStatic;
+
if (caret.length > 0) {
newCaret = {
id,
@@ -198,15 +218,20 @@ export const leftActionForBlockThunk = createAsyncThunk(
};
} else {
if (caret.index > 0) {
+ const delta = new Delta(node.data.delta);
+ const newIndex = getPreviousWordIndex(delta, caret.index);
+
newCaret = {
id,
- index: caret.index - 1,
+ index: newIndex,
length: 0,
};
} else {
const prevNode = findPrevHasDeltaNode(documentState, id);
+
if (!prevNode) return;
const prevDelta = new Delta(prevNode.data.delta);
+
newCaret = {
id: prevNode.id,
index: prevDelta.length(),
@@ -218,6 +243,7 @@ export const leftActionForBlockThunk = createAsyncThunk(
if (!newCaret) {
return;
}
+
dispatch(rangeActions.initialState(docId));
dispatch(
rangeActions.setCaret({
@@ -234,14 +260,16 @@ export const rightActionForBlockThunk = createAsyncThunk(
const { id, docId } = payload;
const { dispatch, getState } = thunkAPI;
const state = getState() as RootState;
- const documentState = state.document[docId];
- const rangeState = state.documentRange[docId];
+ const documentState = state[DOCUMENT_NAME][docId];
+ const rangeState = state[RANGE_NAME][docId];
const caret = rangeState.caret;
const node = documentState.nodes[id];
+
if (!node || !caret || id !== caret.id) return;
let newCaret: RangeStatic;
const delta = new Delta(node.data.delta);
const deltaLength = delta.length();
+
if (caret.length > 0) {
newCaret = {
id,
@@ -251,6 +279,7 @@ export const rightActionForBlockThunk = createAsyncThunk(
} else {
if (caret.index < deltaLength) {
const newIndex = caret.index + caret.length + 1;
+
newCaret = {
id,
index: newIndex > deltaLength ? deltaLength : newIndex,
@@ -258,6 +287,7 @@ export const rightActionForBlockThunk = createAsyncThunk(
};
} else {
const nextNode = findNextHasDeltaNode(documentState, id);
+
if (!nextNode) return;
newCaret = {
id: nextNode.id,
@@ -270,6 +300,7 @@ export const rightActionForBlockThunk = createAsyncThunk(
if (!newCaret) {
return;
}
+
dispatch(rangeActions.initialState(docId));
dispatch(
@@ -285,6 +316,7 @@ export const shiftTabActionForBlockThunk = createAsyncThunk(
'document/shiftTabActionForBlock',
async (payload: { id: string; controller: DocumentController }, thunkAPI) => {
const { dispatch } = thunkAPI;
+
return dispatch(outdentNodeThunk(payload));
}
);
@@ -301,8 +333,8 @@ export const arrowActionForRangeThunk = createAsyncThunk(
const { dispatch, getState } = thunkAPI;
const { key, docId } = payload;
const state = getState() as RootState;
- const documentState = state.document[docId];
- const rangeState = state.documentRange[docId];
+ const documentState = state[DOCUMENT_NAME][docId];
+ const rangeState = state[RANGE_NAME][docId];
let caret;
const leftCaret = getLeftCaretByRange(rangeState);
const rightCaret = getRightCaretByRange(rangeState);
@@ -323,6 +355,7 @@ export const arrowActionForRangeThunk = createAsyncThunk(
caret = transformToNextLineCaret(documentState, rightCaret);
break;
}
+
if (!caret) return;
dispatch(rangeActions.initialState(docId));
dispatch(
diff --git a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/link.ts b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/link.ts
index a3ba5097ce..6e5fab053e 100644
--- a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/link.ts
+++ b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/link.ts
@@ -3,6 +3,7 @@ import { DocumentController } from '$app/stores/effects/document/document_contro
import Delta from 'quill-delta';
import { linkPopoverActions, rangeActions } from '$app_reducers/document/slice';
import { RootState } from '$app/stores/store';
+import { DOCUMENT_NAME, RANGE_NAME, TEXT_LINK_NAME } from '$app/constants/document/name';
export const formatLinkThunk = createAsyncThunk<
boolean,
@@ -14,16 +15,19 @@ export const formatLinkThunk = createAsyncThunk<
const { getState } = thunkAPI;
const docId = controller.documentId;
const state = getState() as RootState;
- const documentState = state.document[docId];
- const linkPopover = state.documentLinkPopover[docId];
+ const documentState = state[DOCUMENT_NAME][docId];
+ const linkPopover = state[TEXT_LINK_NAME][docId];
+
if (!linkPopover) return false;
const { selection, id, href, title = '' } = linkPopover;
+
if (!selection || !id) return false;
const node = documentState.nodes[id];
const nodeDelta = new Delta(node.data?.delta);
const index = selection.index || 0;
const length = selection.length || 0;
const regex = new RegExp(/^(https?:\/\/)?([\da-z.-]+)\.([a-z.]{2,6})([/\w .-]*)*\/?$/);
+
if (href !== undefined && !regex.test(href)) {
return false;
}
@@ -41,6 +45,7 @@ export const formatLinkThunk = createAsyncThunk<
delta: newDelta.ops,
},
});
+
await controller.applyActions([updateAction]);
return true;
});
@@ -53,10 +58,11 @@ export const newLinkThunk = createAsyncThunk<
>('document/newLink', async ({ docId }, thunkAPI) => {
const { getState, dispatch } = thunkAPI;
const state = getState() as RootState;
- const documentState = state.document[docId];
- const documentRange = state.documentRange[docId];
+ const documentState = state[DOCUMENT_NAME][docId];
+ const documentRange = state[RANGE_NAME][docId];
const { caret } = documentRange;
+
if (!caret) return;
const { index, length, id } = caret;
@@ -66,11 +72,14 @@ export const newLinkThunk = createAsyncThunk<
const href = op?.attributes?.href as string;
const domSelection = window.getSelection();
+
if (!domSelection) return;
const domRange = domSelection.rangeCount > 0 ? domSelection.getRangeAt(0) : null;
+
if (!domRange) return;
const title = domSelection.toString();
const { top, left, height, width } = domRange.getBoundingClientRect();
+
dispatch(rangeActions.initialState(docId));
dispatch(
linkPopoverActions.setLinkPopover({
diff --git a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/menu.ts b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/menu.ts
index b7c3e322f1..3d275b2212 100644
--- a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/menu.ts
+++ b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/menu.ts
@@ -8,6 +8,7 @@ import { blockConfig } from '$app/constants/document/config';
import Delta, { Op } from 'quill-delta';
import { getDeltaText } from '$app/utils/document/delta';
import { RootState } from '$app/stores/store';
+import { DOCUMENT_NAME } from '$app/constants/document/name';
/**
* add block below click
@@ -22,6 +23,7 @@ export const addBlockBelowClickThunk = createAsyncThunk(
const { dispatch, getState } = thunkAPI;
const state = (getState() as RootState).document[docId];
const node = state.nodes[id];
+
if (!node) return;
const delta = (node.data.delta as Op[]) || [];
const text = delta.map((d) => d.insert).join('');
@@ -31,6 +33,7 @@ export const addBlockBelowClickThunk = createAsyncThunk(
const { payload: newBlockId } = await dispatch(
insertAfterNodeThunk({ id: id, type: BlockType.TextBlock, controller, data: { delta: [] } })
);
+
if (newBlockId) {
dispatch(
rangeActions.setCaret({
@@ -40,8 +43,10 @@ export const addBlockBelowClickThunk = createAsyncThunk(
);
dispatch(slashCommandActions.openSlashCommand({ docId, blockId: newBlockId as string }));
}
+
return;
}
+
// if current block is empty, open slash command
dispatch(
rangeActions.setCaret({
@@ -76,8 +81,9 @@ export const triggerSlashCommandActionThunk = createAsyncThunk(
const { dispatch, getState } = thunkAPI;
const docId = controller.documentId;
const state = getState() as RootState;
- const document = state.document[docId];
+ const document = state[DOCUMENT_NAME][docId];
const node = document.nodes[id];
+
if (!node) return;
const delta = new Delta(node.data.delta);
const text = getDeltaText(delta);
@@ -107,6 +113,7 @@ export const triggerSlashCommandActionThunk = createAsyncThunk(
delta: delta.slice(1, delta.length()).ops,
},
};
+
await controller.applyActions([controller.getUpdateAction(updateNode)]);
}
diff --git a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/range.ts b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/range.ts
index 4d259864a8..e8cfbe18ac 100644
--- a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/range.ts
+++ b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/range.ts
@@ -13,6 +13,7 @@ import {
} from '$app/utils/document/action';
import { RangeState, SplitRelationship } from '$app/interfaces/document';
import { blockConfig } from '$app/constants/document/config';
+import { DOCUMENT_NAME, RANGE_NAME } from '$app/constants/document/name';
interface storeRangeThunkPayload {
docId: string;
@@ -32,17 +33,20 @@ export const storeRangeThunk = createAsyncThunk('document/storeRange', (payload:
const { docId, id, range } = payload;
const { dispatch, getState } = thunkAPI;
const state = getState() as RootState;
- const rangeState = state.documentRange[docId];
- const documentState = state.document[docId];
+ const rangeState = state[RANGE_NAME][docId];
+ const documentState = state[DOCUMENT_NAME][docId];
// we need amend range between anchor and focus
const { anchor, focus, isDragging } = rangeState;
+
if (!isDragging || !anchor || !focus) return;
const ranges: RangeState['ranges'] = {};
+
ranges[id] = range;
// pin anchor index
let anchorIndex = anchor.point.index;
let anchorLength = anchor.point.length;
+
if (anchorIndex === undefined || anchorLength === undefined) {
dispatch(
rangeActions.setAnchorPointRange({
@@ -68,14 +72,17 @@ export const storeRangeThunk = createAsyncThunk('document/storeRange', (payload:
// amend anchor range because slatejs will stop update selection when dragging quickly
const isForward = anchor.point.y < focus.point.y;
const anchorDelta = new Delta(documentState.nodes[anchor.id].data.delta);
+
if (isForward) {
const selectedDelta = anchorDelta.slice(anchorIndex);
+
ranges[anchor.id] = {
index: anchorIndex,
length: selectedDelta.length(),
};
} else {
const selectedDelta = anchorDelta.slice(0, anchorIndex + anchorLength);
+
ranges[anchor.id] = {
index: 0,
length: selectedDelta.length(),
@@ -87,6 +94,7 @@ export const storeRangeThunk = createAsyncThunk('document/storeRange', (payload:
const endId = isForward ? focus.id : anchor.id;
const middleIds = getMiddleIds(documentState, startId, endId);
+
middleIds.forEach((id) => {
const node = documentState.nodes[id];
@@ -121,19 +129,22 @@ export const deleteRangeAndInsertThunk = createAsyncThunk(
const docId = controller.documentId;
const { getState, dispatch } = thunkAPI;
const state = getState() as RootState;
- const rangeState = state.documentRange[docId];
- const documentState = state.document[docId];
+ const rangeState = state[RANGE_NAME][docId];
+ const documentState = state[DOCUMENT_NAME][docId];
const actions = [];
// get merge actions
const mergeActions = getMergeEndDeltaToStartActionsByRange(state, controller, insertDelta);
+
if (mergeActions) {
actions.push(...mergeActions);
}
+
// get middle nodes
const middleIds = getMiddleIdsByRange(rangeState, documentState);
// delete middle nodes
const deleteMiddleNodesActions = middleIds?.map((id) => controller.getDeleteAction(documentState.nodes[id])) || [];
+
actions.push(...deleteMiddleNodesActions);
const caret = getAfterMergeCaretByRange(rangeState, insertDelta);
@@ -170,11 +181,12 @@ export const deleteRangeAndInsertEnterThunk = createAsyncThunk(
const { getState, dispatch } = thunkAPI;
const docId = controller.documentId;
const state = getState() as RootState;
- const rangeState = state.documentRange[docId];
- const documentState = state.document[docId];
+ const rangeState = state[RANGE_NAME][docId];
+ const documentState = state[DOCUMENT_NAME][docId];
const actions = [];
const { startDelta, endDelta, endNode, startNode } = getStartAndEndExtentDelta(documentState, rangeState) || {};
+
if (!startDelta || !endDelta || !endNode || !startNode) return;
// get middle nodes
@@ -182,12 +194,14 @@ export const deleteRangeAndInsertEnterThunk = createAsyncThunk(
let newStartDelta = new Delta(startDelta);
let caret = null;
+
if (shiftKey) {
newStartDelta = newStartDelta.insert('\n').concat(endDelta);
caret = getAfterMergeCaretByRange(rangeState, new Delta().insert('\n'));
} else {
const insertNodeDelta = new Delta(endDelta);
const insertNodeAction = getInsertEnterNodeAction(startNode, insertNodeDelta, controller);
+
if (!insertNodeAction) return;
actions.push(insertNodeAction.action);
caret = {
@@ -198,6 +212,7 @@ export const deleteRangeAndInsertEnterThunk = createAsyncThunk(
// move start node children to insert node
const needMoveChildren =
blockConfig[startNode.type].splitProps?.nextLineRelationShip === SplitRelationship.NextSibling;
+
if (needMoveChildren) {
// filter children by delete middle ids
const children = documentState.children[startNode.children].filter((id) => middleIds?.includes(id));
@@ -208,6 +223,7 @@ export const deleteRangeAndInsertEnterThunk = createAsyncThunk(
''
)
: [];
+
actions.push(...moveChildrenAction);
}
}
@@ -220,14 +236,17 @@ export const deleteRangeAndInsertEnterThunk = createAsyncThunk(
delta: newStartDelta.ops,
},
});
+
if (endNode.id !== startNode.id) {
// delete end node
const deleteAction = controller.getDeleteAction(endNode);
+
actions.push(updateAction, deleteAction);
}
// delete middle nodes
const deleteMiddleNodesActions = middleIds?.map((id) => controller.getDeleteAction(documentState.nodes[id])) || [];
+
actions.push(...deleteMiddleNodesActions);
// apply actions
diff --git a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/temporary.ts b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/temporary.ts
new file mode 100644
index 0000000000..6062a1d6b7
--- /dev/null
+++ b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/temporary.ts
@@ -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,
+ };
+ }
+);
diff --git a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/slice.ts b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/slice.ts
index b964df3c66..0e01b9da9f 100644
--- a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/slice.ts
+++ b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/slice.ts
@@ -11,6 +11,14 @@ import {
import { BlockEventPayloadPB } from '@/services/backend';
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
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 = {};
@@ -23,7 +31,7 @@ const slashCommandInitialState: Record = {};
const linkPopoverState: Record = {};
export const documentSlice = createSlice({
- name: 'document',
+ name: DOCUMENT_NAME,
initialState: initialState,
// Here we can't offer actions to update the document state.
// Because the document state is updated by the `onDataChange`
@@ -91,7 +99,7 @@ export const documentSlice = createSlice({
});
export const rectSelectionSlice = createSlice({
- name: 'documentRectSelection',
+ name: RECT_RANGE_NAME,
initialState: rectSelectionInitialState,
reducers: {
initialState: (state, action: PayloadAction) => {
@@ -150,7 +158,7 @@ export const rectSelectionSlice = createSlice({
});
export const rangeSlice = createSlice({
- name: 'documentRange',
+ name: RANGE_NAME,
initialState: rangeInitialState,
reducers: {
initialState: (state, action: PayloadAction) => {
@@ -208,16 +216,19 @@ export const rangeSlice = createSlice({
state,
action: PayloadAction<{
docId: string;
- id: string;
- point: { x: number; y: number };
+ anchorPoint?: {
+ id: string;
+ point: { x: number; y: number };
+ };
}>
) => {
- const { docId, id, point } = action.payload;
+ const { docId, anchorPoint } = action.payload;
- state[docId].anchor = {
- id,
- point,
- };
+ if (anchorPoint) {
+ state[docId].anchor = { ...anchorPoint };
+ } else {
+ delete state[docId].anchor;
+ }
},
setAnchorPointRange: (
state,
@@ -241,17 +252,21 @@ export const rangeSlice = createSlice({
state,
action: PayloadAction<{
docId: string;
- id: string;
- point: { x: number; y: number };
+ focusPoint?: {
+ id: string;
+ point: { x: number; y: number };
+ };
}>
) => {
- const { docId, id, point } = action.payload;
+ const { docId, focusPoint } = action.payload;
- state[docId].focus = {
- id,
- point,
- };
+ if (focusPoint) {
+ state[docId].focus = { ...focusPoint };
+ } else {
+ delete state[docId].focus;
+ }
},
+
setDragging: (
state,
action: PayloadAction<{
@@ -295,6 +310,12 @@ export const rangeSlice = createSlice({
) => {
const { docId, exclude } = action.payload;
const ranges = state[docId].ranges;
+
+ if (!exclude) {
+ state[docId].ranges = {};
+ return;
+ }
+
const newRanges = Object.keys(ranges).reduce((acc, id) => {
if (id !== exclude) return { ...acc };
return {
@@ -309,7 +330,7 @@ export const rangeSlice = createSlice({
});
export const slashCommandSlice = createSlice({
- name: 'documentSlashCommand',
+ name: SLASH_COMMAND_NAME,
initialState: slashCommandInitialState,
reducers: {
initialState: (state, action: PayloadAction) => {
@@ -365,7 +386,7 @@ export const slashCommandSlice = createSlice({
});
export const linkPopoverSlice = createSlice({
- name: 'documentLinkPopover',
+ name: TEXT_LINK_NAME,
initialState: linkPopoverState,
reducers: {
initialState: (state, action: PayloadAction) => {
@@ -418,6 +439,7 @@ export const documentReducers = {
[rangeSlice.name]: rangeSlice.reducer,
[slashCommandSlice.name]: slashCommandSlice.reducer,
[linkPopoverSlice.name]: linkPopoverSlice.reducer,
+ [temporarySlice.name]: temporarySlice.reducer,
};
export const documentActions = documentSlice.actions;
diff --git a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/temporary_slice.ts b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/temporary_slice.ts
new file mode 100644
index 0000000000..7ace97d7bc
--- /dev/null
+++ b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/temporary_slice.ts
@@ -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 = {};
+
+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 }>) => {
+ 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) => {
+ const id = action.payload;
+
+ delete state[id];
+ },
+ },
+});
+
+export const temporaryActions = temporarySlice.actions;
diff --git a/frontend/appflowy_tauri/src/appflowy_app/utils/document/action.ts b/frontend/appflowy_tauri/src/appflowy_app/utils/document/action.ts
index e53138556c..7c3a6d07dc 100644
--- a/frontend/appflowy_tauri/src/appflowy_app/utils/document/action.ts
+++ b/frontend/appflowy_tauri/src/appflowy_app/utils/document/action.ts
@@ -23,6 +23,7 @@ import {
transformIndexToNextLine,
transformIndexToPrevLine,
} from '$app/utils/document/delta';
+import { DOCUMENT_NAME, RANGE_NAME } from '$app/constants/document/name';
export function getMiddleIds(document: DocumentState, startId: string, endId: string) {
const middleIds = [];
@@ -116,8 +117,8 @@ export function getMergeEndDeltaToStartActionsByRange(
) {
const actions = [];
const docId = controller.documentId;
- const documentState = state.document[docId];
- const rangeState = state.documentRange[docId];
+ const documentState = state[DOCUMENT_NAME][docId];
+ const rangeState = state[RANGE_NAME][docId];
const { startDelta, endDelta, endNode, startNode } = getStartAndEndExtentDelta(documentState, rangeState) || {};
if (!startDelta || !endDelta || !endNode || !startNode) return;
diff --git a/frontend/appflowy_tauri/src/appflowy_app/utils/document/delta.ts b/frontend/appflowy_tauri/src/appflowy_app/utils/document/delta.ts
index 5ca8dae7bb..8a85aacb15 100644
--- a/frontend/appflowy_tauri/src/appflowy_app/utils/document/delta.ts
+++ b/frontend/appflowy_tauri/src/appflowy_app/utils/document/delta.ts
@@ -1,18 +1,22 @@
import Delta from 'quill-delta';
+import emojiRegex from 'emoji-regex';
export function getDeltaText(delta: Delta) {
const text = delta
.filter((op) => typeof op.insert === 'string')
.map((op) => op.insert)
.join('');
+
return text;
}
export function caretInTopEdgeByDelta(delta: Delta, index: number) {
const text = getDeltaText(delta.slice(0, index));
+
if (!text) return true;
const firstLine = text.split('\n')[0];
+
return index <= firstLine.length;
}
@@ -31,6 +35,7 @@ export function getLineByIndex(delta: Delta, index: number) {
const startLineText = beforeLines[beforeLines.length - 1];
const currentLineText = startLineText + afterLines[0];
+
return {
text: currentLineText,
index: beforeText.length - startLineText.length,
@@ -40,9 +45,11 @@ export function getLineByIndex(delta: Delta, index: number) {
export function transformIndexToPrevLine(delta: Delta, index: number) {
const text = getDeltaText(delta.slice(0, index));
const lines = text.split('\n');
+
if (lines.length < 2) return 0;
const prevLineText = lines[lines.length - 2];
const transformedIndex = index - prevLineText.length - 1;
+
return transformedIndex > 0 ? transformedIndex : 0;
}
@@ -54,6 +61,7 @@ export function transformIndexToNextLine(delta: Delta, index: number) {
const text = getDeltaText(delta);
const currentLineText = getCurrentLineText(delta, index);
const transformedIndex = index + currentLineText.length + 1;
+
return transformedIndex > text.length ? text.length : transformedIndex;
}
@@ -61,12 +69,14 @@ export function getIndexRelativeEnter(delta: Delta, index: number) {
const text = getDeltaText(delta.slice(0, index));
const beforeLines = text.split('\n');
const beforeLineText = beforeLines[beforeLines.length - 1];
+
return beforeLineText.length;
}
export function getLastLineIndex(delta: Delta) {
const text = getDeltaText(delta);
const lastIndex = text.lastIndexOf('\n');
+
return lastIndex === -1 ? 0 : lastIndex + 1;
}
@@ -79,6 +89,7 @@ export function getDeltaByRange(
) {
const start = range.index;
const end = range.index + range.length;
+
return new Delta(delta.slice(start, end));
}
@@ -90,6 +101,7 @@ export function getBeofreExtentDeltaByRange(
}
) {
const start = range.index;
+
return new Delta(delta.slice(0, start));
}
@@ -101,5 +113,46 @@ export function getAfterExtentDeltaByRange(
}
) {
const start = range.index + range.length;
+
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;
+}
diff --git a/frontend/appflowy_tauri/src/appflowy_app/utils/document/node.ts b/frontend/appflowy_tauri/src/appflowy_app/utils/document/node.ts
index b7d1ebbb20..d50cb04b2b 100644
--- a/frontend/appflowy_tauri/src/appflowy_app/utils/document/node.ts
+++ b/frontend/appflowy_tauri/src/appflowy_app/utils/document/node.ts
@@ -4,11 +4,13 @@ function isTextNode(node: Node): boolean {
export function exclude(node: Element) {
let isPlaceholder = false;
+
try {
isPlaceholder = !!node.getAttribute('data-slate-placeholder');
} catch (e) {
// ignore
}
+
return isPlaceholder;
}
@@ -16,13 +18,16 @@ export function findFirstTextNode(node: Node): Node | null {
if (isTextNode(node)) {
return node;
}
+
if (exclude && exclude(node as Element)) {
return null;
}
const children = node.childNodes;
+
for (let i = 0; i < children.length; i++) {
const textNode = findFirstTextNode(children[i]);
+
if (textNode) {
return textNode;
}
@@ -41,6 +46,7 @@ export function setCursorAtStartOfNode(node: Node): void {
}
const selection = window.getSelection();
+
selection?.removeAllRanges();
selection?.addRange(range);
}
@@ -55,8 +61,10 @@ export function findLastTextNode(node: Node): Node | null {
}
const children = node.childNodes;
+
for (let i = children.length - 1; i >= 0; i--) {
const textNode = findLastTextNode(children[i]);
+
if (textNode) {
return textNode;
}
@@ -71,11 +79,13 @@ export function setCursorAtEndOfNode(node: Node): void {
if (textNode) {
const textLength = textNode.textContent?.length || 0;
+
range.setStart(textNode, textLength);
range.setEnd(textNode, textLength);
}
const selection = window.getSelection();
+
selection?.removeAllRanges();
selection?.addRange(range);
}
@@ -84,47 +94,60 @@ export function setFullRangeAtNode(node: Node): void {
const range = document.createRange();
const firstTextNode = findFirstTextNode(node);
const lastTextNode = findLastTextNode(node);
+
if (!firstTextNode || !lastTextNode) return;
range.setStart(firstTextNode, 0);
range.setEnd(lastTextNode, lastTextNode.textContent?.length || 0);
const selection = window.getSelection();
+
selection?.removeAllRanges();
selection?.addRange(range);
}
export function getBlockIdByPoint(target: HTMLElement | null) {
let node = target;
+
while (node) {
const id = node.getAttribute('data-block-id');
+
if (id) {
return id;
}
+
node = node.parentElement;
}
+
return null;
}
export function findTextBoxParent(target: HTMLElement | null) {
let node = target;
+
while (node) {
if (node.getAttribute('role') === 'textbox') {
return node;
}
+
node = node.parentElement;
}
+
return null;
}
export function isFocused(blockId: string) {
const selection = window.getSelection();
+
if (!selection) return false;
const { anchorNode, focusNode } = selection;
+
if (!anchorNode || !focusNode) return false;
const anchorElement = anchorNode.parentElement;
const focusElement = focusNode.parentElement;
+
if (!anchorElement || !focusElement) return false;
const anchorBlockId = getBlockIdByPoint(anchorElement);
const focusBlockId = getBlockIdByPoint(focusElement);
+
return anchorBlockId === blockId || focusBlockId === blockId;
}
@@ -134,12 +157,15 @@ export function getNode(id: string) {
export function isPointInBlock(target: HTMLElement | null) {
let node = target;
+
while (node) {
if (node.getAttribute('data-block-id')) {
return true;
}
+
node = node.parentElement;
}
+
return false;
}
@@ -153,21 +179,27 @@ export function findTextNode(
} {
if (isTextNode(node)) {
const textLength = node.textContent?.length || 0;
+
if (index <= textLength) {
return { node, offset: index };
}
+
return { remainingIndex: index - textLength };
}
if (exclude && exclude(node)) {
return { remainingIndex: index };
}
+
let remainingIndex = index;
+
for (const childNode of node.childNodes) {
const result = findTextNode(childNode as Element, remainingIndex);
+
if (result.node) {
return result;
}
+
remainingIndex = result.remainingIndex || index;
}
@@ -176,6 +208,7 @@ export function findTextNode(
export function getRangeByIndex(node: Element, index: number, length: number) {
const textBoxNode = node.querySelector(`[role="textbox"]`);
+
if (!textBoxNode) return;
const anchorNode = findTextNode(textBoxNode, index);
const focusNode = findTextNode(textBoxNode, index + length);
@@ -183,6 +216,7 @@ export function getRangeByIndex(node: Element, index: number, length: number) {
if (!anchorNode?.node || !focusNode?.node) return;
const range = document.createRange();
+
range.setStart(anchorNode.node, anchorNode.offset || 0);
range.setEnd(focusNode.node, focusNode.offset || 0);
return range;
@@ -190,19 +224,25 @@ export function getRangeByIndex(node: Element, index: number, length: number) {
export function focusNodeByIndex(node: Element, index: number, length: number) {
const range = getRangeByIndex(node, index, length);
+
if (!range) return false;
const selection = window.getSelection();
+
selection?.removeAllRanges();
+
selection?.addRange(range);
const focusNode = selection?.focusNode;
+
if (!focusNode) return false;
const parent = findParent(focusNode as Element, node);
+
return Boolean(parent);
}
export function getNodeTextBoxByBlockId(blockId: string) {
const node = getNode(blockId);
+
return node?.querySelector(`[role="textbox"]`);
}
@@ -210,13 +250,17 @@ export function getNodeText(node: Element) {
if (isTextNode(node)) {
return node.textContent || '';
}
+
if (exclude && exclude(node)) {
return '';
}
+
let text = '';
+
for (const childNode of node.childNodes) {
text += getNodeText(childNode as Element);
}
+
return replaceZeroWidthSpace(text);
}
@@ -231,14 +275,18 @@ export function replaceZeroWidthSpace(text: string) {
export function findParent(node: Element, parentSelector: string | Element) {
let parentNode: Element | null = node;
+
while (parentNode) {
if (typeof parentSelector === 'string' && parentNode.matches(parentSelector)) {
return parentNode;
}
+
if (parentNode === parentSelector) {
return parentNode;
}
+
parentNode = parentNode.parentElement;
}
+
return null;
}
diff --git a/frontend/appflowy_tauri/src/appflowy_app/utils/document/slate_editor.ts b/frontend/appflowy_tauri/src/appflowy_app/utils/document/slate_editor.ts
index 1002ff39ce..619dcf06f0 100644
--- a/frontend/appflowy_tauri/src/appflowy_app/utils/document/slate_editor.ts
+++ b/frontend/appflowy_tauri/src/appflowy_app/utils/document/slate_editor.ts
@@ -1,8 +1,28 @@
-import { BaseElement, BasePoint, Descendant, Editor, Element, Selection, Text } from "slate";
-import Delta from "quill-delta";
-import { getLineByIndex } from "$app/utils/document/delta";
+import { BaseElement, BasePoint, Descendant, Editor, Element, Selection, Text } from 'slate';
+import Delta from 'quill-delta';
+import { getLineByIndex } from '$app/utils/document/delta';
-export function convertToSlateSelection(index: number, length: number, slateValue: Descendant[]){
+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;
const texts = (slateValue[0] as BaseElement).children.map((child) => (child as Text).text);
const anchorIndex = index;
@@ -12,16 +32,20 @@ export function convertToSlateSelection(index: number, length: number, slateValu
let anchorOffset = 0;
let focusOffset = 0;
let charCount = 0;
+
texts.forEach((text, i) => {
const endOffset = charCount + text.length;
+
if (anchorIndex >= charCount && anchorIndex <= endOffset) {
anchorPath = [0, i];
anchorOffset = anchorIndex - charCount;
}
+
if (focusIndex >= charCount && focusIndex <= endOffset) {
focusPath = [0, i];
focusOffset = focusIndex - charCount;
}
+
charCount += text.length;
});
return {
@@ -50,6 +74,7 @@ export function converToIndexLength(editor: Editor, range: Selection) {
focus: after,
}).length;
const length = focusIndex - index;
+
return { index, length };
}
@@ -82,53 +107,63 @@ export function convertToSlateValue(delta: Delta): Descendant[] {
export function convertToDelta(slateValue: Descendant[]) {
const ops = (slateValue[0] as Element).children.map((child) => {
const { text, ...attributes } = child as Text;
+
return {
insert: text,
attributes,
};
});
+
return new Delta(ops);
}
function getBreakLineBeginPoint(editor: Editor, at: Selection): BasePoint | undefined {
const delta = convertToDelta(editor.children);
const currentSelection = converToIndexLength(editor, at);
+
if (!currentSelection) return;
const { index } = getLineByIndex(delta, currentSelection.index);
const selection = convertToSlateSelection(index, 0, editor.children);
+
return selection?.anchor;
}
export function indent(editor: Editor, distance: number) {
const beginPoint = getBreakLineBeginPoint(editor, editor.selection);
+
if (!beginPoint) return;
- const emptyStr = "".padStart(distance);
+ const emptyStr = ''.padStart(distance);
editor.insertText(emptyStr, {
- at: beginPoint
+ at: beginPoint,
});
}
export function outdent(editor: Editor, distance: number) {
const beginPoint = getBreakLineBeginPoint(editor, editor.selection);
+
if (!beginPoint) return;
const afterBeginPoint = Editor.after(editor, beginPoint, {
- distance
+ distance,
});
+
if (!afterBeginPoint) return;
const deleteChar = Editor.string(editor, {
anchor: beginPoint,
- focus: afterBeginPoint
+ focus: afterBeginPoint,
});
- const emptyStr = "".padStart(distance);
+ const emptyStr = ''.padStart(distance);
+
if (deleteChar !== emptyStr) {
if (distance > 1) {
outdent(editor, distance - 1);
}
+
return;
}
+
editor.delete({
at: beginPoint,
- distance
+ distance,
});
-}
\ No newline at end of file
+}
diff --git a/frontend/appflowy_tauri/src/appflowy_app/utils/document/subscribe.ts b/frontend/appflowy_tauri/src/appflowy_app/utils/document/subscribe.ts
index 954f08bc4b..96061af1c9 100644
--- a/frontend/appflowy_tauri/src/appflowy_app/utils/document/subscribe.ts
+++ b/frontend/appflowy_tauri/src/appflowy_app/utils/document/subscribe.ts
@@ -1,8 +1,8 @@
import { DeltaTypePB } from '@/services/backend/models/flowy-document2';
import { BlockPBValue, BlockType, ChangeType, DocumentState, NestedBlock } from '$app/interfaces/document';
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 { 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
const matchCases = [
diff --git a/frontend/appflowy_tauri/src/appflowy_app/utils/document/temporary.ts b/frontend/appflowy_tauri/src/appflowy_app/utils/document/temporary.ts
new file mode 100644
index 0000000000..059c8b2e4c
--- /dev/null
+++ b/frontend/appflowy_tauri/src/appflowy_app/utils/document/temporary.ts
@@ -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;
+}
diff --git a/frontend/appflowy_tauri/src/appflowy_app/utils/env.ts b/frontend/appflowy_tauri/src/appflowy_app/utils/env.ts
new file mode 100644
index 0000000000..fd5aa54113
--- /dev/null
+++ b/frontend/appflowy_tauri/src/appflowy_app/utils/env.ts
@@ -0,0 +1,3 @@
+export function isApple() {
+ return typeof navigator !== 'undefined' && /Mac OS X/.test(navigator.userAgent);
+}