+
>
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 (
<>
-
+
-
+
) => {
+ 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": {