diff --git a/apps/ligo-virgo/src/pages/workspace/WorkspaceContainer.tsx b/apps/ligo-virgo/src/pages/workspace/WorkspaceContainer.tsx index 8e83c601a0..d1b3f3444f 100644 --- a/apps/ligo-virgo/src/pages/workspace/WorkspaceContainer.tsx +++ b/apps/ligo-virgo/src/pages/workspace/WorkspaceContainer.tsx @@ -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'; diff --git a/libs/components/common/src/lib/index.ts b/libs/components/common/src/lib/index.ts index f42927223e..cc94e2a4b4 100644 --- a/libs/components/common/src/lib/index.ts +++ b/libs/components/common/src/lib/index.ts @@ -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'; diff --git a/libs/components/common/src/lib/text/EditableText.tsx b/libs/components/common/src/lib/text/EditableText.tsx index b2b2229fec..87c111544a 100644 --- a/libs/components/common/src/lib/text/EditableText.tsx +++ b/libs/components/common/src/lib/text/EditableText.tsx @@ -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; /** Whether markdown is supported */ supportMarkdown?: boolean; @@ -464,7 +473,13 @@ export const Text = forwardRef((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) => { diff --git a/libs/components/common/src/lib/text/plugins/DoubleLink.tsx b/libs/components/common/src/lib/text/plugins/DoubleLink.tsx index e4bcb9bec2..42f50740f3 100644 --- a/libs/components/common/src/lib/text/plugins/DoubleLink.tsx +++ b/libs/components/common/src/lib/text/plugins/DoubleLink.tsx @@ -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 ( - - - - {children} + + + + + {children} + {displayValue} + ); diff --git a/libs/components/common/src/lib/text/plugins/link.tsx b/libs/components/common/src/lib/text/plugins/link.tsx index 619239c64d..971d8dc62b 100644 --- a/libs/components/common/src/lib/text/plugins/link.tsx +++ b/libs/components/common/src/lib/text/plugins/link.tsx @@ -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; }; diff --git a/libs/components/common/src/lib/text/slate-utils.ts b/libs/components/common/src/lib/text/slate-utils.ts index f44d573e64..2f7684b547 100644 --- a/libs/components/common/src/lib/text/slate-utils.ts +++ b/libs/components/common/src/lib/text/slate-utils.ts @@ -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, { diff --git a/libs/components/editor-blocks/src/components/text-manage/TextManage.tsx b/libs/components/editor-blocks/src/components/text-manage/TextManage.tsx index e42a85c5ed..72faaef8a7 100644 --- a/libs/components/editor-blocks/src/components/text-manage/TextManage.tsx +++ b/libs/components/editor-blocks/src/components/text-manage/TextManage.tsx @@ -108,7 +108,7 @@ const findLowestCommonAncestor = async ( export const TextManage = forwardRef( (props, ref) => { - const { block, editor, ...otherOptions } = props; + const { block, editor, handleEnter, ...otherOptions } = props; const defaultRef = useRef(null); // Maybe there is a better way const textRef = @@ -446,6 +446,38 @@ export const TextManage = forwardRef( } } }; + + 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( handleUndo={onUndo} handleRedo={onRedo} handleEsc={onKeyboardEsc} + handleEnter={onTextEnter} {...otherOptions} /> ); diff --git a/libs/components/editor-core/src/editor/block/block-helper.ts b/libs/components/editor-core/src/editor/block/block-helper.ts index b9e7c12af9..9e3090b527 100644 --- a/libs/components/editor-core/src/editor/block/block-helper.ts +++ b/libs/components/editor-core/src/editor/block/block-helper.ts @@ -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; + } } diff --git a/libs/components/editor-core/src/editor/types.ts b/libs/components/editor-core/src/editor/types.ts index 86c795b615..9fe247fc08 100644 --- a/libs/components/editor-core/src/editor/types.ts +++ b/libs/components/editor-core/src/editor/types.ts @@ -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; isEdgeless: boolean; mouseManager: MouseManager; + getHooks: () => HooksRunner & PluginHooks; } export interface Plugin { diff --git a/libs/components/editor-core/src/editor/views/base-view.ts b/libs/components/editor-core/src/editor/views/base-view.ts index d4867f2b59..87bd55ebbc 100644 --- a/libs/components/editor-core/src/editor/views/base-view.ts +++ b/libs/components/editor-core/src/editor/views/base-view.ts @@ -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 ( diff --git a/libs/components/editor-plugins/src/index.ts b/libs/components/editor-plugins/src/index.ts index 4c6ce9a1e1..568031f5a3 100644 --- a/libs/components/editor-plugins/src/index.ts +++ b/libs/components/editor-plugins/src/index.ts @@ -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, ]; diff --git a/libs/components/editor-plugins/src/menu/double-link-menu/DoubleLinkMenu.tsx b/libs/components/editor-plugins/src/menu/double-link-menu/DoubleLinkMenu.tsx index a81e8b3122..90f5e10c43 100644 --- a/libs/components/editor-plugins/src/menu/double-link-menu/DoubleLinkMenu.tsx +++ b/libs/components/editor-plugins/src/menu/double-link-menu/DoubleLinkMenu.tsx @@ -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,7 +137,8 @@ export const DoubleLinkMenu = ({ const resetState = useCallback( (preNodeId: string, nextNodeId: string) => { - editor.blockHelper.removeDoubleLinkSearchSlash(preNodeId); + preNodeId && + editor.blockHelper.removeDoubleLinkSearchSlash(preNodeId); setCurBlockId(nextNodeId); setSearchText(''); setIsOpen(true); @@ -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( - anchorNode.id - ); + 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,46 +346,48 @@ export const DoubleLinkMenu = ({ ...doubleLinkMenuStyle, }} > - hideMenu()}> - - {({ TransitionProps }) => ( - - - {inAddNewPage && ( - - - - )} - - - - )} - - + {isOpen && ( + hideMenu()}> + + {({ TransitionProps }) => ( + + + {inAddNewPage && ( + + + + )} + + + + )} + + + )} ); }; diff --git a/libs/components/editor-plugins/src/menu/double-link-menu/Plugin.tsx b/libs/components/editor-plugins/src/menu/double-link-menu/Plugin.tsx index 3933bf5f1c..cb1a6e29df 100644 --- a/libs/components/editor-plugins/src/menu/double-link-menu/Plugin.tsx +++ b/libs/components/editor-plugins/src/menu/double-link-menu/Plugin.tsx @@ -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; diff --git a/libs/components/editor-plugins/src/menu/index.ts b/libs/components/editor-plugins/src/menu/index.ts index 190e3a5109..5c67719c87 100644 --- a/libs/components/editor-plugins/src/menu/index.ts +++ b/libs/components/editor-plugins/src/menu/index.ts @@ -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'; diff --git a/libs/components/editor-plugins/src/menu/inline-menu/menu-item/IconItem.tsx b/libs/components/editor-plugins/src/menu/inline-menu/menu-item/IconItem.tsx index c49efd82a9..bd3a13bf8d 100644 --- a/libs/components/editor-plugins/src/menu/inline-menu/menu-item/IconItem.tsx +++ b/libs/components/editor-plugins/src/menu/inline-menu/menu-item/IconItem.tsx @@ -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)) { diff --git a/libs/components/editor-plugins/src/menu/inline-menu/types.ts b/libs/components/editor-plugins/src/menu/inline-menu/types.ts index bdd7bee447..124c7a03ee 100644 --- a/libs/components/editor-plugins/src/menu/inline-menu/types.ts +++ b/libs/components/editor-plugins/src/menu/inline-menu/types.ts @@ -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>; }) => void; export type IconItemType = { diff --git a/libs/components/editor-plugins/src/menu/inline-menu/utils.ts b/libs/components/editor-plugins/src/menu/inline-menu/utils.ts index 78f03fbcf9..bb35d31f5a 100644 --- a/libs/components/editor-plugins/src/menu/inline-menu/utils.ts +++ b/libs/components/editor-plugins/src/menu/inline-menu/utils.ts @@ -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 } }; diff --git a/libs/components/editor-plugins/src/menu/link-menu/LinkMenu.tsx b/libs/components/editor-plugins/src/menu/link-menu/LinkMenu.tsx new file mode 100644 index 0000000000..ddd178e449 --- /dev/null +++ b/libs/components/editor-plugins/src/menu/link-menu/LinkMenu.tsx @@ -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(); + const inputEl = useRef(); + const [linkMenuStyle, setLinkMenuStyle] = useState({ + left: 0, + top: 0, + height: 0, + }); + const url = ''; + + const [curBlockId, setCurBlockId] = useState(); + const [searchText, setSearchText] = useState(); + const [searchResultBlocks, setSearchResultBlocks] = useState( + [] + ); + + 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 ; + }, + }); + 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) => { + 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) => { + if (e.key === 'Enter') { + addLinkUrlToText(); + } + if (e.key === 'Escape') { + hideMenu(); + } + }; + + return ( +
+ {isOpen && ( + hideMenu()}> + + {({ TransitionProps }) => ( + + + + + + + + + + {menuItems.length > 0 && ( + + )} + + + )} + + + )} +
+ ); +}; + +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', +})); diff --git a/libs/components/editor-plugins/src/menu/link-menu/Plugin.tsx b/libs/components/editor-plugins/src/menu/link-menu/Plugin.tsx new file mode 100644 index 0000000000..e0f7140985 --- /dev/null +++ b/libs/components/editor-plugins/src/menu/link-menu/Plugin.tsx @@ -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( + + + + ); + } + + public override dispose() { + this._root?.unmount(); + super.dispose(); + } +} diff --git a/libs/components/editor-plugins/src/menu/link-menu/index.ts b/libs/components/editor-plugins/src/menu/link-menu/index.ts new file mode 100644 index 0000000000..1b3746630c --- /dev/null +++ b/libs/components/editor-plugins/src/menu/link-menu/index.ts @@ -0,0 +1 @@ +export { LinkMenuPlugin } from './Plugin'; diff --git a/libs/components/editor-plugins/src/search/Search.tsx b/libs/components/editor-plugins/src/search/Search.tsx index b689345f64..6092862416 100644 --- a/libs/components/editor-plugins/src/search/Search.tsx +++ b/libs/components/editor-plugins/src/search/Search.tsx @@ -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>; -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([]); + const [open, setOpen] = useState(false); + const [search, setSearch] = useState(); + const [result, setResult] = useState([]); useEffect(() => { - QueryBlocks(props.editor, search, result => { - set_result(result); - }); + search !== undefined && + QueryBlocks(props.editor, search, 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 ( { - set_open(false); - props.onClose(); + setOpen(false); }} > set_search(e.target.value)} + onChange={e => setSearch(e.target.value)} /> { { - handle_navigate(block.id); - props.onClose(); + handleNavigate(block.id); }} /> ))} diff --git a/libs/components/editor-plugins/src/search/index.tsx b/libs/components/editor-plugins/src/search/index.tsx index 41a13695c4..b74e3572a8 100644 --- a/libs/components/editor-plugins/src/search/index.tsx +++ b/libs/components/editor-plugins/src/search/index.tsx @@ -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( - this.unmount()} - editor={this.editor} - /> + ); } } public renderSearch() { - this.render_search(); + this._renderSearch(); + } + + public override dispose() { + this.root?.unmount(); + super.dispose(); } } diff --git a/libs/components/layout/src/header/LayoutHeader.tsx b/libs/components/layout/src/header/LayoutHeader.tsx index c9e7b7b8da..9b104b4442 100644 --- a/libs/components/layout/src/header/LayoutHeader.tsx +++ b/libs/components/layout/src/header/LayoutHeader.tsx @@ -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 ( @@ -49,8 +56,8 @@ export const LayoutHeader = () => { diff --git a/libs/datasource/jwt-rpc/src/broadcast.ts b/libs/datasource/jwt-rpc/src/broadcast.ts index b0f344aad7..59c974bf0c 100644 --- a/libs/datasource/jwt-rpc/src/broadcast.ts +++ b/libs/datasource/jwt-rpc/src/broadcast.ts @@ -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);