Files
AFFiNE/libs/components/editor-plugins/src/menu/double-link-menu/DoubleLinkMenu.tsx
2022-08-23 10:37:14 +08:00

394 lines
13 KiB
TypeScript

import { CommonListItem } from '@toeverything/components/common';
import { AddIcon } from '@toeverything/components/icons';
import {
Input,
ListButton,
MuiClickAwayListener,
MuiGrow as Grow,
MuiPaper as Paper,
MuiPopper as Popper,
styled,
} from '@toeverything/components/ui';
import { services } from '@toeverything/datasource/db-service';
import { HookType, PluginHooks, Virgo } from '@toeverything/framework/virgo';
import React, {
ChangeEvent,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import { useParams } from 'react-router-dom';
import { QueryBlocks, QueryResult } from '../../search';
import { DoubleLinkMenuContainer } from './Container';
const ADD_NEW_SUB_PAGE = 'AddNewSubPage';
const ADD_NEW_PAGE = 'AddNewPage';
const ARRAY_CODES = ['ArrowRight', 'ArrowLeft', 'ArrowUp', 'ArrowDown'];
export type DoubleLinkMenuProps = {
editor: Virgo;
hooks: PluginHooks;
style?: { left: number; top: number };
};
type DoubleLinkMenuStyle = {
left: number;
top: number;
height: number;
};
export const DoubleLinkMenu = ({
editor,
hooks,
style,
}: DoubleLinkMenuProps) => {
const { page_id: curPageId } = useParams();
const [isOpen, setIsOpen] = useState(false);
const [anchorEl, setAnchorEl] = useState(null);
const dialogRef = useRef<HTMLDivElement>();
const newPageSearchRef = useRef<HTMLInputElement>();
const [doubleLinkMenuStyle, setDoubleLinkMenuStyle] =
useState<DoubleLinkMenuStyle>({
left: 0,
top: 0,
height: 0,
});
const [curBlockId, setCurBlockId] = useState<string>();
const [searchText, setSearchText] = useState<string>();
const [inAddNewPage, setInAddNewPage] = useState(false);
const [filterText, setFilterText] = useState<string>('');
const [searchResultBlocks, setSearchResultBlocks] = useState<QueryResult>(
[]
);
const menuTypes = useMemo(() => {
return Object.values(searchResultBlocks)
.map(({ id }) => id)
.concat([ADD_NEW_SUB_PAGE, ADD_NEW_PAGE]);
}, [searchResultBlocks]);
const menuItems: CommonListItem[] = useMemo(() => {
const items: CommonListItem[] = [];
if (searchResultBlocks?.length > 0) {
items.push({
renderCustom: () => {
return (
<ListButton
content={
inAddNewPage ? 'SUGGESTED' : 'LINK TO PAGE'
}
/>
);
},
});
items.push(
...(searchResultBlocks?.map(
block =>
({
block: {
...block,
content: block.content || 'Untitled',
},
} as CommonListItem)
) || [])
);
}
if (items.length > 0) {
items.push({ divider: 'newPage' });
}
items.push({
content: {
id: ADD_NEW_SUB_PAGE,
content: 'Add new sub-page',
icon: AddIcon,
},
});
!inAddNewPage &&
items.push({
content: {
id: ADD_NEW_PAGE,
content: 'Add new page in...',
icon: AddIcon,
},
});
return items;
}, [searchResultBlocks, inAddNewPage]);
useEffect(() => {
const text = inAddNewPage ? filterText : searchText;
QueryBlocks(editor, text, result => {
if (!inAddNewPage) {
result = result.filter(item => item.id !== curPageId);
}
setSearchResultBlocks(result);
});
}, [editor, searchText, filterText, inAddNewPage, curPageId]);
const hideMenu = useCallback(() => {
setIsOpen(false);
setInAddNewPage(false);
editor.blockHelper.removeDoubleLinkSearchSlash(curBlockId);
editor.scrollManager.unLock();
}, [curBlockId, editor]);
const resetState = useCallback(
(preNodeId: string, nextNodeId: string) => {
preNodeId &&
editor.blockHelper.removeDoubleLinkSearchSlash(preNodeId);
setCurBlockId(nextNodeId);
setSearchText('');
setIsOpen(true);
editor.scrollManager.lock();
const clientRect =
editor.selection.currentSelectInfo?.browserSelection
?.getRangeAt(0)
?.getBoundingClientRect();
if (clientRect) {
const rectTop = clientRect.top;
const { top, left } = editor.container.getBoundingClientRect();
setDoubleLinkMenuStyle({
top: rectTop - top,
left: clientRect.left - left,
height: clientRect.height,
});
setAnchorEl(dialogRef.current);
}
const textSelection = editor.blockHelper.selectionToSlateRange(
nextNodeId,
editor.selection.currentSelectInfo?.browserSelection
);
if (textSelection) {
const { anchor } = textSelection;
editor.blockHelper.setDoubleLinkSearchSlash(nextNodeId, anchor);
}
},
[editor]
);
const searchChange = useCallback(
async (event: React.KeyboardEvent<HTMLDivElement>) => {
if (ARRAY_CODES.includes(event.code)) {
return;
}
if (event.code === 'Backspace') {
const searchText =
editor.blockHelper.getDoubleLinkSearchSlashText(curBlockId);
if (!searchText || searchText === '[[') {
hideMenu();
event.preventDefault();
event.stopPropagation();
return;
}
}
const { type, anchorNode } = editor.selection.currentSelectInfo;
if (
!isOpen ||
(type === 'Range' &&
anchorNode &&
anchorNode.id !== curBlockId &&
editor.blockHelper.isSelectionCollapsed(anchorNode.id))
) {
const text = editor.blockHelper.getBlockTextBeforeSelection(
anchorNode.id
);
if (text.endsWith('[[')) {
resetState(curBlockId, anchorNode.id);
}
}
if (isOpen) {
const searchText =
editor.blockHelper.getDoubleLinkSearchSlashText(curBlockId);
if (searchText && searchText.startsWith('[[')) {
setSearchText(searchText.slice(2).trim());
}
}
},
[editor, isOpen, curBlockId, hideMenu, resetState]
);
const handleKeyup = useCallback(
(event: React.KeyboardEvent<HTMLDivElement>) => searchChange(event),
[searchChange]
);
const handleKeyDown = useCallback(
(event: React.KeyboardEvent<HTMLDivElement>) => {
if (!isOpen) {
return;
}
if (event.code === 'Escape') {
hideMenu();
}
if (event.code === 'Backspace') {
const searchText =
editor.blockHelper.getDoubleLinkSearchSlashText(curBlockId);
if (!searchText || searchText === '[[') {
event.preventDefault();
event.stopPropagation();
return;
}
}
},
[hideMenu, editor, isOpen, curBlockId]
);
useEffect(() => {
const sub = hooks
.get(HookType.ON_ROOT_NODE_KEYUP)
.subscribe(handleKeyup);
sub.add(
hooks
.get(HookType.ON_ROOT_NODE_KEYDOWN_CAPTURE)
.subscribe(handleKeyDown)
);
return () => {
sub.unsubscribe();
};
}, [handleKeyup, handleKeyDown, hooks]);
useEffect(() => {
const showDoubleLink = () => {
const { anchorNode } = editor.selection.currentSelectInfo;
editor.blockHelper.insertNodes(anchorNode.id, [{ text: '[[' }], {
select: true,
});
setTimeout(() => {
resetState('', anchorNode.id);
}, 0);
};
editor.plugins.observe('showDoubleLink', showDoubleLink);
return () => editor.plugins.unobserve('showDoubleLink', showDoubleLink);
}, [editor, resetState]);
const insertDoubleLink = useCallback(
async (pageId: string) => {
editor.blockHelper.setSelectDoubleLinkSearchSlash(curBlockId);
await editor.blockHelper.insertDoubleLink(
editor.workspace,
pageId,
curBlockId
);
hideMenu();
},
[editor, curBlockId, hideMenu]
);
const addSubPage = useCallback(
async (parentPageId: string) => {
const newPage = await services.api.editorBlock.create({
workspace: editor.workspace,
type: 'page' as const,
});
services.api.editorBlock.update({
id: newPage.id,
workspace: editor.workspace,
properties: {
text: { value: [{ text: searchText }] },
},
});
await services.api.pageTree.addChildPageToWorkspace(
editor.workspace,
parentPageId,
newPage.id
);
return newPage.id;
},
[searchText, editor]
);
const handleSelected = async (id: string) => {
if (curBlockId) {
if (id === ADD_NEW_PAGE) {
setInAddNewPage(true);
setTimeout(() => {
newPageSearchRef.current?.focus();
});
return;
}
if (id === ADD_NEW_SUB_PAGE) {
const pageId = await addSubPage(curPageId);
insertDoubleLink(pageId);
return;
}
if (inAddNewPage) {
const pageId = await addSubPage(id);
insertDoubleLink(pageId);
} else {
insertDoubleLink(id);
}
}
};
const handleFilterChange = useCallback(
async (e: ChangeEvent<HTMLInputElement>) => {
const text = e.target.value;
await setFilterText(text);
},
[]
);
return (
<div
ref={dialogRef}
style={{
position: 'absolute',
width: '10px',
...doubleLinkMenuStyle,
}}
>
{isOpen && (
<MuiClickAwayListener onClickAway={() => hideMenu()}>
<Popper
open={isOpen}
anchorEl={anchorEl}
transition
placement="bottom-start"
>
{({ TransitionProps }) => (
<Grow
{...TransitionProps}
style={{
transformOrigin: 'left bottom',
}}
>
<Paper>
{inAddNewPage && (
<NewPageSearchContainer>
<Input
ref={newPageSearchRef}
value={filterText}
onChange={handleFilterChange}
placeholder="Search page to add to..."
/>
</NewPageSearchContainer>
)}
<DoubleLinkMenuContainer
editor={editor}
hooks={hooks}
style={style}
blockId={curBlockId}
onSelected={handleSelected}
onClose={hideMenu}
items={menuItems}
types={menuTypes}
/>
</Paper>
</Grow>
)}
</Popper>
</MuiClickAwayListener>
)}
</div>
);
};
const NewPageSearchContainer = styled('div')({
padding: '8px 8px 0px 8px',
});