feat: Intra-line double link interaction

This commit is contained in:
xiaodong zuo
2022-08-19 05:23:25 +08:00
parent 919e0d08d5
commit a58825dd2f
3 changed files with 195 additions and 197 deletions
@@ -57,7 +57,9 @@ export const CommonList = (props: MenuItemsProps) => {
> >
{usedItems?.length ? ( {usedItems?.length ? (
usedItems.map((item, idx) => { usedItems.map((item, idx) => {
if (item.block) { if (item.renderCustom) {
return item.renderCustom(item);
} else if (item.block) {
return ( return (
<BlockPreview <BlockPreview
className={clsx( className={clsx(
@@ -1,21 +1,17 @@
import React, { useEffect, useState, useCallback, useRef } from 'react';
import { Virgo, PluginHooks, HookType } from '@toeverything/framework/virgo';
import { import {
CommonList, CommonList,
CommonListItem,
commonListContainer, commonListContainer,
CommonListItem,
} from '@toeverything/components/common'; } from '@toeverything/components/common';
import { domToRect } from '@toeverything/utils';
import { styled } from '@toeverything/components/ui'; import { styled } from '@toeverything/components/ui';
import { HookType, PluginHooks, Virgo } from '@toeverything/framework/virgo';
import { QueryResult } from '../../search'; import { domToRect } from '@toeverything/utils';
import React, { useCallback, useEffect, useRef, useState } from 'react';
export type DoubleLinkMenuContainerProps = { export type DoubleLinkMenuContainerProps = {
editor: Virgo; editor: Virgo;
hooks: PluginHooks; hooks: PluginHooks;
style?: React.CSSProperties; style?: React.CSSProperties;
isShow?: boolean;
blockId: string; blockId: string;
onSelected?: (item: string) => void; onSelected?: (item: string) => void;
onClose?: () => void; onClose?: () => void;
@@ -25,7 +21,6 @@ export type DoubleLinkMenuContainerProps = {
export const DoubleLinkMenuContainer = ({ export const DoubleLinkMenuContainer = ({
hooks, hooks,
isShow = false,
onSelected, onSelected,
onClose, onClose,
types, types,
@@ -66,12 +61,13 @@ export const DoubleLinkMenuContainer = ({
}, [needCheckIntoView, currentItem]); }, [needCheckIntoView, currentItem]);
useEffect(() => { useEffect(() => {
if (isShow && types && !currentItem) setCurrentItem(types[0]); if (types && !currentItem) {
if (!isShow) onClose?.(); setCurrentItem(types[0]);
}, [currentItem, isShow, onClose, types]); }
}, [currentItem, onClose, types]);
useEffect(() => { useEffect(() => {
if (isShow && types) { if (types) {
if (!types.includes(currentItem)) { if (!types.includes(currentItem)) {
setNeedCheckIntoView(true); setNeedCheckIntoView(true);
if (types.length) { if (types.length) {
@@ -81,11 +77,11 @@ export const DoubleLinkMenuContainer = ({
} }
} }
} }
}, [isShow, types, currentItem]); }, [types, currentItem]);
const handleClickUp = useCallback( const handleClickUp = useCallback(
(event: React.KeyboardEvent<HTMLDivElement>) => { (event: React.KeyboardEvent<HTMLDivElement>) => {
if (isShow && types && event.code === 'ArrowUp') { if (types && event.code === 'ArrowUp') {
event.preventDefault(); event.preventDefault();
if (!currentItem && types.length) { if (!currentItem && types.length) {
setCurrentItem(types[types.length - 1]); setCurrentItem(types[types.length - 1]);
@@ -99,12 +95,12 @@ export const DoubleLinkMenuContainer = ({
} }
} }
}, },
[isShow, types, currentItem] [types, currentItem]
); );
const handleClickDown = useCallback( const handleClickDown = useCallback(
(event: React.KeyboardEvent<HTMLDivElement>) => { (event: React.KeyboardEvent<HTMLDivElement>) => {
if (isShow && types && event.code === 'ArrowDown') { if (types && event.code === 'ArrowDown') {
event.preventDefault(); event.preventDefault();
if (!currentItem && types.length) { if (!currentItem && types.length) {
setCurrentItem(types[0]); setCurrentItem(types[0]);
@@ -118,17 +114,17 @@ export const DoubleLinkMenuContainer = ({
} }
} }
}, },
[isShow, types, currentItem] [types, currentItem]
); );
const handleClickEnter = useCallback( const handleClickEnter = useCallback(
async (event: React.KeyboardEvent<HTMLDivElement>) => { async (event: React.KeyboardEvent<HTMLDivElement>) => {
if (isShow && event.code === 'Enter' && currentItem) { if (event.code === 'Enter' && currentItem) {
event.preventDefault(); event.preventDefault();
onSelected && onSelected(currentItem); onSelected && onSelected(currentItem);
} }
}, },
[isShow, currentItem, onSelected] [currentItem, onSelected]
); );
const handleKeyDown = useCallback( const handleKeyDown = useCallback(
@@ -150,7 +146,7 @@ export const DoubleLinkMenuContainer = ({
}; };
}, [hooks, handleKeyDown]); }, [hooks, handleKeyDown]);
return isShow ? ( return (
<RootContainer <RootContainer
ref={menuRef} ref={menuRef}
onKeyDownCapture={handleKeyDown} onKeyDownCapture={handleKeyDown}
@@ -165,11 +161,10 @@ export const DoubleLinkMenuContainer = ({
/> />
</ContentContainer> </ContentContainer>
</RootContainer> </RootContainer>
) : null; );
}; };
const RootContainer = styled('div')(({ theme }) => ({ const RootContainer = styled('div')(({ theme }) => ({
// position: 'fixed',
zIndex: 1, zIndex: 1,
maxHeight: '525px', maxHeight: '525px',
borderRadius: '10px', borderRadius: '10px',
@@ -1,10 +1,10 @@
import { CommonListItem } from '@toeverything/components/common'; import { CommonListItem } from '@toeverything/components/common';
import { AddIcon } from '@toeverything/components/icons'; import { AddIcon } from '@toeverything/components/icons';
import { import {
Input,
ListButton, ListButton,
MuiClickAwayListener, MuiClickAwayListener,
MuiGrow as Grow, MuiGrow as Grow,
MuiOutlinedInput as OutlinedInput,
MuiPaper as Paper, MuiPaper as Paper,
MuiPopper as Popper, MuiPopper as Popper,
styled, styled,
@@ -13,6 +13,7 @@ import { services } from '@toeverything/datasource/db-service';
import { HookType, PluginHooks, Virgo } from '@toeverything/framework/virgo'; import { HookType, PluginHooks, Virgo } from '@toeverything/framework/virgo';
import { getPageId } from '@toeverything/utils'; import { getPageId } from '@toeverything/utils';
import React, { import React, {
ChangeEvent,
useCallback, useCallback,
useEffect, useEffect,
useMemo, useMemo,
@@ -24,6 +25,7 @@ import { DoubleLinkMenuContainer } from './Container';
const ADD_NEW_SUB_PAGE = 'AddNewSubPage'; const ADD_NEW_SUB_PAGE = 'AddNewSubPage';
const ADD_NEW_PAGE = 'AddNewPage'; const ADD_NEW_PAGE = 'AddNewPage';
const ARRAY_KEYS = ['ArrowRight', 'ArrowLeft', 'ArrowUp', 'ArrowDown'];
export type DoubleLinkMenuProps = { export type DoubleLinkMenuProps = {
editor: Virgo; editor: Virgo;
@@ -31,7 +33,7 @@ export type DoubleLinkMenuProps = {
style?: { left: number; top: number }; style?: { left: number; top: number };
}; };
type DoubleMenuStyle = { type DoubleLinkMenuStyle = {
left: number; left: number;
top: number; top: number;
height: number; height: number;
@@ -42,58 +44,63 @@ export const DoubleLinkMenu = ({
hooks, hooks,
style, style,
}: DoubleLinkMenuProps) => { }: DoubleLinkMenuProps) => {
const [isShow, setIsShow] = useState(false); const [isOpen, setIsOpen] = useState(false);
const [blockId, setBlockId] = useState<string>();
const [searchText, setSearchText] = useState<string>('');
const [searchBlocks, setSearchBlocks] = useState<QueryResult>([]);
const [items, setItems] = useState<CommonListItem[]>([]);
const [isNewPage, setIsNewPage] = useState(false);
const [anchorEl, setAnchorEl] = useState(null); const [anchorEl, setAnchorEl] = useState(null);
const ref = useRef(); const dialogRef = useRef<HTMLDivElement>();
const ref1 = useRef<HTMLInputElement>(); const newPageSearchRef = useRef<HTMLInputElement>();
const [referenceMenuStyle, setReferenceMenuStyle] = const [doubleLinkMenuStyle, setDoubleLinkMenuStyle] =
useState<DoubleMenuStyle>({ useState<DoubleLinkMenuStyle>({
left: 0, left: 0,
top: 0, top: 0,
height: 0, height: 0,
}); });
useEffect(() => { const [curBlockId, setCurBlockId] = useState<string>();
QueryBlocks(editor, searchText, result => { const [searchText, setSearchText] = useState<string>('');
setSearchBlocks(result); const [inAddNewPage, setInAddNewPage] = useState(false);
ref1?.current?.focus(); const [filterText, setFilterText] = useState<string>('');
const items: CommonListItem[] = []; const [searchResultBlocks, setSearchResultBlocks] = useState<QueryResult>(
if (searchBlocks?.length > 0) { []
if (isNewPage) { );
items.push({
renderCustom: () => {
return <ListButton content={'SUGGESTED'} />;
},
});
} else {
items.push({
renderCustom: () => {
return <ListButton content={'LINK TO PAGE'} />;
},
});
}
items.push(
...(searchBlocks?.map(
block => ({ block } as CommonListItem)
) || [])
);
}
if (items.length > 0) { const menuTypes = useMemo(() => {
items.push({ divider: 'newPage' }); 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({ items.push({
content: { renderCustom: () => {
id: ADD_NEW_SUB_PAGE, return (
content: 'Add new sub-page', <ListButton
icon: AddIcon, content={
inAddNewPage ? 'SUGGESTED' : 'LINK TO PAGE'
}
/>
);
}, },
}); });
items.push(
...(searchResultBlocks?.map(
block => ({ block } 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({ items.push({
content: { content: {
id: ADD_NEW_PAGE, id: ADD_NEW_PAGE,
@@ -101,26 +108,28 @@ export const DoubleLinkMenu = ({
icon: AddIcon, icon: AddIcon,
}, },
}); });
return items;
}, [searchResultBlocks, inAddNewPage]);
setItems(items); useEffect(() => {
const text = inAddNewPage ? filterText : searchText;
QueryBlocks(editor, text, result => {
setSearchResultBlocks(result);
}); });
}, [editor, searchText, isNewPage]); }, [editor, searchText, filterText, inAddNewPage]);
const types = useMemo(() => {
return Object.values(searchBlocks)
.map(({ id }) => id)
.concat([ADD_NEW_SUB_PAGE, ADD_NEW_PAGE]);
}, [searchBlocks]);
const hideMenu = useCallback(() => { const hideMenu = useCallback(() => {
setIsShow(false); setIsOpen(false);
setIsNewPage(false); setInAddNewPage(false);
editor.blockHelper.removeDoubleLinkSearchSlash(blockId); editor.blockHelper.removeDoubleLinkSearchSlash(curBlockId);
editor.scrollManager.unLock(); editor.scrollManager.unLock();
}, [blockId, editor.blockHelper, editor.scrollManager]); }, [curBlockId, editor]);
const handleSearch = useCallback( const searchChange = useCallback(
async (event: React.KeyboardEvent<HTMLDivElement>) => { async (event: React.KeyboardEvent<HTMLDivElement>) => {
if (ARRAY_KEYS.includes(event.key)) {
return;
}
const { type, anchorNode } = editor.selection.currentSelectInfo; const { type, anchorNode } = editor.selection.currentSelectInfo;
if ( if (
type === 'Range' && type === 'Range' &&
@@ -130,24 +139,31 @@ export const DoubleLinkMenu = ({
const text = editor.blockHelper.getBlockTextBeforeSelection( const text = editor.blockHelper.getBlockTextBeforeSelection(
anchorNode.id anchorNode.id
); );
if (text.endsWith('[[')) { if (text.endsWith('[[')) {
if (
[
'ArrowRight',
'ArrowLeft',
'ArrowUp',
'ArrowDown',
].includes(event.key)
) {
return;
}
if (event.key === 'Backspace') { if (event.key === 'Backspace') {
hideMenu(); hideMenu();
return; return;
} }
setBlockId(anchorNode.id); editor.blockHelper.removeDoubleLinkSearchSlash(curBlockId);
editor.blockHelper.removeDoubleLinkSearchSlash(blockId); setCurBlockId(anchorNode.id);
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);
}
setTimeout(() => { setTimeout(() => {
const textSelection = const textSelection =
editor.blockHelper.selectionToSlateRange( editor.blockHelper.selectionToSlateRange(
@@ -163,40 +179,22 @@ export const DoubleLinkMenu = ({
); );
} }
}); });
setSearchText('');
setIsShow(true);
editor.scrollManager.lock();
const rect =
editor.selection.currentSelectInfo?.browserSelection
?.getRangeAt(0)
?.getBoundingClientRect();
if (rect) {
const rectTop = rect.top;
const { top, left } =
editor.container.getBoundingClientRect();
setReferenceMenuStyle({
top: rectTop - top,
left: rect.left - left,
height: rect.height,
});
setAnchorEl(ref.current);
}
} }
} }
if (isShow) { if (isOpen) {
const searchText = const searchText =
editor.blockHelper.getDoubleLinkSearchSlashText(blockId); editor.blockHelper.getDoubleLinkSearchSlashText(curBlockId);
if (searchText && searchText.startsWith('[[')) { if (searchText && searchText.startsWith('[[')) {
setSearchText(searchText.slice(2).trim()); setSearchText(searchText.slice(2).trim());
} }
} }
}, },
[editor, isShow, blockId, hideMenu] [editor, isOpen, curBlockId, hideMenu]
); );
const handleKeyup = useCallback( const handleKeyup = useCallback(
(event: React.KeyboardEvent<HTMLDivElement>) => handleSearch(event), (event: React.KeyboardEvent<HTMLDivElement>) => searchChange(event),
[handleSearch] [searchChange]
); );
const handleKeyDown = useCallback( const handleKeyDown = useCallback(
@@ -223,75 +221,86 @@ export const DoubleLinkMenu = ({
}; };
}, [handleKeyup, handleKeyDown, hooks]); }, [handleKeyup, handleKeyDown, hooks]);
const handleSelected = async (linkBlockId: string) => { const insertDoubleLink = useCallback(
if (blockId) { async (pageId: string) => {
if (linkBlockId === ADD_NEW_SUB_PAGE) { editor.blockHelper.setSelectDoubleLinkSearchSlash(curBlockId);
handleAddSubPage();
return;
}
if (linkBlockId === ADD_NEW_PAGE) {
setIsNewPage(true);
return;
}
if (isNewPage) {
const newPage = await services.api.editorBlock.create({
workspace: editor.workspace,
type: 'page' as const,
});
await services.api.pageTree.addChildPageToWorkspace(
editor.workspace,
linkBlockId,
newPage.id
);
linkBlockId = newPage.id;
}
editor.blockHelper.setSelectDoubleLinkSearchSlash(blockId);
await editor.blockHelper.insertDoubleLink( await editor.blockHelper.insertDoubleLink(
editor.workspace, editor.workspace,
linkBlockId, pageId,
blockId curBlockId
); );
hideMenu(); 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(getPageId());
insertDoubleLink(pageId);
return;
}
if (inAddNewPage) {
const pageId = await addSubPage(id);
insertDoubleLink(pageId);
} else {
insertDoubleLink(id);
}
} }
}; };
const handleClose = () => { const handleFilterChange = useCallback(
blockId && editor.blockHelper.removeDoubleLinkSearchSlash(blockId); async (e: ChangeEvent<HTMLInputElement>) => {
}; const text = e.target.value;
const handleAddSubPage = async () => { await setFilterText(text);
const newPage = await services.api.editorBlock.create({ },
workspace: editor.workspace, []
type: 'page' as const, );
});
setIsNewPage(false);
services.api.editorBlock.update({
id: newPage.id,
workspace: editor.workspace,
properties: {
text: { value: [{ text: searchText }] },
},
});
await services.api.pageTree.addChildPageToWorkspace(
editor.workspace,
getPageId(),
newPage.id
);
handleSelected(newPage.id);
};
return ( return (
<div <div
ref={ref} ref={dialogRef}
style={{ style={{
position: 'absolute', position: 'absolute',
width: '10px', width: '10px',
...referenceMenuStyle, ...doubleLinkMenuStyle,
}} }}
> >
<MuiClickAwayListener onClickAway={() => hideMenu()}> <MuiClickAwayListener onClickAway={() => hideMenu()}>
<Popper <Popper
open={isShow} open={isOpen}
anchorEl={anchorEl} anchorEl={anchorEl}
transition transition
placement="bottom-start" placement="bottom-start"
@@ -304,25 +313,26 @@ export const DoubleLinkMenu = ({
}} }}
> >
<Paper> <Paper>
<DoubleLinkMenuWrapper> {inAddNewPage && (
<SearchContainer ref={ref1}> <NewPageSearchContainer>
<OutlinedInput <Input
placeholder="Filter actions..." ref={newPageSearchRef}
onChange={() => {}} value={filterText}
onChange={handleFilterChange}
placeholder="Search page to add to..."
/> />
</SearchContainer> </NewPageSearchContainer>
<DoubleLinkMenuContainer )}
editor={editor} <DoubleLinkMenuContainer
hooks={hooks} editor={editor}
style={style} hooks={hooks}
isShow={true} style={style}
blockId={blockId} blockId={curBlockId}
onSelected={handleSelected} onSelected={handleSelected}
onClose={handleClose} onClose={hideMenu}
items={items} items={menuItems}
types={types} types={menuTypes}
/> />
</DoubleLinkMenuWrapper>
</Paper> </Paper>
</Grow> </Grow>
)} )}
@@ -332,15 +342,6 @@ export const DoubleLinkMenu = ({
); );
}; };
const DoubleLinkMenuWrapper = styled('div')({ const NewPageSearchContainer = styled('div')({
zIndex: 1, padding: '8px 8px 0px 8px',
});
const SearchContainer = styled('div')({
padding: '8px 8px',
input: {
height: '28px',
padding: '5px 10px',
with: '300px',
},
}); });