feat: Intra-line double link interaction

This commit is contained in:
xiaodong zuo
2022-08-18 16:22:45 +08:00
parent 879a208034
commit 919e0d08d5
18 changed files with 859 additions and 460 deletions
+11 -11
View File
@@ -1,15 +1,15 @@
import type { PluginCreator } from '@toeverything/framework/virgo';
import {
LeftMenuPlugin,
InlineMenuPlugin,
CommandMenuPlugin,
ReferenceMenuPlugin,
SelectionGroupPlugin,
GroupMenuPlugin,
} from './menu';
import { TemplatePlugin } from './template';
import { FullTextSearchPlugin } from './search';
import { AddCommentPlugin } from './comment';
import {
CommandMenuPlugin,
DoubleLinkMenuPlugin,
GroupMenuPlugin,
InlineMenuPlugin,
LeftMenuPlugin,
SelectionGroupPlugin,
} from './menu';
import { FullTextSearchPlugin } from './search';
import { TemplatePlugin } from './template';
// import { PlaceholderPlugin } from './placeholder';
// import { BlockPropertyPlugin } from './block-property';
@@ -19,7 +19,7 @@ export const plugins: PluginCreator[] = [
LeftMenuPlugin,
InlineMenuPlugin,
CommandMenuPlugin,
ReferenceMenuPlugin,
DoubleLinkMenuPlugin,
TemplatePlugin,
SelectionGroupPlugin,
AddCommentPlugin,
@@ -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',
},
});
@@ -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';