mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-12 23:56:36 +08:00
feat: Intra-line double link interaction
This commit is contained in:
@@ -0,0 +1,185 @@
|
||||
import React, { useEffect, useState, useCallback, useRef } from 'react';
|
||||
|
||||
import { Virgo, PluginHooks, HookType } from '@toeverything/framework/virgo';
|
||||
import {
|
||||
CommonList,
|
||||
CommonListItem,
|
||||
commonListContainer,
|
||||
} from '@toeverything/components/common';
|
||||
import { domToRect } from '@toeverything/utils';
|
||||
import { styled } from '@toeverything/components/ui';
|
||||
|
||||
import { QueryResult } from '../../search';
|
||||
|
||||
export type DoubleLinkMenuContainerProps = {
|
||||
editor: Virgo;
|
||||
hooks: PluginHooks;
|
||||
style?: React.CSSProperties;
|
||||
isShow?: boolean;
|
||||
blockId: string;
|
||||
onSelected?: (item: string) => void;
|
||||
onClose?: () => void;
|
||||
types?: Array<string>;
|
||||
items?: CommonListItem[];
|
||||
};
|
||||
|
||||
export const DoubleLinkMenuContainer = ({
|
||||
hooks,
|
||||
isShow = false,
|
||||
onSelected,
|
||||
onClose,
|
||||
types,
|
||||
style,
|
||||
items,
|
||||
}: DoubleLinkMenuContainerProps) => {
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
const [currentItem, setCurrentItem] = useState<string | undefined>();
|
||||
const [needCheckIntoView, setNeedCheckIntoView] = useState<boolean>(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (needCheckIntoView) {
|
||||
if (currentItem && menuRef.current) {
|
||||
const itemEle =
|
||||
menuRef.current.querySelector<HTMLButtonElement>(
|
||||
`.item-${currentItem}`
|
||||
);
|
||||
const scrollEle =
|
||||
menuRef.current.querySelector<HTMLButtonElement>(
|
||||
`.${commonListContainer}`
|
||||
);
|
||||
if (itemEle) {
|
||||
const itemRect = domToRect(itemEle);
|
||||
const scrollRect = domToRect(scrollEle);
|
||||
if (
|
||||
itemRect.top < scrollRect.top ||
|
||||
itemRect.bottom > scrollRect.bottom
|
||||
) {
|
||||
// IMP: may be do it with self function
|
||||
itemEle.scrollIntoView({
|
||||
block: 'nearest',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
setNeedCheckIntoView(false);
|
||||
}
|
||||
}, [needCheckIntoView, currentItem]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isShow && types && !currentItem) setCurrentItem(types[0]);
|
||||
if (!isShow) onClose?.();
|
||||
}, [currentItem, isShow, onClose, types]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isShow && types) {
|
||||
if (!types.includes(currentItem)) {
|
||||
setNeedCheckIntoView(true);
|
||||
if (types.length) {
|
||||
setCurrentItem(types[0]);
|
||||
} else {
|
||||
setCurrentItem(undefined);
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [isShow, types, currentItem]);
|
||||
|
||||
const handleClickUp = useCallback(
|
||||
(event: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
if (isShow && types && event.code === 'ArrowUp') {
|
||||
event.preventDefault();
|
||||
if (!currentItem && types.length) {
|
||||
setCurrentItem(types[types.length - 1]);
|
||||
}
|
||||
if (currentItem) {
|
||||
const idx = types.indexOf(currentItem);
|
||||
if (idx > 0) {
|
||||
setNeedCheckIntoView(true);
|
||||
setCurrentItem(types[idx - 1]);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
[isShow, types, currentItem]
|
||||
);
|
||||
|
||||
const handleClickDown = useCallback(
|
||||
(event: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
if (isShow && types && event.code === 'ArrowDown') {
|
||||
event.preventDefault();
|
||||
if (!currentItem && types.length) {
|
||||
setCurrentItem(types[0]);
|
||||
}
|
||||
if (currentItem) {
|
||||
const idx = types.indexOf(currentItem);
|
||||
if (idx < types.length - 1) {
|
||||
setNeedCheckIntoView(true);
|
||||
setCurrentItem(types[idx + 1]);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
[isShow, types, currentItem]
|
||||
);
|
||||
|
||||
const handleClickEnter = useCallback(
|
||||
async (event: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
if (isShow && event.code === 'Enter' && currentItem) {
|
||||
event.preventDefault();
|
||||
onSelected && onSelected(currentItem);
|
||||
}
|
||||
},
|
||||
[isShow, currentItem, onSelected]
|
||||
);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(event: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
handleClickUp(event);
|
||||
handleClickDown(event);
|
||||
handleClickEnter(event);
|
||||
},
|
||||
[handleClickUp, handleClickDown, handleClickEnter]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const sub = hooks
|
||||
.get(HookType.ON_ROOT_NODE_KEYDOWN_CAPTURE)
|
||||
.subscribe(handleKeyDown);
|
||||
|
||||
return () => {
|
||||
sub.unsubscribe();
|
||||
};
|
||||
}, [hooks, handleKeyDown]);
|
||||
|
||||
return isShow ? (
|
||||
<RootContainer
|
||||
ref={menuRef}
|
||||
onKeyDownCapture={handleKeyDown}
|
||||
style={style}
|
||||
>
|
||||
<ContentContainer>
|
||||
<CommonList
|
||||
items={items}
|
||||
onSelected={type => onSelected?.(type)}
|
||||
currentItem={currentItem}
|
||||
setCurrentItem={setCurrentItem}
|
||||
/>
|
||||
</ContentContainer>
|
||||
</RootContainer>
|
||||
) : null;
|
||||
};
|
||||
|
||||
const RootContainer = styled('div')(({ theme }) => ({
|
||||
// position: 'fixed',
|
||||
zIndex: 1,
|
||||
maxHeight: '525px',
|
||||
borderRadius: '10px',
|
||||
boxShadow: theme.affine.shadows.shadow1,
|
||||
backgroundColor: '#fff',
|
||||
padding: '8px 4px',
|
||||
}));
|
||||
|
||||
const ContentContainer = styled('div')(({ theme }) => ({
|
||||
display: 'flex',
|
||||
overflow: 'hidden',
|
||||
maxHeight: '493px',
|
||||
}));
|
||||
@@ -0,0 +1,346 @@
|
||||
import { CommonListItem } from '@toeverything/components/common';
|
||||
import { AddIcon } from '@toeverything/components/icons';
|
||||
import {
|
||||
ListButton,
|
||||
MuiClickAwayListener,
|
||||
MuiGrow as Grow,
|
||||
MuiOutlinedInput as OutlinedInput,
|
||||
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 { getPageId } from '@toeverything/utils';
|
||||
import React, {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { QueryBlocks, QueryResult } from '../../search';
|
||||
import { DoubleLinkMenuContainer } from './Container';
|
||||
|
||||
const ADD_NEW_SUB_PAGE = 'AddNewSubPage';
|
||||
const ADD_NEW_PAGE = 'AddNewPage';
|
||||
|
||||
export type DoubleLinkMenuProps = {
|
||||
editor: Virgo;
|
||||
hooks: PluginHooks;
|
||||
style?: { left: number; top: number };
|
||||
};
|
||||
|
||||
type DoubleMenuStyle = {
|
||||
left: number;
|
||||
top: number;
|
||||
height: number;
|
||||
};
|
||||
|
||||
export const DoubleLinkMenu = ({
|
||||
editor,
|
||||
hooks,
|
||||
style,
|
||||
}: DoubleLinkMenuProps) => {
|
||||
const [isShow, setIsShow] = 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 ref = useRef();
|
||||
const ref1 = useRef<HTMLInputElement>();
|
||||
const [referenceMenuStyle, setReferenceMenuStyle] =
|
||||
useState<DoubleMenuStyle>({
|
||||
left: 0,
|
||||
top: 0,
|
||||
height: 0,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
QueryBlocks(editor, searchText, result => {
|
||||
setSearchBlocks(result);
|
||||
ref1?.current?.focus();
|
||||
const items: CommonListItem[] = [];
|
||||
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) {
|
||||
items.push({ divider: 'newPage' });
|
||||
}
|
||||
items.push({
|
||||
content: {
|
||||
id: ADD_NEW_SUB_PAGE,
|
||||
content: 'Add new sub-page',
|
||||
icon: AddIcon,
|
||||
},
|
||||
});
|
||||
items.push({
|
||||
content: {
|
||||
id: ADD_NEW_PAGE,
|
||||
content: 'Add new page in...',
|
||||
icon: AddIcon,
|
||||
},
|
||||
});
|
||||
|
||||
setItems(items);
|
||||
});
|
||||
}, [editor, searchText, isNewPage]);
|
||||
|
||||
const types = useMemo(() => {
|
||||
return Object.values(searchBlocks)
|
||||
.map(({ id }) => id)
|
||||
.concat([ADD_NEW_SUB_PAGE, ADD_NEW_PAGE]);
|
||||
}, [searchBlocks]);
|
||||
|
||||
const hideMenu = useCallback(() => {
|
||||
setIsShow(false);
|
||||
setIsNewPage(false);
|
||||
editor.blockHelper.removeDoubleLinkSearchSlash(blockId);
|
||||
editor.scrollManager.unLock();
|
||||
}, [blockId, editor.blockHelper, editor.scrollManager]);
|
||||
|
||||
const handleSearch = useCallback(
|
||||
async (event: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
const { type, anchorNode } = editor.selection.currentSelectInfo;
|
||||
if (
|
||||
type === 'Range' &&
|
||||
anchorNode &&
|
||||
editor.blockHelper.isSelectionCollapsed(anchorNode.id)
|
||||
) {
|
||||
const text = editor.blockHelper.getBlockTextBeforeSelection(
|
||||
anchorNode.id
|
||||
);
|
||||
|
||||
if (text.endsWith('[[')) {
|
||||
if (
|
||||
[
|
||||
'ArrowRight',
|
||||
'ArrowLeft',
|
||||
'ArrowUp',
|
||||
'ArrowDown',
|
||||
].includes(event.key)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (event.key === 'Backspace') {
|
||||
hideMenu();
|
||||
return;
|
||||
}
|
||||
setBlockId(anchorNode.id);
|
||||
editor.blockHelper.removeDoubleLinkSearchSlash(blockId);
|
||||
setTimeout(() => {
|
||||
const textSelection =
|
||||
editor.blockHelper.selectionToSlateRange(
|
||||
anchorNode.id,
|
||||
editor.selection.currentSelectInfo
|
||||
.browserSelection
|
||||
);
|
||||
if (textSelection) {
|
||||
const { anchor } = textSelection;
|
||||
editor.blockHelper.setDoubleLinkSearchSlash(
|
||||
anchorNode.id,
|
||||
anchor
|
||||
);
|
||||
}
|
||||
});
|
||||
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) {
|
||||
const searchText =
|
||||
editor.blockHelper.getDoubleLinkSearchSlashText(blockId);
|
||||
if (searchText && searchText.startsWith('[[')) {
|
||||
setSearchText(searchText.slice(2).trim());
|
||||
}
|
||||
}
|
||||
},
|
||||
[editor, isShow, blockId, hideMenu]
|
||||
);
|
||||
|
||||
const handleKeyup = useCallback(
|
||||
(event: React.KeyboardEvent<HTMLDivElement>) => handleSearch(event),
|
||||
[handleSearch]
|
||||
);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(event: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
if (event.code === 'Escape') {
|
||||
hideMenu();
|
||||
}
|
||||
},
|
||||
[hideMenu]
|
||||
);
|
||||
|
||||
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]);
|
||||
|
||||
const handleSelected = async (linkBlockId: string) => {
|
||||
if (blockId) {
|
||||
if (linkBlockId === ADD_NEW_SUB_PAGE) {
|
||||
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(
|
||||
editor.workspace,
|
||||
linkBlockId,
|
||||
blockId
|
||||
);
|
||||
hideMenu();
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
blockId && editor.blockHelper.removeDoubleLinkSearchSlash(blockId);
|
||||
};
|
||||
|
||||
const handleAddSubPage = async () => {
|
||||
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 (
|
||||
<div
|
||||
ref={ref}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
width: '10px',
|
||||
...referenceMenuStyle,
|
||||
}}
|
||||
>
|
||||
<MuiClickAwayListener onClickAway={() => hideMenu()}>
|
||||
<Popper
|
||||
open={isShow}
|
||||
anchorEl={anchorEl}
|
||||
transition
|
||||
placement="bottom-start"
|
||||
>
|
||||
{({ TransitionProps }) => (
|
||||
<Grow
|
||||
{...TransitionProps}
|
||||
style={{
|
||||
transformOrigin: 'left bottom',
|
||||
}}
|
||||
>
|
||||
<Paper>
|
||||
<DoubleLinkMenuWrapper>
|
||||
<SearchContainer ref={ref1}>
|
||||
<OutlinedInput
|
||||
placeholder="Filter actions..."
|
||||
onChange={() => {}}
|
||||
/>
|
||||
</SearchContainer>
|
||||
<DoubleLinkMenuContainer
|
||||
editor={editor}
|
||||
hooks={hooks}
|
||||
style={style}
|
||||
isShow={true}
|
||||
blockId={blockId}
|
||||
onSelected={handleSelected}
|
||||
onClose={handleClose}
|
||||
items={items}
|
||||
types={types}
|
||||
/>
|
||||
</DoubleLinkMenuWrapper>
|
||||
</Paper>
|
||||
</Grow>
|
||||
)}
|
||||
</Popper>
|
||||
</MuiClickAwayListener>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const DoubleLinkMenuWrapper = styled('div')({
|
||||
zIndex: 1,
|
||||
});
|
||||
|
||||
const SearchContainer = styled('div')({
|
||||
padding: '8px 8px',
|
||||
input: {
|
||||
height: '28px',
|
||||
padding: '5px 10px',
|
||||
with: '300px',
|
||||
},
|
||||
});
|
||||
+3
-4
@@ -1,12 +1,11 @@
|
||||
import { StrictMode } from 'react';
|
||||
|
||||
import { BasePlugin } from '../../base-plugin';
|
||||
import { PluginRenderRoot } from '../../utils';
|
||||
import { ReferenceMenu } from './ReferenceMenu';
|
||||
import { DoubleLinkMenu } from './DoubleLinkMenu';
|
||||
|
||||
const PLUGIN_NAME = 'reference-menu';
|
||||
|
||||
export class ReferenceMenuPlugin extends BasePlugin {
|
||||
export class DoubleLinkMenuPlugin extends BasePlugin {
|
||||
private _root?: PluginRenderRoot;
|
||||
|
||||
public static override get pluginName(): string {
|
||||
@@ -22,7 +21,7 @@ export class ReferenceMenuPlugin extends BasePlugin {
|
||||
|
||||
this._root?.render(
|
||||
<StrictMode>
|
||||
<ReferenceMenu editor={this.editor} hooks={this.hooks} />
|
||||
<DoubleLinkMenu editor={this.editor} hooks={this.hooks} />
|
||||
</StrictMode>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { DoubleLinkMenuPlugin } from './Plugin';
|
||||
@@ -1,9 +1,7 @@
|
||||
export { CommandMenuPlugin } from './command-menu';
|
||||
export { DoubleLinkMenuPlugin } from './double-link-menu';
|
||||
export { GroupMenuPlugin } from './group-menu';
|
||||
export { InlineMenuPlugin } from './inline-menu';
|
||||
export { LeftMenuPlugin } from './left-menu/LeftMenuPlugin';
|
||||
export { CommandMenuPlugin } from './command-menu';
|
||||
export { ReferenceMenuPlugin } from './reference-menu';
|
||||
export { SelectionGroupPlugin } from './selection-group-menu';
|
||||
|
||||
export { MENU_WIDTH as menuWidth } from './left-menu/menu-config';
|
||||
|
||||
export { GroupMenuPlugin } from './group-menu';
|
||||
export { SelectionGroupPlugin } from './selection-group-menu';
|
||||
|
||||
@@ -1,190 +0,0 @@
|
||||
import React, { useEffect, useState, useCallback, useRef } from 'react';
|
||||
|
||||
import { Virgo, PluginHooks, HookType } from '@toeverything/framework/virgo';
|
||||
import {
|
||||
CommonList,
|
||||
CommonListItem,
|
||||
commonListContainer,
|
||||
} from '@toeverything/components/common';
|
||||
import { domToRect } from '@toeverything/utils';
|
||||
import { styled } from '@toeverything/components/ui';
|
||||
|
||||
import { QueryResult } from '../../search';
|
||||
|
||||
export type ReferenceMenuContainerProps = {
|
||||
editor: Virgo;
|
||||
hooks: PluginHooks;
|
||||
style?: React.CSSProperties;
|
||||
isShow?: boolean;
|
||||
blockId: string;
|
||||
onSelected?: (item: string) => void;
|
||||
onClose?: () => void;
|
||||
searchBlocks?: QueryResult;
|
||||
types?: Array<string>;
|
||||
};
|
||||
|
||||
export const ReferenceMenuContainer = ({
|
||||
hooks,
|
||||
isShow = false,
|
||||
onSelected,
|
||||
onClose,
|
||||
types,
|
||||
searchBlocks,
|
||||
style,
|
||||
}: ReferenceMenuContainerProps) => {
|
||||
const menu_ref = useRef<HTMLDivElement>(null);
|
||||
const [current_item, set_current_item] = useState<string | undefined>();
|
||||
const [need_check_into_view, set_need_check_into_view] =
|
||||
useState<boolean>(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (need_check_into_view) {
|
||||
if (current_item && menu_ref.current) {
|
||||
const item_ele =
|
||||
menu_ref.current.querySelector<HTMLButtonElement>(
|
||||
`.item-${current_item}`
|
||||
);
|
||||
const scroll_ele =
|
||||
menu_ref.current.querySelector<HTMLButtonElement>(
|
||||
`.${commonListContainer}`
|
||||
);
|
||||
if (item_ele) {
|
||||
const itemRect = domToRect(item_ele);
|
||||
const scrollRect = domToRect(scroll_ele);
|
||||
if (
|
||||
itemRect.top < scrollRect.top ||
|
||||
itemRect.bottom > scrollRect.bottom
|
||||
) {
|
||||
// IMP: may be do it with self function
|
||||
item_ele.scrollIntoView({
|
||||
block: 'nearest',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
set_need_check_into_view(false);
|
||||
}
|
||||
}, [need_check_into_view, current_item]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isShow && types && !current_item) set_current_item(types[0]);
|
||||
if (!isShow) onClose?.();
|
||||
}, [current_item, isShow, onClose, types]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isShow && types) {
|
||||
if (!types.includes(current_item)) {
|
||||
set_need_check_into_view(true);
|
||||
if (types.length) {
|
||||
set_current_item(types[0]);
|
||||
} else {
|
||||
set_current_item(undefined);
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [isShow, types, current_item]);
|
||||
|
||||
const handle_click_up = useCallback(
|
||||
(event: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
if (isShow && types && event.code === 'ArrowUp') {
|
||||
event.preventDefault();
|
||||
if (!current_item && types.length) {
|
||||
set_current_item(types[types.length - 1]);
|
||||
}
|
||||
if (current_item) {
|
||||
const idx = types.indexOf(current_item);
|
||||
if (idx > 0) {
|
||||
set_need_check_into_view(true);
|
||||
set_current_item(types[idx - 1]);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
[isShow, types, current_item]
|
||||
);
|
||||
|
||||
const handle_click_down = useCallback(
|
||||
(event: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
if (isShow && types && event.code === 'ArrowDown') {
|
||||
event.preventDefault();
|
||||
if (!current_item && types.length) {
|
||||
set_current_item(types[0]);
|
||||
}
|
||||
if (current_item) {
|
||||
const idx = types.indexOf(current_item);
|
||||
if (idx < types.length - 1) {
|
||||
set_need_check_into_view(true);
|
||||
set_current_item(types[idx + 1]);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
[isShow, types, current_item]
|
||||
);
|
||||
|
||||
const handle_click_enter = useCallback(
|
||||
async (event: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
if (isShow && event.code === 'Enter' && current_item) {
|
||||
event.preventDefault();
|
||||
onSelected && onSelected(current_item);
|
||||
}
|
||||
},
|
||||
[isShow, current_item, onSelected]
|
||||
);
|
||||
|
||||
const handle_key_down = useCallback(
|
||||
(event: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
handle_click_up(event);
|
||||
handle_click_down(event);
|
||||
handle_click_enter(event);
|
||||
},
|
||||
[handle_click_up, handle_click_down, handle_click_enter]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const sub = hooks
|
||||
.get(HookType.ON_ROOT_NODE_KEYDOWN_CAPTURE)
|
||||
.subscribe(handle_key_down);
|
||||
|
||||
return () => {
|
||||
sub.unsubscribe();
|
||||
};
|
||||
}, [hooks, handle_key_down]);
|
||||
|
||||
return isShow ? (
|
||||
<RootContainer
|
||||
ref={menu_ref}
|
||||
onKeyDownCapture={handle_key_down}
|
||||
style={style}
|
||||
>
|
||||
<ContentContainer>
|
||||
<CommonList
|
||||
items={
|
||||
searchBlocks?.map(
|
||||
block => ({ block } as CommonListItem)
|
||||
) || []
|
||||
}
|
||||
onSelected={type => onSelected?.(type)}
|
||||
currentItem={current_item}
|
||||
setCurrentItem={set_current_item}
|
||||
/>
|
||||
</ContentContainer>
|
||||
</RootContainer>
|
||||
) : null;
|
||||
};
|
||||
|
||||
const RootContainer = styled('div')(({ theme }) => ({
|
||||
position: 'fixed',
|
||||
zIndex: 1,
|
||||
maxHeight: '525px',
|
||||
borderRadius: '10px',
|
||||
boxShadow: theme.affine.shadows.shadow1,
|
||||
backgroundColor: '#fff',
|
||||
padding: '8px 4px',
|
||||
}));
|
||||
|
||||
const ContentContainer = styled('div')(({ theme }) => ({
|
||||
display: 'flex',
|
||||
overflow: 'hidden',
|
||||
maxHeight: '493px',
|
||||
}));
|
||||
@@ -1,148 +0,0 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { MuiClickAwayListener, styled } from '@toeverything/components/ui';
|
||||
import { Virgo, HookType, PluginHooks } from '@toeverything/framework/virgo';
|
||||
import { Point } from '@toeverything/utils';
|
||||
|
||||
import { ReferenceMenuContainer } from './Container';
|
||||
import { QueryBlocks, QueryResult } from '../../search';
|
||||
|
||||
export type ReferenceMenuProps = {
|
||||
editor: Virgo;
|
||||
hooks: PluginHooks;
|
||||
style?: { left: number; top: number };
|
||||
};
|
||||
|
||||
export type RefLinkComponent = {
|
||||
type: 'reflink';
|
||||
reference: string;
|
||||
};
|
||||
|
||||
const BEFORE_REGEX = /\[\[(.*)$/;
|
||||
|
||||
export const ReferenceMenu = ({ editor, hooks, style }: ReferenceMenuProps) => {
|
||||
const [is_show, set_is_show] = useState(false);
|
||||
const [block_id, set_block_id] = useState<string>();
|
||||
const [position, set_position] = useState<Point>(new Point(0, 0));
|
||||
|
||||
const [search_text, set_search_text] = useState<string>('');
|
||||
const [search_blocks, set_search_blocks] = useState<QueryResult>([]);
|
||||
|
||||
useEffect(() => {
|
||||
QueryBlocks(editor, search_text, result => set_search_blocks(result));
|
||||
}, [editor, search_text]);
|
||||
|
||||
const search_block_ids = useMemo(
|
||||
() => Object.values(search_blocks).map(({ id }) => id),
|
||||
[search_blocks]
|
||||
);
|
||||
|
||||
const handle_search = useCallback(
|
||||
async (event: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
const { type, anchorNode } = editor.selection.currentSelectInfo;
|
||||
if (
|
||||
type === 'Range' &&
|
||||
anchorNode &&
|
||||
editor.blockHelper.isSelectionCollapsed(anchorNode.id)
|
||||
) {
|
||||
const text = editor.blockHelper.getBlockTextBeforeSelection(
|
||||
anchorNode.id
|
||||
);
|
||||
const matched = BEFORE_REGEX.exec(text)?.[1];
|
||||
|
||||
if (typeof matched === 'string') {
|
||||
if (event.key === '[') set_is_show(true);
|
||||
|
||||
set_block_id(anchorNode.id);
|
||||
set_search_text(matched);
|
||||
|
||||
const rect =
|
||||
editor.selection.currentSelectInfo?.browserSelection
|
||||
?.getRangeAt(0)
|
||||
?.getBoundingClientRect();
|
||||
if (rect) {
|
||||
set_position(new Point(rect.left, rect.top + 24));
|
||||
}
|
||||
} else if (is_show) {
|
||||
set_is_show(false);
|
||||
}
|
||||
}
|
||||
},
|
||||
[editor, is_show]
|
||||
);
|
||||
|
||||
const handle_keyup = useCallback(
|
||||
(event: React.KeyboardEvent<HTMLDivElement>) => handle_search(event),
|
||||
[handle_search]
|
||||
);
|
||||
|
||||
const handle_key_down = useCallback(
|
||||
(event: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
if (event.code === 'Escape') {
|
||||
set_is_show(false);
|
||||
}
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const sub = hooks
|
||||
.get(HookType.ON_ROOT_NODE_KEYUP)
|
||||
.subscribe(handle_keyup);
|
||||
sub.add(
|
||||
hooks
|
||||
.get(HookType.ON_ROOT_NODE_KEYDOWN_CAPTURE)
|
||||
.subscribe(handle_key_down)
|
||||
);
|
||||
|
||||
return () => {
|
||||
sub.unsubscribe();
|
||||
};
|
||||
}, [handle_keyup, handle_key_down, hooks]);
|
||||
|
||||
const handle_selected = async (reference: string) => {
|
||||
if (block_id) {
|
||||
const { anchorNode } = editor.selection.currentSelectInfo;
|
||||
editor.blockHelper.insertReference(
|
||||
reference,
|
||||
anchorNode.id,
|
||||
editor.selection.currentSelectInfo?.browserSelection,
|
||||
-search_text.length - 2
|
||||
);
|
||||
}
|
||||
|
||||
set_is_show(false);
|
||||
};
|
||||
|
||||
const handle_close = () => {
|
||||
block_id && editor.blockHelper.removeSearchSlash(block_id);
|
||||
};
|
||||
|
||||
return (
|
||||
<ReferenceMenuWrapper
|
||||
style={{ top: position.y, left: position.x }}
|
||||
onKeyUp={handle_keyup}
|
||||
>
|
||||
<MuiClickAwayListener onClickAway={() => set_is_show(false)}>
|
||||
<div>
|
||||
<ReferenceMenuContainer
|
||||
editor={editor}
|
||||
hooks={hooks}
|
||||
style={style}
|
||||
isShow={is_show && !!search_text}
|
||||
blockId={block_id}
|
||||
onSelected={handle_selected}
|
||||
onClose={handle_close}
|
||||
searchBlocks={search_blocks}
|
||||
types={search_block_ids}
|
||||
/>
|
||||
</div>
|
||||
</MuiClickAwayListener>
|
||||
</ReferenceMenuWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
const ReferenceMenuWrapper = styled('div')({
|
||||
position: 'absolute',
|
||||
zIndex: 1,
|
||||
});
|
||||
@@ -1 +0,0 @@
|
||||
export { ReferenceMenuPlugin } from './Plugin';
|
||||
Reference in New Issue
Block a user