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 ( +
+ +
Link
+
+ ); + 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 ( -
- -
Link
-
- ); - 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); +}