diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/DocumentBanner/cover/ChangeCoverPopover.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/document/DocumentBanner/cover/ChangeCoverPopover.tsx index c14a9b9404..9c37cbe775 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/DocumentBanner/cover/ChangeCoverPopover.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/DocumentBanner/cover/ChangeCoverPopover.tsx @@ -43,7 +43,7 @@ function ChangeCoverPopover({
- +
{renderAlign(align)}
-
+ setPopoverOpen(true)} onClose={() => setPopoverOpen(false)} /> - +
{ dispatch(deleteNodeThunk({ id, controller })); @@ -33,7 +33,7 @@ function ImageToolbar({ id, open, align }: { id: string; open: boolean; align: A >
-
+
); diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/Mention/Mention.hooks.ts b/frontend/appflowy_tauri/src/appflowy_app/components/document/Mention/Mention.hooks.ts new file mode 100644 index 0000000000..e569dd2249 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/Mention/Mention.hooks.ts @@ -0,0 +1,88 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import Delta, { Op } from 'quill-delta'; +import { getDeltaText } from '$app/utils/document/delta'; +import { useSubscribeNode } from '$app/components/document/_shared/SubscribeNode.hooks'; +import { useAppSelector } from '$app/stores/store'; +import { Page } from '$app_reducers/pages/slice'; + +export function useSubscribeMentionSearchText({ blockId, open }: { blockId: string; open: boolean }) { + const [searchText, setSearchText] = useState(''); + const beforeOpenDeltaRef = useRef([]); + const { node } = useSubscribeNode(blockId); + const handleSearch = useCallback((newDelta: Delta) => { + const diff = new Delta(beforeOpenDeltaRef.current).diff(newDelta); + const text = getDeltaText(diff); + + setSearchText(text); + }, []); + + useEffect(() => { + if (!open) return; + handleSearch(new Delta(node?.data?.delta)); + }, [handleSearch, node?.data?.delta, open]); + + useEffect(() => { + if (!open) return; + beforeOpenDeltaRef.current = node?.data?.delta; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open]); + + return { + searchText, + }; +} +export function useMentionPopoverProps({ open }: { open: boolean }) { + const [anchorPosition, setAnchorPosition] = useState< + | { + top: number; + left: number; + } + | undefined + >(undefined); + const popoverOpen = Boolean(anchorPosition); + const getPosition = useCallback(() => { + const range = document.getSelection()?.getRangeAt(0); + const rangeRect = range?.getBoundingClientRect(); + return rangeRect; + }, []); + + useEffect(() => { + if (open) { + const position = getPosition(); + if (!position) return; + setAnchorPosition({ + top: position.top + position.height || 0, + left: position.left + 14 || 0, + }); + } else { + setAnchorPosition(undefined); + } + }, [getPosition, open]); + + return { + anchorPosition, + popoverOpen, + }; +} + +export function useLoadRecentPages(searchText: string) { + const [recentPages, setRecentPages] = useState([]); + const pages = useAppSelector((state) => state.pages.pageMap); + + useEffect(() => { + const recentPages = Object.values(pages) + .map((page) => { + return page; + }) + .filter((page) => { + const text = searchText.slice(1, searchText.length); + if (!text) return true; + return page.name.toLowerCase().includes(text.toLowerCase()); + }); + setRecentPages(recentPages); + }, [pages, searchText]); + + return { + recentPages, + }; +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/Mention/MentionPopover.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/document/Mention/MentionPopover.tsx new file mode 100644 index 0000000000..de1e152241 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/Mention/MentionPopover.tsx @@ -0,0 +1,84 @@ +import React, { useCallback, useEffect } from 'react'; +import { useSubscribeMentionState } from '$app/components/document/_shared/SubscribeMention.hooks'; +import Popover from '@mui/material/Popover'; +import { useAppDispatch } from '$app/stores/store'; +import { mentionActions } from '$app_reducers/document/mention_slice'; +import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks'; +import { useMentionPopoverProps, useSubscribeMentionSearchText } from '$app/components/document/Mention/Mention.hooks'; +import RecentPages from '$app/components/document/Mention/RecentPages'; +import { formatMention, MentionType } from '$app_reducers/document/async-actions/mention'; + +function MentionPopover() { + const { docId, controller } = useSubscribeDocument(); + const { open, blockId } = useSubscribeMentionState(); + const dispatch = useAppDispatch(); + const onClose = useCallback(() => { + dispatch( + mentionActions.close({ + docId, + }) + ); + }, [dispatch, docId]); + + const { searchText } = useSubscribeMentionSearchText({ + blockId, + open, + }); + + const { popoverOpen, anchorPosition } = useMentionPopoverProps({ + open, + }); + + useEffect(() => { + if (searchText === '' && popoverOpen) { + onClose(); + } + }, [searchText, popoverOpen, onClose]); + + const onSelectPage = useCallback( + async (pageId: string) => { + await dispatch( + formatMention({ + controller, + type: MentionType.PAGE, + value: pageId, + searchTextLength: searchText.length, + }) + ); + onClose(); + }, + [controller, dispatch, searchText.length, onClose] + ); + + if (!open) return null; + return ( + +
+ +
+
+ ); +} + +export default MentionPopover; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/Mention/RecentPages.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/document/Mention/RecentPages.tsx new file mode 100644 index 0000000000..da746eb679 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/Mention/RecentPages.tsx @@ -0,0 +1,59 @@ +import React, { useEffect, useState } from 'react'; +import { List } from '@mui/material'; +import MenuItem from '@mui/material/MenuItem'; +import { useLoadRecentPages } from '$app/components/document/Mention/Mention.hooks'; +import { useTranslation } from 'react-i18next'; +import { Article } from '@mui/icons-material'; +import { useBindArrowKey } from '$app/components/document/_shared/useBindArrowKey'; + +function RecentPages({ searchText, onSelect }: { searchText: string; onSelect: (pageId: string) => void }) { + const { t } = useTranslation(); + const { recentPages } = useLoadRecentPages(searchText); + const [selectOption, setSelectOption] = useState(null); + + const { run, stop } = useBindArrowKey({ + options: recentPages.map((item) => item.id), + onChange: (key) => { + setSelectOption(key); + }, + selectOption, + onEnter: () => selectOption && onSelect(selectOption), + }); + + useEffect(() => { + if (recentPages.length > 0) { + run(); + } else { + stop(); + } + }, [recentPages, run, stop]); + + return ( + +
{t('document.mention.page.label')}
+ {recentPages.map((page) => ( + { + setSelectOption(page.id); + }} + selected={selectOption === page.id} + key={page.id} + onClick={() => { + onSelect(page.id); + }} + > +
+
{page.icon?.value ||
}
+
{page.name || t('menuAppHeader.defaultNewPageName')}
+
+
+ ))} +
+ ); +} + +export default RecentPages; 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 09f149d687..32035418ef 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 @@ -6,6 +6,7 @@ import { useCopy } from '$app/components/document/_shared/CopyPasteHooks/useCopy import { usePaste } from '$app/components/document/_shared/CopyPasteHooks/usePaste'; import { useUndoRedo } from '$app/components/document/_shared/UndoHooks/useUndoRedo'; import TemporaryPopover from '$app/components/document/_shared/TemporaryInput/TemporaryPopover'; +import MentionPopover from '$app/components/document/Mention/MentionPopover'; export default function Overlay({ container }: { container: HTMLDivElement }) { useCopy(container); @@ -17,6 +18,7 @@ export default function Overlay({ container }: { container: HTMLDivElement }) { + ); } diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/Root/index.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/document/Root/index.tsx index 67c7213250..23b9e6720c 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/Root/index.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/Root/index.tsx @@ -20,7 +20,7 @@ function Root({ documentData }: { documentData: DocumentData }) { return ( <> -
+
diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/TextActionMenu/menu/ColorPicker.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/document/TextActionMenu/menu/ColorPicker.tsx index a1dfc84b60..9427b98bc7 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/TextActionMenu/menu/ColorPicker.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/TextActionMenu/menu/ColorPicker.tsx @@ -119,7 +119,7 @@ function ColorPicker({ } }, [selectOption, formatColor, colors]); - useBindArrowKey({ + const { run, stop } = useBindArrowKey({ options: colors.map((item) => item.key), onChange: (key) => { setSelectOption(key); @@ -128,6 +128,14 @@ function ColorPicker({ onEnter: () => onClick(), }); + useEffect(() => { + if (open) { + run(); + } else { + stop(); + } + }, [open, run, stop]); + return ( <>
}, [icon]); return ( - +
formatClick(format)}> {formatIcon}
-
+ ); }; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/TextActionMenu/menu/TurnIntoSelect.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/document/TextActionMenu/menu/TurnIntoSelect.tsx index 2e9e91fdf4..cdbf752e54 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/TextActionMenu/menu/TurnIntoSelect.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/TextActionMenu/menu/TurnIntoSelect.tsx @@ -3,7 +3,7 @@ import TurnIntoPopover from '$app/components/document/_shared/TurnInto'; import ArrowDropDown from '@mui/icons-material/ArrowDropDown'; import { useSubscribeNode } from '$app/components/document/_shared/SubscribeNode.hooks'; import { useTranslation } from 'react-i18next'; -import ToolbarTooltip from '../../_shared/ToolbarTooltip'; +import Tooltip from '@mui/material/Tooltip'; function TurnIntoSelect({ id }: { id: string }) { const [anchorPosition, setAnchorPosition] = React.useState<{ @@ -30,12 +30,12 @@ function TurnIntoSelect({ id }: { id: string }) { return ( <> - +
{node.type}
-
+ ) => { + return e.key === '@'; + }, + handler: (e: React.KeyboardEvent) => { + dispatch(openMention({ docId })); + }, + }, ...turnIntoEvents, ]; - }, [commonKeyEvents, controller, dispatch, id, turnIntoEvents]); + }, [docId, commonKeyEvents, controller, dispatch, id, turnIntoEvents]); const onKeyDown = useCallback( (e: React.KeyboardEvent) => { diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/InlineBlock/CodeInline.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/InlineBlock/CodeInline.tsx new file mode 100644 index 0000000000..4f2c031602 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/InlineBlock/CodeInline.tsx @@ -0,0 +1,18 @@ +import React from 'react'; + +function CodeInline({ text, children, selected }: { text: string; children: React.ReactNode; selected: boolean }) { + return ( + + {children} + + ); +} + +export default CodeInline; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/InlineBlock/FakeCursorContainer.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/InlineBlock/FakeCursorContainer.tsx new file mode 100644 index 0000000000..8ea849be6f --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/InlineBlock/FakeCursorContainer.tsx @@ -0,0 +1,91 @@ +import React, { useContext, useEffect, useRef, useState } from 'react'; +import { useFocused, useRangeRef } from '$app/components/document/_shared/SubscribeSelection.hooks'; +import { NodeIdContext } from '$app/components/document/_shared/SubscribeNode.hooks'; + +/** + * This component is used to wrap the cursor display position for inline block. + * Since the children of inline blocks are just single characters, + * if not wrapped, the cursor position would follow the character instead of the block's boundary. + * This component ensures that when the cursor switches between characters, + * it is wrapped to move within the block's boundary. + */ +export const FakeCursorContainer = ({ + isFirst, + isLast, + onClick, + getSelection, + children, + renderNode, +}: { + onClick?: (node: HTMLSpanElement) => void; + getSelection: (element: HTMLElement) => { index: number; length: number } | null; + isFirst: boolean; + isLast: boolean; + children: React.ReactNode; + renderNode: () => React.ReactNode; +}) => { + const id = useContext(NodeIdContext); + const ref = useRef(null); + const { focused, focusCaret } = useFocused(id); + const rangeRef = useRangeRef(); + const [position, setPosition] = useState<'left' | 'right' | undefined>(); + + useEffect(() => { + setPosition(undefined); + 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) { + setPosition('left'); + return; + } + + if (distance === -1) { + setPosition('right'); + return; + } + }, [focused, focusCaret, getSelection, 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); + }; + }, []); + + return ( + ref.current && onClick?.(ref.current)}> + + {children} + + + {renderNode()} + + {isLast && } + + ); +}; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/InlineBlock/FormulaInline.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/InlineBlock/FormulaInline.tsx new file mode 100644 index 0000000000..6646c7c92a --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/InlineBlock/FormulaInline.tsx @@ -0,0 +1,67 @@ +import React, { useCallback, useContext } from 'react'; +import { NodeIdContext } from '$app/components/document/_shared/SubscribeNode.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 { FakeCursorContainer } from '$app/components/document/_shared/InlineBlock/FakeCursorContainer'; + +function FormulaInline({ + isFirst, + isLast, + children, + getSelection, + selectedText, + data, +}: { + getSelection: (node: Element) => RangeStaticNoId | null; + children: React.ReactNode; + selectedText: string; + isLast: boolean; + isFirst: boolean; + data: { + latex?: string; + }; +}) { + const id = useContext(NodeIdContext); + const { docId } = useSubscribeDocument(); + const dispatch = useAppDispatch(); + const onClick = useCallback( + (node: HTMLSpanElement) => { + const selection = getSelection(node); + + if (!selection) return; + + dispatch( + createTemporary({ + docId, + state: { + id, + selection, + selectedText, + type: TemporaryType.Equation, + data: { latex: data.latex }, + }, + }) + ); + }, + [getSelection, data.latex, dispatch, docId, id, selectedText] + ); + + if (!selectedText) return null; + + return ( + } + > + {children} + + ); +} + +export default FormulaInline; 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 deleted file mode 100644 index 286bc9c60a..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/InlineBlock/InlineContainer.tsx +++ /dev/null @@ -1,143 +0,0 @@ -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'; - -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; - 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 - }, - }) - ); - }, - [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/PageInline.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/InlineBlock/PageInline.tsx new file mode 100644 index 0000000000..1dcd4ac31e --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/InlineBlock/PageInline.tsx @@ -0,0 +1,61 @@ +import React, { useCallback, useEffect, useState } from 'react'; +import { useAppSelector } from '$app/stores/store'; +import { Article } from '@mui/icons-material'; +import { PageController } from '$app/stores/effects/workspace/page/page_controller'; +import { Page } from '$app_reducers/pages/slice'; +import { useTranslation } from 'react-i18next'; +import { useNavigate } from 'react-router-dom'; +import { pageTypeMap } from '$app/constants'; +import { LinearProgress } from '@mui/material'; +import Tooltip from "@mui/material/Tooltip"; + +function PageInline({ pageId }: { pageId: string }) { + const { t } = useTranslation(); + const page = useAppSelector((state) => state.pages.pageMap[pageId]); + const navigate = useNavigate(); + const [currentPage, setCurrentPage] = useState(null); + const loadPage = useCallback(async (id: string) => { + const controller = new PageController(id); + const page = await controller.getPage(); + setCurrentPage(page); + }, []); + + const navigateToPage = useCallback( + (page: Page) => { + const pageType = pageTypeMap[page.layout]; + navigate(`/page/${pageType}/${page.id}`); + }, + [navigate] + ); + + useEffect(() => { + if (page) { + setCurrentPage(page); + return; + } + void loadPage(pageId); + }, [page, loadPage, pageId]); + + return currentPage ? ( + + { + if (!currentPage) return; + + navigateToPage(currentPage); + }} + className={'inline-block cursor-pointer rounded px-1 hover:bg-content-blue-100'} + > + {currentPage.icon?.value ||
} + {currentPage.name || t('menuAppHeader.defaultNewPageName')} + + + + ) : ( + + + + ); +} + +export default PageInline; 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 deleted file mode 100644 index 8106b25450..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/InlineBlock/inline.css +++ /dev/null @@ -1,31 +0,0 @@ -.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/SlateEditor/TextElement.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SlateEditor/TextElement.tsx index 283e11fc74..fbf4e9005a 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SlateEditor/TextElement.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SlateEditor/TextElement.tsx @@ -1,12 +1,8 @@ import { RenderElementProps } from 'slate-react'; -import React, { useEffect, useRef } from 'react'; +import React, { useRef } from 'react'; export function TextElement(props: RenderElementProps) { const ref = useRef(null); - useEffect(() => { - if (!ref.current) return; - amendCodeLeafs(ref.current); - }); return (
); } - -function amendCodeLeafs(textElement: Element) { - const leafNodes = textElement.querySelectorAll(`[data-slate-leaf="true"]`); - let codeLeafNodes: Element[] = []; - leafNodes?.forEach((leafNode, index) => { - const isCodeLeaf = leafNode.classList.contains('inline-code'); - if (isCodeLeaf) { - codeLeafNodes.push(leafNode); - } else { - if (codeLeafNodes.length > 0) { - addStyleToCodeLeafs(codeLeafNodes); - codeLeafNodes = []; - } - } - if (codeLeafNodes.length > 0 && index === leafNodes.length - 1) { - addStyleToCodeLeafs(codeLeafNodes); - codeLeafNodes = []; - } - }); -} - -function addStyleToCodeLeafs(codeLeafs: Element[]) { - if (codeLeafs.length === 0) return; - if (codeLeafs.length === 1) { - const codeNode = codeLeafs[0].firstChild as Element; - codeNode.classList.add('rounded', 'px-1.5'); - return; - } - codeLeafs.forEach((codeLeaf, index) => { - const codeNode = codeLeaf.firstChild as Element; - codeNode.classList.remove('rounded', 'px-1.5'); - codeNode.classList.remove('rounded-l', 'pl-1.5'); - codeNode.classList.remove('rounded-r', 'pr-1.5'); - if (index === 0) { - codeNode.classList.add('rounded-l', 'pl-1.5'); - return; - } - if (index === codeLeafs.length - 1) { - codeNode.classList.add('rounded-r', 'pr-1.5'); - return; - } - }); -} 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 c1bd2ab907..83bd7fe680 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 @@ -3,9 +3,13 @@ import { BaseText } from 'slate'; import { useCallback, useRef } from 'react'; import { converToIndexLength } from '$app/utils/document/slate_editor'; import TemporaryInput from '$app/components/document/_shared/TemporaryInput'; -import InlineContainer from '$app/components/document/_shared/InlineBlock/InlineContainer'; +import FormulaInline from '$app/components/document/_shared/InlineBlock/FormulaInline'; import { TemporaryType } from '$app/interfaces/document'; import LinkInline from '$app/components/document/_shared/InlineBlock/LinkInline'; +import { MentionType } from '$app_reducers/document/async-actions/mention'; +import PageInline from '$app/components/document/_shared/InlineBlock/PageInline'; +import { FakeCursorContainer } from '$app/components/document/_shared/InlineBlock/FakeCursorContainer'; +import CodeInline from '$app/components/document/_shared/InlineBlock/CodeInline'; interface Attributes { bold?: boolean; @@ -20,6 +24,7 @@ interface Attributes { formula?: string; font_color?: string; bg_color?: string; + mention?: Record; } interface TextLeafProps extends RenderLeafProps { leaf: BaseText & Attributes; @@ -30,7 +35,10 @@ interface TextLeafProps extends RenderLeafProps { const TextLeaf = (props: TextLeafProps) => { const { attributes, children, leaf, isCodeBlock, editor } = props; const ref = useRef(null); + const { isLast, text, parent } = children.props; + const isSelected = Boolean(leaf.selection_high_lighted); + const isFirst = text === parent?.children?.[0]; const customAttributes = { ...attributes, }; @@ -38,15 +46,9 @@ const TextLeaf = (props: TextLeafProps) => { if (leaf.code && !leaf.temporary) { newChildren = ( - + {newChildren} - + ); } @@ -79,34 +81,38 @@ const TextLeaf = (props: TextLeafProps) => { ); } - if (leaf.formula) { - const { isLast, text, parent } = children.props; - const temporaryType = TemporaryType.Equation; + if (leaf.formula && leaf.text) { const data = { latex: leaf.formula }; newChildren = ( - + {newChildren} + + ); + } + + const mention = leaf.mention; + if (mention && mention.type === MentionType.PAGE && leaf.text) { + newChildren = ( + } > {newChildren} - + ); } const className = [ isCodeBlock && 'token', leaf.prism_token && leaf.prism_token, - leaf.strikethrough && 'line-through', - leaf.selection_high_lighted && 'bg-content-blue-100', - leaf.code && !leaf.temporary && 'inline-code', + isSelected && 'bg-content-blue-100', leaf.bold && 'font-bold', leaf.italic && 'italic', leaf.underline && 'underline', + leaf.strikethrough && 'line-through', ].filter(Boolean); if (leaf.temporary) { 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 afd0e5bc33..66eb0a6fac 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 @@ -42,21 +42,53 @@ export function useEditor({ const onChangeHandler = useCallback( (slateValue: Descendant[]) => { const oldContents = delta || new Delta(); - - onChange?.(convertToDelta(slateValue), oldContents); + const newContents = convertToDelta(slateValue); + onChange?.(newContents, oldContents); onSelectionChangeHandler(editor.selection); }, [delta, editor, onChange, onSelectionChangeHandler] ); - const onDOMBeforeInput = useCallback((e: InputEvent) => { - // COMPAT: in Apple, `compositionend` is dispatched after the `beforeinput` for "insertFromComposition". - // It will cause repeated characters when inputting Chinese. - // Here, prevent the beforeInput event and wait for the compositionend event to take effect. - if (e.inputType === 'insertFromComposition') { - e.preventDefault(); + // Prevent attributes from being applied when entering text at the beginning or end of an inline block. + // For example, when entering text before or after a mentioned page, + // we expect plain text instead of applying mention attributes. + // Similarly, when entering text before or after inline code, + // we also expect plain text that is not confined within the inline code scope. + const preventInlineBlockAttributeOverride = useCallback(() => { + const marks = editor.getMarks(); + const markKeys = marks + ? Object.keys(marks).filter((mark) => ['mention', 'formula', 'href', 'code'].includes(mark)) + : []; + const currentSelection = editor.selection || []; + let removeMark = markKeys.length > 0; + const [_, path] = editor.node(currentSelection); + if (removeMark) { + const selectionStart = editor.start(currentSelection); + const selectionEnd = editor.end(currentSelection); + const isNodeEnd = editor.isEnd(selectionEnd, path); + const isNodeStart = editor.isStart(selectionStart, path); + removeMark = isNodeStart || isNodeEnd; } - }, []); + + if (removeMark) { + markKeys.forEach((mark) => { + editor.removeMark(mark); + }); + } + }, [editor]); + + const onDOMBeforeInput = useCallback( + (e: InputEvent) => { + // COMPAT: in Apple, `compositionend` is dispatched after the `beforeinput` for "insertFromComposition". + // It will cause repeated characters when inputting Chinese. + // Here, prevent the beforeInput event and wait for the compositionend event to take effect. + if (e.inputType === 'insertFromComposition') { + e.preventDefault(); + } + preventInlineBlockAttributeOverride(); + }, + [preventInlineBlockAttributeOverride] + ); const getDecorateRange = useCallback( ( @@ -162,7 +194,8 @@ export function useEditor({ if (!slateSelection) return; - if (isFocused && JSON.stringify(slateSelection) === JSON.stringify(editor.selection)) return; + const isEqual = JSON.stringify(slateSelection) === JSON.stringify(editor.selection); + if (isFocused && isEqual) return; // why we didn't use slate api to change selection? // because the slate must be focused before change 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 50473c59b2..66267b8176 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 @@ -35,7 +35,6 @@ 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/SubscribeMention.hooks.ts b/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SubscribeMention.hooks.ts new file mode 100644 index 0000000000..17d103a30d --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/SubscribeMention.hooks.ts @@ -0,0 +1,18 @@ +import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks'; +import { useAppSelector } from '$app/stores/store'; +import { MENTION_NAME } from '$app/constants/document/name'; +import { MentionState } from '$app_reducers/document/mention_slice'; + +const initialState: MentionState = { + open: false, + blockId: '', +}; +export function useSubscribeMentionState() { + const { docId } = useSubscribeDocument(); + + const state = useAppSelector((state) => { + return state[MENTION_NAME][docId] || initialState; + }); + + return state; +} 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 index ec53767774..6f27a871bf 100644 --- 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 @@ -17,6 +17,7 @@ function TemporaryPopover() { const anchorPosition = useMemo(() => temporaryState?.popoverPosition, [temporaryState]); const open = Boolean(anchorPosition); const id = temporaryState?.id; + const type = temporaryState?.type; const dispatch = useAppDispatch(); const { docId, controller } = useSubscribeDocument(); diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/ToolbarTooltip/index.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/ToolbarTooltip/index.tsx deleted file mode 100644 index f85d51fb98..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/ToolbarTooltip/index.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import React from 'react'; -import Tooltip from '@mui/material/Tooltip'; - -function ToolbarTooltip({ title, children }: { children: JSX.Element; title?: string }) { - return ( - -
{children}
-
- ); -} - -export default ToolbarTooltip; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/useBindArrowKey.ts b/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/useBindArrowKey.ts index 541f136407..8d2ac27796 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/useBindArrowKey.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/_shared/useBindArrowKey.ts @@ -16,6 +16,7 @@ export const useBindArrowKey = ({ onChange?: (key: string) => void; selectOption?: string | null; }) => { + const [isRun, setIsRun] = useState(false); const onUp = useCallback(() => { const getSelected = () => { const index = options.findIndex((item) => item === selectOption); @@ -68,10 +69,27 @@ export const useBindArrowKey = ({ [onDown, onEnter, onLeft, onRight, onUp] ); + const run = useCallback(() => { + setIsRun(true); + }, []); + + const stop = useCallback(() => { + setIsRun(false); + }, []); + useEffect(() => { - document.addEventListener('keydown', handleArrowKey, true); + if (isRun) { + document.addEventListener('keydown', handleArrowKey, true); + } else { + document.removeEventListener('keydown', handleArrowKey, true); + } return () => { document.removeEventListener('keydown', handleArrowKey, true); }; - }, [handleArrowKey]); + }, [handleArrowKey, isRun]); + + return { + run, + stop, + }; }; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/layout/Breadcrumb/index.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/layout/Breadcrumb/index.tsx index 616854abdf..2eceb2fc14 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/layout/Breadcrumb/index.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/layout/Breadcrumb/index.tsx @@ -6,8 +6,10 @@ import Typography from '@mui/material/Typography'; import { Page } from '$app_reducers/pages/slice'; import { useNavigate } from 'react-router-dom'; import { pageTypeMap } from '$app/constants'; +import { useTranslation } from 'react-i18next'; function Breadcrumb() { + const { t } = useTranslation(); const { pagePath } = useLoadExpandedPages(); const navigate = useNavigate(); const activePage = useMemo(() => pagePath[pagePath.length - 1], [pagePath]); @@ -35,7 +37,7 @@ function Breadcrumb() { {page.name} ))} - {activePage?.name} + {activePage?.name || t('menuAppHeader.defaultNewPageName')} ); } diff --git a/frontend/appflowy_tauri/src/appflowy_app/constants/document/name.ts b/frontend/appflowy_tauri/src/appflowy_app/constants/document/name.ts index a48d9a2f63..9a1a3d149a 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/constants/document/name.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/constants/document/name.ts @@ -3,6 +3,8 @@ export const TEMPORARY_NAME = 'document/temporary'; export const BLOCK_EDIT_NAME = 'document/block_edit'; export const RANGE_NAME = 'document/range'; +export const MENTION_NAME = 'document/mention'; + export const RECT_RANGE_NAME = 'document/rect_range'; export const SLASH_COMMAND_NAME = 'document/slash_command'; export const TEXT_LINK_NAME = 'document/text_link'; diff --git a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/mention.ts b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/mention.ts new file mode 100644 index 0000000000..f735d38b28 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/mention.ts @@ -0,0 +1,90 @@ +import { createAsyncThunk } from '@reduxjs/toolkit'; +import { RootState } from '$app/stores/store'; +import { DOCUMENT_NAME, MENTION_NAME, RANGE_NAME } from '$app/constants/document/name'; +import Delta from 'quill-delta'; +import { getDeltaText } from '$app/utils/document/delta'; +import { mentionActions } from '$app_reducers/document/mention_slice'; +import { DocumentController } from '$app/stores/effects/document/document_controller'; +import { rangeActions } from '$app_reducers/document/slice'; + +export enum MentionType { + PAGE = 'page', +} +export const openMention = createAsyncThunk('document/mention/open', async (payload: { docId: string }, thunkAPI) => { + const { docId } = payload; + const { dispatch, getState } = thunkAPI; + const state = getState() as RootState; + const rangeState = state[RANGE_NAME][docId]; + const { caret } = rangeState; + if (!caret) return; + const { id, index } = caret; + const node = state[DOCUMENT_NAME][docId].nodes[id]; + if (!node.parent) { + return; + } + const nodeDelta = new Delta(node.data?.delta); + + const beforeDelta = nodeDelta.slice(0, index); + const beforeText = getDeltaText(beforeDelta); + let canOpenMention = !beforeText; + if (!canOpenMention) { + if (index === 1) { + canOpenMention = beforeText.endsWith('@'); + } else { + canOpenMention = beforeText.endsWith(' '); + } + } + + if (!canOpenMention) return; + + dispatch( + mentionActions.open({ + docId, + blockId: id, + }) + ); +}); + +export const formatMention = createAsyncThunk( + 'document/mention/format', + async ( + payload: { controller: DocumentController; type: MentionType; value: string; searchTextLength: number }, + thunkAPI + ) => { + const { controller, type, value, searchTextLength } = payload; + const docId = controller.documentId; + const { dispatch, getState } = thunkAPI; + const state = getState() as RootState; + const mentionState = state[MENTION_NAME][docId]; + const { blockId } = mentionState; + const rangeState = state[RANGE_NAME][docId]; + const caret = rangeState.caret; + if (!caret) return; + const index = caret.index - searchTextLength; + + const node = state[DOCUMENT_NAME][docId].nodes[blockId]; + const nodeDelta = new Delta(node.data?.delta); + const diffDelta = new Delta() + .retain(index) + .delete(searchTextLength) + .insert(`@`, { + mention: { + type, + [type]: value, + }, + }); + const newDelta = nodeDelta.compose(diffDelta); + const updateAction = controller.getUpdateAction({ + ...node, + data: { + ...node.data, + delta: newDelta.ops, + }, + }); + + await controller.applyActions([updateAction]); + + dispatch(rangeActions.initialState(docId)); + dispatch(rangeActions.setCaret({ docId, caret: { id: blockId, index, length: 0 } })); + } +); diff --git a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/mention_slice.ts b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/mention_slice.ts new file mode 100644 index 0000000000..8cf38c6b42 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/mention_slice.ts @@ -0,0 +1,36 @@ +import { MENTION_NAME } from '$app/constants/document/name'; +import { createSlice } from '@reduxjs/toolkit'; + +export interface MentionState { + open: boolean; + blockId: string; +} +const initialState: Record = {}; + +export const mentionSlice = createSlice({ + name: MENTION_NAME, + initialState, + reducers: { + open: ( + state, + action: { + payload: { + docId: string; + blockId: string; + }; + } + ) => { + const { docId, blockId } = action.payload; + state[docId] = { + open: true, + blockId, + }; + }, + close: (state, action: { payload: { docId: string } }) => { + const { docId } = action.payload; + delete state[docId]; + }, + }, +}); + +export const mentionActions = mentionSlice.actions; 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 6f14155929..fcc4d04b0c 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 @@ -14,6 +14,7 @@ import { temporarySlice } from '$app_reducers/document/temporary_slice'; import { DOCUMENT_NAME, RANGE_NAME, RECT_RANGE_NAME, SLASH_COMMAND_NAME } from '$app/constants/document/name'; import { blockEditSlice } from '$app_reducers/document/block_edit_slice'; import { Op } from 'quill-delta'; +import { mentionSlice } from '$app_reducers/document/mention_slice'; const initialState: Record = {}; @@ -386,6 +387,7 @@ export const documentReducers = { [slashCommandSlice.name]: slashCommandSlice.reducer, [temporarySlice.name]: temporarySlice.reducer, [blockEditSlice.name]: blockEditSlice.reducer, + [mentionSlice.name]: mentionSlice.reducer, }; export const documentActions = documentSlice.actions; 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 619dcf06f0..2f8cfac126 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 @@ -107,7 +107,6 @@ 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, diff --git a/frontend/appflowy_tauri/src/styles/mui.css b/frontend/appflowy_tauri/src/styles/mui.css index 57e6f6bc1c..6d81bb64c4 100644 --- a/frontend/appflowy_tauri/src/styles/mui.css +++ b/frontend/appflowy_tauri/src/styles/mui.css @@ -53,6 +53,10 @@ font-weight: 400 !important; } +.MuiTooltip-arrow { + color: var(--bg-tips) !important; +} + .MuiInput-input[class$='-MuiSelect-select-MuiInputBase-input-MuiInput-input']:focus { background: transparent; } @@ -65,4 +69,4 @@ .MuiDivider-root.MuiDivider-fullWidth { border-color: var(--line-divider); -} \ No newline at end of file +} diff --git a/frontend/appflowy_tauri/src/styles/variables/index.css b/frontend/appflowy_tauri/src/styles/variables/index.css index 72aec58eb2..08d6a948f1 100644 --- a/frontend/appflowy_tauri/src/styles/variables/index.css +++ b/frontend/appflowy_tauri/src/styles/variables/index.css @@ -1,2 +1,7 @@ @import "./light.variables.css"; -@import "./dark.variables.css"; \ No newline at end of file +@import "./dark.variables.css"; + +:root { + /* resize popover shadow */ + --shadow-resize-popover: 0px 5px 5px -3px rgba(0,0,0,0.2),0px 8px 10px 1px rgba(0,0,0,0.14),0px 3px 14px 2px rgba(0,0,0,0.12); +} \ No newline at end of file diff --git a/frontend/resources/translations/en.json b/frontend/resources/translations/en.json index 7b8772564c..605898f944 100644 --- a/frontend/resources/translations/en.json +++ b/frontend/resources/translations/en.json @@ -580,6 +580,13 @@ "label": "Link Title", "placeholder": "Enter link title" } + }, + "mention": { + "placeholder": "Mention a person or a page or date...", + "page": { + "label": "Link to page", + "tooltip": "Click to open page" + } } }, "board": {