Merge pull request #368 from toeverything/feat/doublelink220820

Feat/doublelink220820
This commit is contained in:
xiaodong zuo 2022-09-06 16:25:39 +08:00 committed by GitHub
commit f3f8fd78de
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
24 changed files with 643 additions and 186 deletions

View File

@ -1,7 +1,5 @@
import { Route, Routes, useParams } from 'react-router';
import { useUserAndSpaces } from '@toeverything/datasource/state';
import { Route, Routes, useParams } from 'react-router';
import { WorkspaceRootContainer } from './Container';
import { Page } from './docs';
import { Edgeless } from './Edgeless';

View File

@ -19,6 +19,7 @@ declare module 'slate' {
}
}
export { default as isUrl } from 'is-url';
export { BlockPreview, StyledBlockPreview } from './block-preview';
export { default as Button } from './button';
export { CollapsibleTitle } from './collapsible-title';

View File

@ -41,7 +41,12 @@ import { InlineDate, withDate } from './plugins/date';
import { DoubleLinkComponent } from './plugins/DoubleLink';
import { LinkComponent, LinkModal, withLinks, wrapLink } from './plugins/link';
import { InlineRefLink } from './plugins/reflink';
import { Contents, isSelectAll, SlateUtils } from './slate-utils';
import {
Contents,
isSelectAll,
SelectionStartAndEnd,
SlateUtils,
} from './slate-utils';
import {
getCommentsIdsOnTextNode,
getExtraPropertiesFromEditorOutmostNode,
@ -88,8 +93,12 @@ export interface TextProps {
/** Backspace event */
handleBackSpace?: ({
isCollAndStart,
splitContents,
selection,
}: {
isCollAndStart: boolean;
splitContents: Contents;
selection: SelectionStartAndEnd;
}) => boolean | undefined | Promise<boolean | undefined>;
/** Whether markdown is supported */
supportMarkdown?: boolean;
@ -464,7 +473,13 @@ export const Text = forwardRef<ExtendedTextUtils, TextProps>((props, ref) => {
if (!isCool) {
hideInlineMenu && hideInlineMenu();
}
preventBindIfNeeded(handleBackSpace)(e, { isCollAndStart });
const selection = utils.current.getSelectionStartAndEnd();
const splitContents = utils.current.getSplitContentsBySelection();
preventBindIfNeeded(handleBackSpace)(e, {
isCollAndStart,
selection,
splitContents,
});
};
const onTab = (e: KeyboardEvent) => {

View File

@ -6,6 +6,7 @@ import { RenderElementProps } from 'slate-react';
export type DoubleLinkElement = {
type: 'link';
linkType: 'doubleLink';
workspaceId: string;
blockId: string;
children: Descendant[];
@ -25,15 +26,20 @@ export const DoubleLinkComponent = (props: RenderElementProps) => {
[doubleLinkElement, navigate]
);
const displayValue = doubleLinkElement.children
.map((item: any) => item.text)
.join('');
return (
<span onClick={handleClickLinkText}>
<a {...attributes} style={{ cursor: 'pointer' }}>
<PagesIcon
style={{ verticalAlign: 'middle', height: '20px' }}
/>
<span>
<PagesIcon style={{ verticalAlign: 'middle', height: '20px' }} />
<a
{...attributes}
style={{ cursor: 'pointer' }}
href={`/${doubleLinkElement.workspaceId}/${doubleLinkElement.blockId}`}
>
<span onClick={handleClickLinkText}>{children}</span>
{children}
{displayValue}
</span>
</a>
</span>
);

View File

@ -1,44 +1,42 @@
import React, {
useEffect,
useMemo,
useRef,
useState,
useCallback,
KeyboardEvent,
MouseEvent,
memo,
} from 'react';
import { createPortal } from 'react-dom';
import { useNavigate } from 'react-router-dom';
import isUrl from 'is-url';
import style9 from 'style9';
import {
Editor,
Transforms,
Element as SlateElement,
Descendant,
Range as SlateRange,
Node,
} from 'slate';
import { ReactEditor } from 'slate-react';
import OpenInNewIcon from '@mui/icons-material/OpenInNew';
import EditIcon from '@mui/icons-material/Edit';
import LinkOffIcon from '@mui/icons-material/LinkOff';
import OpenInNewIcon from '@mui/icons-material/OpenInNew';
import { LinkIcon } from '@toeverything/components/icons';
import {
MuiTooltip as Tooltip,
styled,
muiTooltipClasses,
styled,
type MuiTooltipProps,
} from '@toeverything/components/ui';
import {
getRelativeUrlForInternalPageUrl,
isInternalPageUrl,
} from '@toeverything/utils';
import isUrl from 'is-url';
import React, {
KeyboardEvent,
memo,
MouseEvent,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import { createPortal } from 'react-dom';
import { useNavigate } from 'react-router-dom';
import {
Descendant,
Editor,
Element as SlateElement,
Node,
Range as SlateRange,
Transforms,
} from 'slate';
import { ReactEditor } from 'slate-react';
import style9 from 'style9';
import { getRandomString } from '../utils';
import { colors } from '../../colors';
import { LinkIcon } from '@toeverything/components/icons';
export type LinkElement = {
type: 'link';
url: string;
@ -47,13 +45,20 @@ export type LinkElement = {
};
export const withLinks = (editor: ReactEditor) => {
const { isInline } = editor;
const { isInline, isVoid } = editor;
editor.isInline = element => {
// @ts-ignore
return element.type === 'link' ? true : isInline(element);
};
editor.isVoid = element => {
// @ts-ignore
return element.type === 'link' && element.linkType === 'doubleLink'
? true
: isVoid(element);
};
return editor;
};

View File

@ -2,6 +2,7 @@
import {
Descendant,
Editor,
Element as SlateElement,
Location,
Node as SlateNode,
Path,
@ -405,11 +406,11 @@ Editor.before = function (
if (element) {
if (isInlineAndVoid(editor, element)) {
// Inline entities need to be drilled out
// target = Editor.before(editor, target);
target = {
path: [0, path[1] - 1],
offset: 0,
};
target = Editor.before(editor, target);
// target = {
// path: [0, path[1] - 1],
// offset: 0,
// };
} else if (editor.isInline(element) && !editor.isVoid(element)) {
// Inline styles such as hyperlinks need to drill directly into it
const inlineTextLength = element?.children?.[0]?.text?.length;
@ -503,7 +504,7 @@ Editor.after = function (
return target;
};
type SelectionStartAndEnd = {
export type SelectionStartAndEnd = {
selectionStart: Point;
selectionEnd: Point;
};
@ -517,6 +518,10 @@ export type Contents = {
content: Descendant[];
isEmpty: boolean;
};
contentSelection: {
content: Descendant[];
isEmpty: boolean;
};
};
class SlateUtils {
@ -572,6 +577,7 @@ class SlateUtils {
anchor: point1,
focus: point2,
});
if (!fragment.length) {
console.error('Debug information:', point1, point2, fragment);
throw new Error('Failed to get content between!');
@ -602,7 +608,7 @@ class SlateUtils {
for (let i = 0; i < fragmentChildren.length; i++) {
const child = fragmentChildren[i];
if ('type' in child && child.type === 'link') {
i !== fragmentChildren.length - 1 && textChildren.push(child);
textChildren.push(child);
continue;
}
if (!('text' in child)) {
@ -638,6 +644,10 @@ class SlateUtils {
content: this.getContentBetween(selectionEnd, end),
isEmpty: Point.equals(end, selectionEnd),
},
contentSelection: {
content: this.getContentBetween(selectionStart, selectionEnd),
isEmpty: false,
},
} as Contents;
}
@ -680,10 +690,7 @@ class SlateUtils {
}
public getStart() {
return Editor.start(this.editor, {
path: [0, 0],
offset: 0,
});
return Editor.start(this.editor, [0]);
}
public getEnd() {
@ -713,7 +720,10 @@ class SlateUtils {
if (!this.editor) {
return undefined;
}
const { selectionEnd } = this.getSelectionStartAndEnd();
const selectionEnd = this.getSelectionStartAndEnd()?.selectionEnd;
if (!selectionEnd) {
return undefined;
}
return this.getStringBetween(this.getStart(), selectionEnd);
}
@ -1252,6 +1262,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 */
public getCommentsIdsBySelection() {
const commentedTextNodes = Editor.nodes(this.editor, {

View File

@ -108,7 +108,7 @@ const findLowestCommonAncestor = async (
export const TextManage = forwardRef<ExtendedTextUtils, CreateTextView>(
(props, ref) => {
const { block, editor, ...otherOptions } = props;
const { block, editor, handleEnter, ...otherOptions } = props;
const defaultRef = useRef<ExtendedTextUtils>(null);
// Maybe there is a better way
const textRef =
@ -446,6 +446,38 @@ export const TextManage = forwardRef<ExtendedTextUtils, CreateTextView>(
}
}
};
const onTextEnter: TextProps['handleEnter'] = async props => {
const { splitContents } = props;
if (splitContents) {
const { contentBeforeSelection, contentAfterSelection } =
splitContents;
// after[after.length - 1];
if (!contentBeforeSelection.isEmpty) {
const beforeSelection = contentBeforeSelection.content;
const lastItem: any =
beforeSelection.length > 0
? beforeSelection[beforeSelection.length - 1]
: null;
if (lastItem?.linkType === 'doubleLink') {
contentBeforeSelection.content.push({ text: '' });
}
}
if (!contentAfterSelection.isEmpty) {
const afterSelection = contentAfterSelection.content;
const firstItem: any =
afterSelection.length > 0 ? afterSelection[0] : null;
if (firstItem?.linkType === 'doubleLink') {
contentAfterSelection.content.splice(0, 0, {
text: '',
});
}
}
}
return handleEnter && handleEnter(props);
};
if (!properties || !properties.text) {
return <></>;
}
@ -467,6 +499,7 @@ export const TextManage = forwardRef<ExtendedTextUtils, CreateTextView>(
handleUndo={onUndo}
handleRedo={onRedo}
handleEsc={onKeyboardEsc}
handleEnter={onTextEnter}
{...otherOptions}
/>
);

View File

@ -5,13 +5,14 @@ import type {
} from '@toeverything/components/common';
import {
BaseRange,
Location,
Node,
Path,
Point,
Selection as SlateSelection,
} from 'slate';
import { Editor } from '../editor';
import { AsyncBlock } from '../block';
import { Editor } from '../editor';
import { SelectBlock } from '../selection';
type TextUtilsFunctions =
@ -44,6 +45,7 @@ type TextUtilsFunctions =
| 'setSelection'
| 'insertNodes'
| 'getNodeByPath'
| 'wrapLink'
| 'getNodeByRange'
| 'convertLeaf2Html';
@ -160,7 +162,7 @@ export class BlockHelper {
if (properties.text.value.length === 0) {
return properties;
}
let text_value = properties.text.value;
const text_value = properties.text.value;
const {
text: { value: originTextValue, ...otherTextProperties },
@ -517,4 +519,13 @@ export class BlockHelper {
console.warn('Could find the block text utils');
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;
}
}

View File

@ -13,8 +13,6 @@ import type {
BlockFlavors,
ReturnEditorBlock,
} from '@toeverything/datasource/db-service';
import type { IdList, SelectionInfo, SelectionManager } from './selection';
import { Point } from '@toeverything/utils';
import { Observable } from 'rxjs';
import type { AsyncBlock } from './block';
@ -23,6 +21,7 @@ import type { BlockCommands } from './commands/block-commands';
import type { DragDropManager } from './drag-drop';
import { MouseManager } from './mouse';
import { ScrollManager } from './scroll';
import type { IdList, SelectionInfo, SelectionManager } from './selection';
// import { BrowserClipboard } from './clipboard/browser-clipboard';
@ -114,6 +113,7 @@ export interface Virgo {
getGroupBlockByPoint: (point: Point) => Promise<AsyncBlock>;
isEdgeless: boolean;
mouseManager: MouseManager;
getHooks: () => HooksRunner & PluginHooks;
}
export interface Plugin {

View File

@ -4,7 +4,7 @@ import {
BlockDecoration,
MapOperation,
} from '@toeverything/datasource/jwt';
import type { ComponentType, ReactElement } from 'react';
import { ComponentType, ReactElement } from 'react';
import type { EventData } from '../block';
import { AsyncBlock } from '../block';
import { HTML2BlockResult } from '../clipboard';
@ -108,7 +108,11 @@ export abstract class BaseView {
// Whether the component is empty
isEmpty(block: AsyncBlock): boolean {
const text = block.getProperty('text');
const result = !text?.value?.[0]?.text;
// const result = !text?.value?.[0]?.text;
const result =
text?.value?.findIndex(
(item: any) => item.text || item.children?.length
) === -1;
// Assert that the text is really empty
if (

View File

@ -6,7 +6,7 @@ import {
GroupMenuPlugin,
InlineMenuPlugin,
LeftMenuPlugin,
SelectionGroupPlugin,
LinkMenuPlugin,
} from './menu';
import { FullTextSearchPlugin } from './search';
import { TemplatePlugin } from './template';
@ -24,4 +24,5 @@ export const plugins: PluginCreator[] = [
// SelectionGroupPlugin,
AddCommentPlugin,
GroupMenuPlugin,
LinkMenuPlugin,
];

View File

@ -107,14 +107,14 @@ export const DoubleLinkMenu = ({
icon: AddIcon,
},
});
!inAddNewPage &&
items.push({
content: {
id: ADD_NEW_PAGE,
content: 'Add new page in...',
icon: AddIcon,
},
});
// !inAddNewPage &&
// items.push({
// content: {
// id: ADD_NEW_PAGE,
// content: 'Add new page in...',
// icon: AddIcon,
// },
// });
return items;
}, [searchResultBlocks, inAddNewPage]);
@ -137,6 +137,7 @@ export const DoubleLinkMenu = ({
const resetState = useCallback(
(preNodeId: string, nextNodeId: string) => {
preNodeId &&
editor.blockHelper.removeDoubleLinkSearchSlash(preNodeId);
setCurBlockId(nextNodeId);
setSearchText('');
@ -184,16 +185,19 @@ export const DoubleLinkMenu = ({
}
}
const { type, anchorNode } = editor.selection.currentSelectInfo;
if (!anchorNode) {
return;
}
if (
!isOpen ||
(type === 'Range' &&
anchorNode &&
anchorNode.id !== curBlockId &&
editor.blockHelper.isSelectionCollapsed(anchorNode.id))
) {
const text = editor.blockHelper.getBlockTextBeforeSelection(
const text =
editor.blockHelper.getBlockTextBeforeSelection(
anchorNode.id
);
) || '';
if (text.endsWith('[[')) {
resetState(curBlockId, anchorNode.id);
}
@ -206,7 +210,7 @@ export const DoubleLinkMenu = ({
}
}
},
[editor, isOpen, curBlockId, hideMenu]
[editor, isOpen, curBlockId, hideMenu, resetState]
);
const handleKeyup = useCallback(
@ -251,6 +255,20 @@ export const DoubleLinkMenu = ({
};
}, [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);
@ -328,6 +346,7 @@ export const DoubleLinkMenu = ({
...doubleLinkMenuStyle,
}}
>
{isOpen && (
<MuiClickAwayListener onClickAway={() => hideMenu()}>
<Popper
open={isOpen}
@ -368,6 +387,7 @@ export const DoubleLinkMenu = ({
)}
</Popper>
</MuiClickAwayListener>
)}
</div>
);
};

View File

@ -3,7 +3,7 @@ import { BasePlugin } from '../../base-plugin';
import { PluginRenderRoot } from '../../utils';
import { DoubleLinkMenu } from './DoubleLinkMenu';
const PLUGIN_NAME = 'reference-menu';
const PLUGIN_NAME = 'doublelink-menu';
export class DoubleLinkMenuPlugin extends BasePlugin {
private _root?: PluginRenderRoot;

View File

@ -4,4 +4,5 @@ export { GroupMenuPlugin } from './group-menu';
export { InlineMenuPlugin } from './inline-menu';
export { LeftMenuPlugin } from './left-menu/LeftMenuPlugin';
export { MENU_WIDTH as menuWidth } from './left-menu/menu-config';
export { LinkMenuPlugin } from './link-menu';
export { SelectionGroupPlugin } from './selection-group-menu';

View File

@ -1,14 +1,10 @@
import React, { useCallback } from 'react';
import style9 from 'style9';
import { Tooltip } from '@toeverything/components/ui';
import { uaHelper } from '@toeverything/utils';
import {
inlineMenuNamesKeys,
MacInlineMenuShortcuts,
WinInlineMenuShortcuts,
} from '../config';
import React, { useCallback } from 'react';
import style9 from 'style9';
import { inlineMenuNamesKeys, WinInlineMenuShortcuts } from '../config';
import type { IconItemType, WithEditorSelectionType } from '../types';
type MenuIconItemProps = IconItemType & WithEditorSelectionType;
export const MenuIconItem = ({
@ -27,6 +23,7 @@ export const MenuIconItem = ({
editor,
type: nameKey,
anchorNodeId: selectionInfo?.anchorNode?.id,
setShow,
});
}
if ([inlineMenuNamesKeys.comment].includes(nameKey)) {

View File

@ -1,5 +1,5 @@
import type { SvgIconProps } from '@toeverything/components/ui';
import type { Virgo, SelectionInfo } from '@toeverything/framework/virgo';
import type { SelectionInfo, Virgo } from '@toeverything/framework/virgo';
import { inlineMenuNames, INLINE_MENU_UI_TYPES } from './config';
export type WithEditorSelectionType = {
@ -14,10 +14,12 @@ export type ClickItemHandler = ({
type,
editor,
anchorNodeId,
setShow,
}: {
type: InlineMenuNamesType;
editor: Virgo;
anchorNodeId: string;
setShow?: React.Dispatch<React.SetStateAction<boolean>>;
}) => void;
export type IconItemType = {

View File

@ -107,6 +107,7 @@ const common_handler_for_inline_menu: ClickItemHandler = ({
editor,
anchorNodeId,
type,
setShow,
}) => {
switch (type) {
case inlineMenuNamesKeys.text:
@ -184,6 +185,8 @@ const common_handler_for_inline_menu: ClickItemHandler = ({
editor,
blockId: anchorNodeId,
});
// editor.plugins.emit('showAddLink');
// setShow(false);
break;
case inlineMenuNamesKeys.code:
toggle_text_format({
@ -438,6 +441,10 @@ const common_handler_for_inline_menu: ClickItemHandler = ({
blockType: Protocol.Block.Type.file,
});
break;
case inlineMenuNamesKeys.backlinks:
editor.plugins.emit('showDoubleLink');
setShow(false);
break;
default: // do nothing
}
};

View File

@ -0,0 +1,259 @@
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 showLinkMenu = () => {
const { anchorNode } = editor.selection.currentSelectInfo;
resetState('', anchorNode.id);
};
editor.plugins.observe('showAddLink', showLinkMenu);
return () => editor.plugins.unobserve('showAddLink', showLinkMenu);
}, [editor, resetState]);
const handleSelected = async (id: string) => {};
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"
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',
}));
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',
}));

View 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();
}
}

View File

@ -0,0 +1 @@
export { LinkMenuPlugin } from './Plugin';

View File

@ -5,7 +5,7 @@ import {
styled,
TransitionsModal,
} from '@toeverything/components/ui';
import { BlockEditor, Virgo } from '@toeverything/framework/virgo';
import { BlockEditor, PluginHooks, Virgo } from '@toeverything/framework/virgo';
import { throttle } from '@toeverything/utils';
import { useCallback, useEffect, useState } from 'react';
import { useNavigate, useParams } from 'react-router';
@ -31,7 +31,7 @@ const styles = style9.create({
export type QueryResult = Awaited<ReturnType<BlockEditor['search']>>;
const query_blocks = (
const queryBlocksExec = (
editor: Virgo,
search: string,
callback: (result: QueryResult) => void
@ -39,45 +39,58 @@ const query_blocks = (
(editor as BlockEditor).search(search).then(pages => callback(pages));
};
export const QueryBlocks = throttle(query_blocks, 500);
export const QueryBlocks = throttle(queryBlocksExec, 500);
type SearchProps = {
onClose: () => void;
editor: Virgo;
hooks: PluginHooks;
};
export const Search = (props: SearchProps) => {
const { workspace_id } = useParams();
const { workspace_id: workspaceId } = useParams();
const navigate = useNavigate();
const [open, set_open] = useState(true);
const [search, set_search] = useState('');
const [result, set_result] = useState<QueryResult>([]);
const [open, setOpen] = useState(false);
const [search, setSearch] = useState<string>();
const [result, setResult] = useState<QueryResult>([]);
useEffect(() => {
search !== undefined &&
QueryBlocks(props.editor, search, result => {
set_result(result);
setResult(result);
});
}, [props.editor, search]);
const handle_navigate = useCallback(
(id: string) => navigate(`/${workspace_id}/${id}`),
[navigate, workspace_id]
const handleNavigate = useCallback(
(id: string) => navigate(`/${workspaceId}/${id}`),
[navigate, workspaceId]
);
const handleSearch = useCallback(() => {
setOpen(true);
setSearch('');
}, []);
// useEffect(() => {
// const sub = props.hooks.get(HookType.ON_SEARCH).subscribe(handleSearch);
// return () => {
// sub.unsubscribe();
// };
// }, [props, handleSearch]);
return (
<TransitionsModal
open={open}
onClose={() => {
set_open(false);
props.onClose();
setOpen(false);
}}
>
<Box className={styles('wrapper')}>
<SearchInput
autoFocus
value={search}
onChange={e => set_search(e.target.value)}
onChange={e => setSearch(e.target.value)}
/>
<ResultContainer
sx={{ maxHeight: `${result.length * 28 + 32 + 20}px` }}
@ -89,10 +102,12 @@ export const Search = (props: SearchProps) => {
<BlockPreview
className={styles('resultItem')}
key={block.id}
block={block}
block={{
...block,
content: block.content || 'Untitled',
}}
onClick={() => {
handle_navigate(block.id);
props.onClose();
handleNavigate(block.id);
}}
/>
))}

View File

@ -1,59 +1,40 @@
import { StrictMode } from 'react';
import { HookType } from '@toeverything/framework/virgo';
import { BasePlugin } from '../base-plugin';
import { PluginRenderRoot } from '../utils';
import { Search } from './Search';
export class FullTextSearchPlugin extends BasePlugin {
#root?: PluginRenderRoot;
private root?: PluginRenderRoot;
public static override get pluginName(): string {
return 'search';
}
public override init(): void {
this.sub.add(
this.hooks.get(HookType.ON_SEARCH).subscribe(this._handleSearch)
);
}
protected override _onRender(): void {
this.#root = new PluginRenderRoot({
this.root = new PluginRenderRoot({
name: FullTextSearchPlugin.pluginName,
render: this.editor.reactRenderRoot.render,
});
this._renderSearch();
}
private unmount() {
if (this.#root) {
this.editor.setHotKeysScope();
this.#root.unmount();
// this.#root = undefined;
}
this.sub.unsubscribe();
}
private _handleSearch = () => {
this.editor.setHotKeysScope('search');
this.render_search();
};
private render_search() {
if (this.#root) {
this.#root.mount();
this.#root.render(
private _renderSearch() {
if (this.root) {
this.root.mount();
this.root.render(
<StrictMode>
<Search
onClose={() => this.unmount()}
editor={this.editor}
/>
<Search editor={this.editor} hooks={this.hooks} />
</StrictMode>
);
}
}
public renderSearch() {
this.render_search();
this._renderSearch();
}
public override dispose() {
this.root?.unmount();
super.dispose();
}
}

View File

@ -1,5 +1,3 @@
import { useMemo } from 'react';
import {
LogoIcon,
SearchIcon,
@ -8,10 +6,11 @@ import {
} from '@toeverything/components/icons';
import { IconButton, styled } from '@toeverything/components/ui';
import {
useCurrentEditors,
useLocalTrigger,
useShowSettingsSidebar,
} from '@toeverything/datasource/state';
import { useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { EditorBoardSwitcher } from './EditorBoardSwitcher';
import { fsApiSupported } from './FileSystem';
@ -33,6 +32,14 @@ export const LayoutHeader = () => {
}
}, [isLocalWorkspace, t]);
const { currentEditors } = useCurrentEditors();
const handleSearch = useCallback(() => {
for (const key in currentEditors || {}) {
currentEditors[key].getHooks().onSearch();
}
}, [currentEditors]);
return (
<StyledContainerForHeaderRoot>
<StyledHeaderRoot>
@ -49,8 +56,8 @@ export const LayoutHeader = () => {
<IconButton
size="large"
hoverColor={'transparent'}
onClick={handleSearch}
disabled={true}
style={{ cursor: 'not-allowed' }}
>
<SearchIcon />
</IconButton>

View File

@ -3,7 +3,6 @@ import * as encoding from 'lib0/encoding';
import * as awarenessProtocol from 'y-protocols/awareness';
import * as syncProtocol from 'y-protocols/sync';
import * as Y from 'yjs';
import { Message } from './handler';
import { readMessage } from './processor';
import { WebsocketProvider } from './provider';
@ -14,7 +13,7 @@ export const registerBroadcastSubscriber = (
document: Y.Doc
) => {
const channel = provider.broadcastChannel;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const subscriber = (data: ArrayBuffer, origin: any) => {
if (origin !== provider) {
const encoder = readMessage(provider, new Uint8Array(data), false);