mirror of
https://github.com/AppFlowy-IO/AppFlowy.git
synced 2024-08-30 18:12:39 +00:00
feat: support mention a page (#3117)
This commit is contained in:
parent
125bcbc9db
commit
923285bfcf
@ -43,7 +43,7 @@ function ChangeCoverPopover({
|
||||
<div
|
||||
style={{
|
||||
boxShadow:
|
||||
'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)',
|
||||
"var(--shadow-resize-popover)",
|
||||
}}
|
||||
className={'flex flex-col rounded-md bg-bg-body p-4 '}
|
||||
ref={ref}
|
||||
|
@ -4,9 +4,9 @@ import { useSubscribeDocument } from '$app/components/document/_shared/Subscribe
|
||||
import { Align } from '$app/interfaces/document';
|
||||
import { FormatAlignCenter, FormatAlignLeft, FormatAlignRight } from '@mui/icons-material';
|
||||
import { updateNodeDataThunk } from '$app_reducers/document/async-actions';
|
||||
import ToolbarTooltip from '$app/components/document/_shared/ToolbarTooltip';
|
||||
import Popover from '@mui/material/Popover';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Tooltip } from '@mui/material';
|
||||
|
||||
function ImageAlign({
|
||||
id,
|
||||
@ -63,7 +63,7 @@ function ImageAlign({
|
||||
|
||||
return (
|
||||
<>
|
||||
<ToolbarTooltip title={t('document.plugins.optionAction.align')}>
|
||||
<Tooltip disableInteractive placement={'top'} title={t('document.plugins.optionAction.align')}>
|
||||
<div
|
||||
ref={ref}
|
||||
className='flex items-center justify-center p-1'
|
||||
@ -73,7 +73,7 @@ function ImageAlign({
|
||||
>
|
||||
{renderAlign(align)}
|
||||
</div>
|
||||
</ToolbarTooltip>
|
||||
</Tooltip>
|
||||
<Popover
|
||||
open={popoverOpen}
|
||||
anchorOrigin={{
|
||||
|
@ -1,12 +1,12 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Align } from '$app/interfaces/document';
|
||||
import ImageAlign from '$app/components/document/ImageBlock/ImageAlign';
|
||||
import ToolbarTooltip from '$app/components/document/_shared/ToolbarTooltip';
|
||||
import { DeleteOutline } from '@mui/icons-material';
|
||||
import { useAppDispatch } from '$app/stores/store';
|
||||
import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks';
|
||||
import { deleteNodeThunk } from '$app_reducers/document/async-actions';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import Tooltip from '@mui/material/Tooltip';
|
||||
|
||||
function ImageToolbar({ id, open, align }: { id: string; open: boolean; align: Align }) {
|
||||
const [popoverOpen, setPopoverOpen] = useState(false);
|
||||
@ -24,7 +24,7 @@ function ImageToolbar({ id, open, align }: { id: string; open: boolean; align: A
|
||||
} absolute right-2 top-2 z-[1px] flex h-[26px] max-w-[calc(100%-16px)] cursor-pointer items-center justify-center whitespace-nowrap rounded bg-bg-body text-sm text-text-title transition-opacity`}
|
||||
>
|
||||
<ImageAlign id={id} align={align} onOpen={() => setPopoverOpen(true)} onClose={() => setPopoverOpen(false)} />
|
||||
<ToolbarTooltip title={t('button.delete')}>
|
||||
<Tooltip disableInteractive placement={'top'} title={t('button.delete')}>
|
||||
<div
|
||||
onClick={() => {
|
||||
dispatch(deleteNodeThunk({ id, controller }));
|
||||
@ -33,7 +33,7 @@ function ImageToolbar({ id, open, align }: { id: string; open: boolean; align: A
|
||||
>
|
||||
<DeleteOutline />
|
||||
</div>
|
||||
</ToolbarTooltip>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
@ -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<string>('');
|
||||
const beforeOpenDeltaRef = useRef<Op[]>([]);
|
||||
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<Page[]>([]);
|
||||
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,
|
||||
};
|
||||
}
|
@ -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 (
|
||||
<Popover
|
||||
onClose={onClose}
|
||||
open={popoverOpen}
|
||||
disableAutoFocus
|
||||
disableRestoreFocus={true}
|
||||
anchorReference={'anchorPosition'}
|
||||
anchorPosition={anchorPosition}
|
||||
transformOrigin={{ vertical: 'top', horizontal: 'left' }}
|
||||
PaperProps={{
|
||||
sx: {
|
||||
height: 'auto',
|
||||
overflow: 'visible',
|
||||
},
|
||||
elevation: 0,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
boxShadow:
|
||||
"var(--shadow-resize-popover)",
|
||||
}}
|
||||
className={'flex w-[420px] flex-col rounded-md bg-bg-body px-4 py-2'}
|
||||
>
|
||||
<RecentPages onSelect={onSelectPage} searchText={searchText} />
|
||||
</div>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
export default MentionPopover;
|
@ -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<string | null>(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 (
|
||||
<List>
|
||||
<div className={'p-2 text-text-caption'}>{t('document.mention.page.label')}</div>
|
||||
{recentPages.map((page) => (
|
||||
<MenuItem
|
||||
style={{
|
||||
margin: 0,
|
||||
padding: '0.5rem',
|
||||
}}
|
||||
onMouseEnter={() => {
|
||||
setSelectOption(page.id);
|
||||
}}
|
||||
selected={selectOption === page.id}
|
||||
key={page.id}
|
||||
onClick={() => {
|
||||
onSelect(page.id);
|
||||
}}
|
||||
>
|
||||
<div className={'flex items-center'}>
|
||||
<div className={'mr-2'}>{page.icon?.value || <Article />}</div>
|
||||
<div>{page.name || t('menuAppHeader.defaultNewPageName')}</div>
|
||||
</div>
|
||||
</MenuItem>
|
||||
))}
|
||||
</List>
|
||||
);
|
||||
}
|
||||
|
||||
export default RecentPages;
|
@ -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 }) {
|
||||
<BlockSelection container={container} />
|
||||
<BlockSlash container={container} />
|
||||
<TemporaryPopover />
|
||||
<MentionPopover />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
@ -20,7 +20,7 @@ function Root({ documentData }: { documentData: DocumentData }) {
|
||||
|
||||
return (
|
||||
<>
|
||||
<div id='appflowy-block-doc' className='h-[100%] overflow-hidden text-text-title caret-text-title'>
|
||||
<div id='appflowy-block-doc' className='h-[100%] overflow-hidden text-base text-text-title caret-text-title'>
|
||||
<VirtualizedList node={node} childIds={childIds} renderNode={renderNode} />
|
||||
</div>
|
||||
</>
|
||||
|
@ -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 (
|
||||
<>
|
||||
<div
|
||||
|
@ -1,6 +1,5 @@
|
||||
import React, { useCallback, useEffect, useMemo } from 'react';
|
||||
import { TemporaryType, TextAction } from '$app/interfaces/document';
|
||||
import ToolbarTooltip from '$app/components/document/_shared/ToolbarTooltip';
|
||||
import { getFormatActiveThunk, toggleFormatThunk } from '$app_reducers/document/async-actions/format';
|
||||
import { useAppDispatch, useAppSelector } from '$app/stores/store';
|
||||
import { useSubscribeNode } from '$app/components/document/_shared/SubscribeNode.hooks';
|
||||
@ -17,6 +16,7 @@ import {
|
||||
} from '@mui/icons-material';
|
||||
import LinkIcon from '@mui/icons-material/AddLink';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import Tooltip from '@mui/material/Tooltip';
|
||||
|
||||
export const iconSize = { width: 18, height: 18 };
|
||||
|
||||
@ -130,11 +130,11 @@ const FormatButton = ({ format, icon }: { format: TextAction; icon: string }) =>
|
||||
}, [icon]);
|
||||
|
||||
return (
|
||||
<ToolbarTooltip title={formatTooltips[format]}>
|
||||
<Tooltip disableInteractive placement={'top'} title={formatTooltips[format]}>
|
||||
<div className={`${color} cursor-pointer px-1 hover:text-fill-default`} onClick={() => formatClick(format)}>
|
||||
{formatIcon}
|
||||
</div>
|
||||
</ToolbarTooltip>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
||||
|
@ -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 (
|
||||
<>
|
||||
<ToolbarTooltip title={t('document.plugins.optionAction.turnInto')}>
|
||||
<Tooltip disableInteractive placement={'top'} title={t('document.plugins.optionAction.turnInto')}>
|
||||
<div onClick={handleClick} className='flex cursor-pointer items-center px-2 text-sm text-fill-default'>
|
||||
<span>{node.type}</span>
|
||||
<ArrowDropDown />
|
||||
</div>
|
||||
</ToolbarTooltip>
|
||||
</Tooltip>
|
||||
<TurnIntoPopover
|
||||
id={id}
|
||||
open={open}
|
||||
|
@ -10,9 +10,10 @@ import {
|
||||
import { useTurnIntoBlockEvents } from './useTurnIntoBlockEvents';
|
||||
import { useCommonKeyEvents } from '../_shared/EditorHooks/useCommonKeyEvents';
|
||||
import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks';
|
||||
import { openMention } from '$app_reducers/document/async-actions/mention';
|
||||
|
||||
export function useKeyDown(id: string) {
|
||||
const { controller } = useSubscribeDocument();
|
||||
const { controller, docId } = useSubscribeDocument();
|
||||
const dispatch = useAppDispatch();
|
||||
const turnIntoEvents = useTurnIntoBlockEvents(id);
|
||||
const commonKeyEvents = useCommonKeyEvents(id);
|
||||
@ -82,9 +83,18 @@ export function useKeyDown(id: string) {
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
// handle @ key for mention panel
|
||||
canHandle: (e: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
return e.key === '@';
|
||||
},
|
||||
handler: (e: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
dispatch(openMention({ docId }));
|
||||
},
|
||||
},
|
||||
...turnIntoEvents,
|
||||
];
|
||||
}, [commonKeyEvents, controller, dispatch, id, turnIntoEvents]);
|
||||
}, [docId, commonKeyEvents, controller, dispatch, id, turnIntoEvents]);
|
||||
|
||||
const onKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
|
@ -0,0 +1,18 @@
|
||||
import React from 'react';
|
||||
|
||||
function CodeInline({ text, children, selected }: { text: string; children: React.ReactNode; selected: boolean }) {
|
||||
return (
|
||||
<span
|
||||
className={'bg-content-blue-50 py-1'}
|
||||
style={{
|
||||
fontSize: '85%',
|
||||
lineHeight: 'normal',
|
||||
backgroundColor: selected ? 'var(--content-blue-100)' : undefined,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export default CodeInline;
|
@ -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<HTMLSpanElement>(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 (
|
||||
<span className={'relative inline-block px-1'} ref={ref} onClick={() => ref.current && onClick?.(ref.current)}>
|
||||
<span
|
||||
style={{
|
||||
pointerEvents: 'none',
|
||||
left: position === 'left' ? '-1px' : undefined,
|
||||
right: position === 'right' ? '-1px' : undefined,
|
||||
caretColor: position === undefined ? 'transparent' : undefined,
|
||||
}}
|
||||
className={`absolute text-transparent`}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
<span data-slate-placeholder={true} contentEditable={false} className={'inline-block-content'}>
|
||||
{renderNode()}
|
||||
</span>
|
||||
{isLast && <span data-slate-string={false}></span>}
|
||||
</span>
|
||||
);
|
||||
};
|
@ -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 (
|
||||
<FakeCursorContainer
|
||||
onClick={onClick}
|
||||
getSelection={getSelection}
|
||||
isFirst={isFirst}
|
||||
isLast={isLast}
|
||||
renderNode={() => <KatexMath latex={data.latex!} isInline />}
|
||||
>
|
||||
{children}
|
||||
</FakeCursorContainer>
|
||||
);
|
||||
}
|
||||
|
||||
export default FormulaInline;
|
@ -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<HTMLSpanElement>(null);
|
||||
const dispatch = useAppDispatch();
|
||||
const onClick = useCallback(
|
||||
(node: HTMLSpanElement) => {
|
||||
const selection = getSelection(node);
|
||||
|
||||
if (!selection) return;
|
||||
const temporaryData = temporaryType === TemporaryType.Equation ? { latex: data.latex } : {};
|
||||
|
||||
dispatch(
|
||||
createTemporary({
|
||||
docId,
|
||||
state: {
|
||||
id,
|
||||
selection,
|
||||
selectedText,
|
||||
type: temporaryType,
|
||||
data: temporaryData
|
||||
},
|
||||
})
|
||||
);
|
||||
},
|
||||
[getSelection, temporaryType, data.latex, dispatch, docId, id, selectedText]
|
||||
);
|
||||
|
||||
const renderNode = useCallback(() => {
|
||||
switch (temporaryType) {
|
||||
case TemporaryType.Equation:
|
||||
return <KatexMath latex={data.latex!} isInline />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}, [data, temporaryType]);
|
||||
|
||||
const resetCaret = useCallback(() => {
|
||||
if (!ref.current) return;
|
||||
ref.current.classList.remove(RIGHT_CARET_CLASS);
|
||||
ref.current.classList.remove(LEFT_CARET_CLASS);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
resetCaret();
|
||||
if (!ref.current) return;
|
||||
if (!focused || !focusCaret || rangeRef.current?.isDragging) {
|
||||
return;
|
||||
}
|
||||
|
||||
const inlineBlockSelection = getSelection(ref.current);
|
||||
|
||||
if (!inlineBlockSelection) return;
|
||||
const distance = inlineBlockSelection.index - focusCaret.index;
|
||||
|
||||
if (distance === 0 && isFirst) {
|
||||
ref.current.classList.add(LEFT_CARET_CLASS);
|
||||
return;
|
||||
}
|
||||
|
||||
if (distance === -1) {
|
||||
ref.current.classList.add(RIGHT_CARET_CLASS);
|
||||
return;
|
||||
}
|
||||
}, [focused, focusCaret, getSelection, resetCaret, isFirst, rangeRef]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!ref.current) return;
|
||||
const onMouseDown = (e: MouseEvent) => {
|
||||
if (e.target === ref.current) {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
}
|
||||
};
|
||||
|
||||
// prevent page scroll when the caret change by mouse down
|
||||
document.addEventListener('mousedown', onMouseDown, true);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', onMouseDown, true);
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (!selectedText) return null;
|
||||
|
||||
return (
|
||||
<span className={'inline-block-with-cursor relative'} ref={ref} onClick={() => onClick(ref.current!)}>
|
||||
<span
|
||||
style={{
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
className={`absolute caret-transparent opacity-0`}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
<span
|
||||
data-slate-placeholder={true}
|
||||
contentEditable={false}
|
||||
style={{
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
className={'inline-block-content'}
|
||||
>
|
||||
{renderNode()}
|
||||
</span>
|
||||
{isLast && <span data-slate-string={false}></span>}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export default InlineContainer;
|
@ -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<Page | null>(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 ? (
|
||||
<Tooltip arrow title={t('document.mention.page.tooltip')} placement={'top'}>
|
||||
<span
|
||||
onClick={() => {
|
||||
if (!currentPage) return;
|
||||
|
||||
navigateToPage(currentPage);
|
||||
}}
|
||||
className={'inline-block cursor-pointer rounded px-1 hover:bg-content-blue-100'}
|
||||
>
|
||||
<span className={'mr-1'}>{currentPage.icon?.value || <Article />}</span>
|
||||
<span className={'font-medium underline '}>{currentPage.name || t('menuAppHeader.defaultNewPageName')}</span>
|
||||
</span>
|
||||
</Tooltip>
|
||||
|
||||
) : (
|
||||
<span>
|
||||
<LinearProgress />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export default PageInline;
|
@ -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; }
|
||||
}
|
@ -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<HTMLDivElement | null>(null);
|
||||
useEffect(() => {
|
||||
if (!ref.current) return;
|
||||
amendCodeLeafs(ref.current);
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
@ -20,46 +16,3 @@ export function TextElement(props: RenderElementProps) {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
@ -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<string, string>;
|
||||
}
|
||||
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<HTMLSpanElement>(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 = (
|
||||
<span
|
||||
className={`bg-content-blue-50 text-text-title`}
|
||||
style={{
|
||||
fontSize: '85%',
|
||||
lineHeight: 'normal',
|
||||
}}
|
||||
>
|
||||
<CodeInline selected={isSelected} text={text}>
|
||||
{newChildren}
|
||||
</span>
|
||||
</CodeInline>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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 = (
|
||||
<InlineContainer
|
||||
isLast={isLast}
|
||||
isFirst={text === parent.children[0]}
|
||||
<FormulaInline isLast={isLast} isFirst={isFirst} getSelection={getSelection} data={data} selectedText={leaf.text}>
|
||||
{newChildren}
|
||||
</FormulaInline>
|
||||
);
|
||||
}
|
||||
|
||||
const mention = leaf.mention;
|
||||
if (mention && mention.type === MentionType.PAGE && leaf.text) {
|
||||
newChildren = (
|
||||
<FakeCursorContainer
|
||||
getSelection={getSelection}
|
||||
data={data}
|
||||
temporaryType={temporaryType}
|
||||
selectedText={leaf.text}
|
||||
isFirst={isFirst}
|
||||
isLast={isLast}
|
||||
renderNode={() => <PageInline pageId={mention.page} />}
|
||||
>
|
||||
{newChildren}
|
||||
</InlineContainer>
|
||||
</FakeCursorContainer>
|
||||
);
|
||||
}
|
||||
|
||||
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) {
|
||||
|
@ -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,
|
||||
|
@ -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]);
|
||||
|
@ -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;
|
||||
}
|
@ -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();
|
||||
|
||||
|
@ -1,17 +0,0 @@
|
||||
import React from 'react';
|
||||
import Tooltip from '@mui/material/Tooltip';
|
||||
|
||||
function ToolbarTooltip({ title, children }: { children: JSX.Element; title?: string }) {
|
||||
return (
|
||||
<Tooltip
|
||||
disableInteractive
|
||||
slotProps={{ tooltip: { style: { background: 'var(--bg-tips)', borderRadius: 8 } } }}
|
||||
title={title}
|
||||
placement='top-start'
|
||||
>
|
||||
<div>{children}</div>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
export default ToolbarTooltip;
|
@ -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,
|
||||
};
|
||||
};
|
||||
|
@ -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}
|
||||
</Link>
|
||||
))}
|
||||
<Typography color='text.primary'>{activePage?.name}</Typography>
|
||||
<Typography color='text.primary'>{activePage?.name || t('menuAppHeader.defaultNewPageName')}</Typography>
|
||||
</Breadcrumbs>
|
||||
);
|
||||
}
|
||||
|
@ -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';
|
||||
|
@ -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 } }));
|
||||
}
|
||||
);
|
@ -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<string, MentionState> = {};
|
||||
|
||||
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;
|
@ -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<string, DocumentState> = {};
|
||||
|
||||
@ -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;
|
||||
|
@ -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,
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
@ -1,2 +1,7 @@
|
||||
@import "./light.variables.css";
|
||||
@import "./dark.variables.css";
|
||||
@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);
|
||||
}
|
@ -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": {
|
||||
|
Loading…
Reference in New Issue
Block a user