feat: support mention a page (#3117)

This commit is contained in:
Kilu.He 2023-08-08 10:07:59 +08:00 committed by GitHub
parent 125bcbc9db
commit 923285bfcf
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
35 changed files with 767 additions and 295 deletions

View File

@ -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}

View File

@ -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={{

View File

@ -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>
</>
);

View File

@ -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,
};
}

View File

@ -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;

View File

@ -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;

View File

@ -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 />
</>
);
}

View File

@ -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>
</>

View File

@ -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

View File

@ -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>
);
};

View File

@ -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}

View File

@ -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>) => {

View File

@ -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;

View File

@ -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}>&#xFEFF;</span>}
</span>
);
};

View File

@ -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;

View File

@ -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}>&#xFEFF;</span>}
</span>
);
}
export default InlineContainer;

View File

@ -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;

View File

@ -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; }
}

View File

@ -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;
}
});
}

View File

@ -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) {

View File

@ -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,

View File

@ -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]);

View File

@ -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;
}

View File

@ -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();

View File

@ -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;

View File

@ -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,
};
};

View File

@ -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>
);
}

View File

@ -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';

View File

@ -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 } }));
}
);

View File

@ -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;

View File

@ -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;

View File

@ -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,

View File

@ -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);
}
}

View File

@ -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);
}

View File

@ -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": {