feat: Link feature add search function
This commit is contained in:
@@ -19,6 +19,7 @@ declare module 'slate' {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export { default as isUrl } from 'is-url';
|
||||||
export { BlockPreview, StyledBlockPreview } from './block-preview';
|
export { BlockPreview, StyledBlockPreview } from './block-preview';
|
||||||
export { default as Button } from './button';
|
export { default as Button } from './button';
|
||||||
export { CollapsibleTitle } from './collapsible-title';
|
export { CollapsibleTitle } from './collapsible-title';
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import {
|
import {
|
||||||
Descendant,
|
Descendant,
|
||||||
Editor,
|
Editor,
|
||||||
|
Element as SlateElement,
|
||||||
Location,
|
Location,
|
||||||
Node as SlateNode,
|
Node as SlateNode,
|
||||||
Path,
|
Path,
|
||||||
@@ -1230,6 +1231,57 @@ class SlateUtils {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public wrapLink(url: string, preSelection?: Location) {
|
||||||
|
if (!ReactEditor.isFocused(this.editor) && preSelection) {
|
||||||
|
Transforms.select(this.editor, preSelection);
|
||||||
|
}
|
||||||
|
if (this.isLinkActive()) {
|
||||||
|
this.unwrapLink();
|
||||||
|
}
|
||||||
|
const { selection } = this.editor;
|
||||||
|
const isCollapsed = selection && this.isCollapsed();
|
||||||
|
const link = {
|
||||||
|
type: 'link',
|
||||||
|
url: url,
|
||||||
|
children: isCollapsed ? [{ text: url }] : [],
|
||||||
|
id: getRandomString('link'),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isCollapsed) {
|
||||||
|
Transforms.insertNodes(this.editor, link);
|
||||||
|
} else {
|
||||||
|
Transforms.wrapNodes(this.editor, link, { split: true });
|
||||||
|
Transforms.collapse(this.editor, { edge: 'end' });
|
||||||
|
}
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
ReactEditor.focus(this.editor);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public isLinkActive() {
|
||||||
|
const [link] = Editor.nodes(this.editor, {
|
||||||
|
match: n =>
|
||||||
|
!Editor.isEditor(n) &&
|
||||||
|
SlateElement.isElement(n) &&
|
||||||
|
// @ts-expect-error
|
||||||
|
n.type === 'link',
|
||||||
|
});
|
||||||
|
return !!link;
|
||||||
|
}
|
||||||
|
|
||||||
|
public unwrapLink() {
|
||||||
|
Transforms.unwrapNodes(this.editor, {
|
||||||
|
match: n => {
|
||||||
|
return (
|
||||||
|
!Editor.isEditor(n) &&
|
||||||
|
SlateElement.isElement(n) &&
|
||||||
|
// @ts-expect-error
|
||||||
|
n.type === 'link'
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/** todo improve if selection is collapsed */
|
/** todo improve if selection is collapsed */
|
||||||
public getCommentsIdsBySelection() {
|
public getCommentsIdsBySelection() {
|
||||||
const commentedTextNodes = Editor.nodes(this.editor, {
|
const commentedTextNodes = Editor.nodes(this.editor, {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import type {
|
|||||||
} from '@toeverything/components/common';
|
} from '@toeverything/components/common';
|
||||||
import {
|
import {
|
||||||
BaseRange,
|
BaseRange,
|
||||||
|
Location,
|
||||||
Node,
|
Node,
|
||||||
Path,
|
Path,
|
||||||
Point,
|
Point,
|
||||||
@@ -41,7 +42,8 @@ type TextUtilsFunctions =
|
|||||||
| 'blur'
|
| 'blur'
|
||||||
| 'setSelection'
|
| 'setSelection'
|
||||||
| 'insertNodes'
|
| 'insertNodes'
|
||||||
| 'getNodeByPath';
|
| 'getNodeByPath'
|
||||||
|
| 'wrapLink';
|
||||||
|
|
||||||
type ExtendedTextUtils = SlateUtils & {
|
type ExtendedTextUtils = SlateUtils & {
|
||||||
setLinkModalVisible: (visible: boolean) => void;
|
setLinkModalVisible: (visible: boolean) => void;
|
||||||
@@ -412,4 +414,13 @@ export class BlockHelper {
|
|||||||
console.warn('Could find the block text utils');
|
console.warn('Could find the block text utils');
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public wrapLink(blockId: string, url: string, preSelection?: Location) {
|
||||||
|
const text_utils = this._blockTextUtilsMap[blockId];
|
||||||
|
if (text_utils) {
|
||||||
|
return text_utils.wrapLink(url, preSelection);
|
||||||
|
}
|
||||||
|
console.warn('Could find the block text utils');
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
GroupMenuPlugin,
|
GroupMenuPlugin,
|
||||||
InlineMenuPlugin,
|
InlineMenuPlugin,
|
||||||
LeftMenuPlugin,
|
LeftMenuPlugin,
|
||||||
SelectionGroupPlugin,
|
LinkMenuPlugin,
|
||||||
} from './menu';
|
} from './menu';
|
||||||
import { FullTextSearchPlugin } from './search';
|
import { FullTextSearchPlugin } from './search';
|
||||||
import { TemplatePlugin } from './template';
|
import { TemplatePlugin } from './template';
|
||||||
@@ -24,4 +24,5 @@ export const plugins: PluginCreator[] = [
|
|||||||
// SelectionGroupPlugin,
|
// SelectionGroupPlugin,
|
||||||
AddCommentPlugin,
|
AddCommentPlugin,
|
||||||
GroupMenuPlugin,
|
GroupMenuPlugin,
|
||||||
|
LinkMenuPlugin,
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -4,4 +4,5 @@ export { GroupMenuPlugin } from './group-menu';
|
|||||||
export { InlineMenuPlugin } from './inline-menu';
|
export { InlineMenuPlugin } from './inline-menu';
|
||||||
export { LeftMenuPlugin } from './left-menu/LeftMenuPlugin';
|
export { LeftMenuPlugin } from './left-menu/LeftMenuPlugin';
|
||||||
export { MENU_WIDTH as menuWidth } from './left-menu/menu-config';
|
export { MENU_WIDTH as menuWidth } from './left-menu/menu-config';
|
||||||
|
export { LinkMenuPlugin } from './link-menu';
|
||||||
export { SelectionGroupPlugin } from './selection-group-menu';
|
export { SelectionGroupPlugin } from './selection-group-menu';
|
||||||
|
|||||||
@@ -185,6 +185,8 @@ const common_handler_for_inline_menu: ClickItemHandler = ({
|
|||||||
editor,
|
editor,
|
||||||
blockId: anchorNodeId,
|
blockId: anchorNodeId,
|
||||||
});
|
});
|
||||||
|
editor.plugins.emit('showAddLink');
|
||||||
|
setShow(false);
|
||||||
break;
|
break;
|
||||||
case inlineMenuNamesKeys.code:
|
case inlineMenuNamesKeys.code:
|
||||||
toggle_text_format({
|
toggle_text_format({
|
||||||
|
|||||||
263
libs/components/editor-plugins/src/menu/link-menu/LinkMenu.tsx
Normal file
263
libs/components/editor-plugins/src/menu/link-menu/LinkMenu.tsx
Normal file
@@ -0,0 +1,263 @@
|
|||||||
|
import { CommonListItem, isUrl } from '@toeverything/components/common';
|
||||||
|
import { LinkIcon } from '@toeverything/components/icons';
|
||||||
|
import {
|
||||||
|
ListButton,
|
||||||
|
MuiClickAwayListener,
|
||||||
|
MuiGrow as Grow,
|
||||||
|
MuiPaper as Paper,
|
||||||
|
MuiPopper as Popper,
|
||||||
|
styled,
|
||||||
|
} from '@toeverything/components/ui';
|
||||||
|
import { PluginHooks, Virgo } from '@toeverything/framework/virgo';
|
||||||
|
import {
|
||||||
|
ChangeEvent,
|
||||||
|
KeyboardEvent,
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from 'react';
|
||||||
|
import { useParams } from 'react-router-dom';
|
||||||
|
import { QueryBlocks, QueryResult } from '../../search';
|
||||||
|
import { DoubleLinkMenuContainer } from '../double-link-menu/Container';
|
||||||
|
|
||||||
|
const ADD_NEW_SUB_PAGE = 'AddNewSubPage';
|
||||||
|
const ADD_NEW_PAGE = 'AddNewPage';
|
||||||
|
|
||||||
|
export type LinkMenuProps = {
|
||||||
|
editor: Virgo;
|
||||||
|
hooks: PluginHooks;
|
||||||
|
};
|
||||||
|
|
||||||
|
type LinkMenuStyle = {
|
||||||
|
left: number;
|
||||||
|
top: number;
|
||||||
|
height: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeUrl = (url: string) => {
|
||||||
|
// eslint-disable-next-line no-restricted-globals
|
||||||
|
return /^https?/.test(url) ? url : `${location.protocol}//${url}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const LinkMenu = ({ editor, hooks }: LinkMenuProps) => {
|
||||||
|
const { page_id: curPageId } = useParams();
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const [anchorEl, setAnchorEl] = useState(null);
|
||||||
|
const dialogRef = useRef<HTMLDivElement>();
|
||||||
|
const inputEl = useRef<HTMLInputElement>();
|
||||||
|
const [linkMenuStyle, setLinkMenuStyle] = useState<LinkMenuStyle>({
|
||||||
|
left: 0,
|
||||||
|
top: 0,
|
||||||
|
height: 0,
|
||||||
|
});
|
||||||
|
const url = '';
|
||||||
|
|
||||||
|
const [curBlockId, setCurBlockId] = useState<string>();
|
||||||
|
const [searchText, setSearchText] = 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={'LINK TO PAGE'} />;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
items.push(
|
||||||
|
...(searchResultBlocks?.map(
|
||||||
|
block =>
|
||||||
|
({
|
||||||
|
block: {
|
||||||
|
...block,
|
||||||
|
content: block.content || 'Untitled',
|
||||||
|
},
|
||||||
|
} as CommonListItem)
|
||||||
|
) || [])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return items;
|
||||||
|
}, [searchResultBlocks]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const text = searchText;
|
||||||
|
QueryBlocks(editor, text, result => {
|
||||||
|
result = result.filter(item => item.id !== curPageId);
|
||||||
|
setSearchResultBlocks(result);
|
||||||
|
});
|
||||||
|
}, [editor, searchText, curPageId]);
|
||||||
|
|
||||||
|
const hideMenu = useCallback(() => {
|
||||||
|
setIsOpen(false);
|
||||||
|
editor.blockHelper.removeDoubleLinkSearchSlash(curBlockId);
|
||||||
|
editor.scrollManager.unLock();
|
||||||
|
}, [curBlockId, editor]);
|
||||||
|
|
||||||
|
const resetState = useCallback(
|
||||||
|
(preNodeId: string, nextNodeId: string) => {
|
||||||
|
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();
|
||||||
|
setLinkMenuStyle({
|
||||||
|
top: rectTop - top,
|
||||||
|
left: clientRect.left - left,
|
||||||
|
height: clientRect.height,
|
||||||
|
});
|
||||||
|
setAnchorEl(dialogRef.current);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[editor]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const showDoubleLink = () => {
|
||||||
|
const { anchorNode } = editor.selection.currentSelectInfo;
|
||||||
|
resetState('', anchorNode.id);
|
||||||
|
};
|
||||||
|
editor.plugins.observe('showAddLink', showDoubleLink);
|
||||||
|
return () => editor.plugins.unobserve('showAddLink', showDoubleLink);
|
||||||
|
}, [editor, resetState]);
|
||||||
|
|
||||||
|
const handleSelected = async (id: string) => {
|
||||||
|
if (curBlockId) {
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFilterChange = useCallback(
|
||||||
|
async (e: ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const text = e.target.value;
|
||||||
|
|
||||||
|
await setSearchText(text);
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
const addLinkUrlToText = () => {
|
||||||
|
const newUrl = inputEl.current.value;
|
||||||
|
if (newUrl && newUrl !== url && isUrl(normalizeUrl(newUrl))) {
|
||||||
|
editor.blockHelper.wrapLink(curBlockId, newUrl);
|
||||||
|
hideMenu();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
addLinkUrlToText();
|
||||||
|
}
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
hideMenu();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={dialogRef}
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
width: '10px',
|
||||||
|
...linkMenuStyle,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isOpen && (
|
||||||
|
<MuiClickAwayListener onClickAway={() => hideMenu()}>
|
||||||
|
<Popper
|
||||||
|
open={isOpen}
|
||||||
|
anchorEl={anchorEl}
|
||||||
|
transition
|
||||||
|
placement="bottom-start"
|
||||||
|
>
|
||||||
|
{({ TransitionProps }) => (
|
||||||
|
<Grow
|
||||||
|
{...TransitionProps}
|
||||||
|
style={{
|
||||||
|
transformOrigin: 'left bottom',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Paper>
|
||||||
|
<LinkModalContainer>
|
||||||
|
<LinkModalContainerIcon>
|
||||||
|
<LinkIcon
|
||||||
|
style={{
|
||||||
|
fontSize: '16px',
|
||||||
|
marginTop: '2px',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</LinkModalContainerIcon>
|
||||||
|
<LinkModalContainerInput
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
placeholder="Paste link url, like https://affine.pro"
|
||||||
|
autoComplete="off"
|
||||||
|
value={searchText}
|
||||||
|
onChange={handleFilterChange}
|
||||||
|
ref={inputEl}
|
||||||
|
/>
|
||||||
|
</LinkModalContainer>
|
||||||
|
|
||||||
|
{menuItems.length > 0 && (
|
||||||
|
<DoubleLinkMenuContainer
|
||||||
|
editor={editor}
|
||||||
|
hooks={hooks}
|
||||||
|
blockId={curBlockId}
|
||||||
|
onSelected={handleSelected}
|
||||||
|
onClose={hideMenu}
|
||||||
|
items={menuItems}
|
||||||
|
types={menuTypes}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Paper>
|
||||||
|
</Grow>
|
||||||
|
)}
|
||||||
|
</Popper>
|
||||||
|
</MuiClickAwayListener>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const LinkModalContainer = styled('div')(({ theme }) => ({
|
||||||
|
display: 'flex',
|
||||||
|
borderRadius: '4px',
|
||||||
|
boxShadow: theme.affine.shadows.shadow1,
|
||||||
|
backgroundColor: '#fff',
|
||||||
|
alignItems: 'center',
|
||||||
|
zIndex: '1',
|
||||||
|
width: '354px',
|
||||||
|
}));
|
||||||
|
|
||||||
|
const LinkModalContainerIcon = styled('div')(({ theme }) => ({
|
||||||
|
display: 'flex',
|
||||||
|
width: '16px',
|
||||||
|
margin: '0 16px 0 4px',
|
||||||
|
color: '#4C6275',
|
||||||
|
}));
|
||||||
|
|
||||||
|
const LinkModalContainerInput = styled('input')(({ theme }) => ({
|
||||||
|
flex: '1',
|
||||||
|
outline: 'none',
|
||||||
|
border: 'none',
|
||||||
|
padding: '8px',
|
||||||
|
fontFamily: 'Helvetica,Arial,"Microsoft Yahei",SimHei,sans-serif',
|
||||||
|
'::-webkit-input-placeholder': {
|
||||||
|
color: '#98acbd',
|
||||||
|
},
|
||||||
|
color: '#4C6275',
|
||||||
|
}));
|
||||||
33
libs/components/editor-plugins/src/menu/link-menu/Plugin.tsx
Normal file
33
libs/components/editor-plugins/src/menu/link-menu/Plugin.tsx
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import { StrictMode } from 'react';
|
||||||
|
import { BasePlugin } from '../../base-plugin';
|
||||||
|
import { PluginRenderRoot } from '../../utils';
|
||||||
|
import { LinkMenu } from './LinkMenu';
|
||||||
|
|
||||||
|
const PLUGIN_NAME = 'reference-menu';
|
||||||
|
|
||||||
|
export class LinkMenuPlugin extends BasePlugin {
|
||||||
|
private _root?: PluginRenderRoot;
|
||||||
|
|
||||||
|
public static override get pluginName(): string {
|
||||||
|
return PLUGIN_NAME;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override _onRender(): void {
|
||||||
|
this._root = new PluginRenderRoot({
|
||||||
|
name: PLUGIN_NAME,
|
||||||
|
render: this.editor.reactRenderRoot.render,
|
||||||
|
});
|
||||||
|
this._root.mount();
|
||||||
|
|
||||||
|
this._root?.render(
|
||||||
|
<StrictMode>
|
||||||
|
<LinkMenu editor={this.editor} hooks={this.hooks} />
|
||||||
|
</StrictMode>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public override dispose() {
|
||||||
|
this._root?.unmount();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { LinkMenuPlugin } from './Plugin';
|
||||||
Reference in New Issue
Block a user