diff --git a/libs/components/common/src/lib/list/index.tsx b/libs/components/common/src/lib/list/index.tsx index 8f57a1d871..7de7e35b8e 100644 --- a/libs/components/common/src/lib/list/index.tsx +++ b/libs/components/common/src/lib/list/index.tsx @@ -1,8 +1,4 @@ -import { useState } from 'react'; -import { useNavigate } from 'react-router'; -import clsx from 'clsx'; -import style9 from 'style9'; - +import { BackwardUndoIcon } from '@toeverything/components/icons'; import { BaseButton, ListButton, @@ -12,9 +8,11 @@ import { } from '@toeverything/components/ui'; // eslint-disable-next-line @nrwl/nx/enforce-module-boundaries import { BlockSearchItem } from '@toeverything/datasource/jwt'; - +import clsx from 'clsx'; +import { useState } from 'react'; +import { useNavigate } from 'react-router'; +import style9 from 'style9'; import { BlockPreview } from '../block-preview'; -import { BackwardUndoIcon } from '@toeverything/components/icons'; export const commonListContainer = 'commonListContainer'; @@ -28,6 +26,7 @@ export type CommonListItem = { divider?: string; content?: Content; block?: BlockSearchItem; + renderCustom?: (props: CommonListItem) => JSX.Element; }; type MenuItemsProps = { @@ -45,7 +44,7 @@ export const CommonList = (props: MenuItemsProps) => { // ]); // TODO Insert bidirectional link to be developed const JSONUnsupportedBlockTypes = ['page']; - let usedItems = items.filter(item => { + const usedItems = items.filter(item => { return !JSONUnsupportedBlockTypes.includes(item?.content?.id); }); return ( diff --git a/libs/components/common/src/lib/text/EditableText.tsx b/libs/components/common/src/lib/text/EditableText.tsx index 3c9ca6fe5c..7bc6c3904a 100644 --- a/libs/components/common/src/lib/text/EditableText.tsx +++ b/libs/components/common/src/lib/text/EditableText.tsx @@ -1,54 +1,54 @@ /* eslint-disable max-lines */ +import { SearchIcon } from '@toeverything/components/icons'; +import { ErrorBoundary, isEqual } from '@toeverything/utils'; +import isHotkey from 'is-hotkey'; +import isUrl from 'is-url'; import React, { + CSSProperties, + DragEvent, + forwardRef, KeyboardEvent, KeyboardEventHandler, + MouseEvent, + MouseEventHandler, useCallback, useEffect, + useLayoutEffect, useMemo, useRef, useState, - forwardRef, - MouseEventHandler, - useLayoutEffect, - CSSProperties, - MouseEvent, - DragEvent, } from 'react'; -import isHotkey from 'is-hotkey'; import { createEditor, Descendant, - Range, - Element as SlateElement, Editor, - Transforms, + Element as SlateElement, Node, Path, + Range, + Transforms, } from 'slate'; import { Editable, - withReact, - Slate, ReactEditor, + Slate, useSlateStatic, + withReact, } from 'slate-react'; - -import { ErrorBoundary, isEqual, uaHelper } from '@toeverything/utils'; - -import { Contents, SlateUtils, isSelectAll } from './slate-utils'; +import { CustomElement } from '..'; +import { HOTKEYS, INLINE_STYLES } from './constants'; +import { TextWithComments } from './element-leaf/TextWithComments'; +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 { getCommentsIdsOnTextNode, getExtraPropertiesFromEditorOutmostNode, isInterceptCharacter, matchMarkdown, } from './utils'; -import { HOTKEYS, INLINE_STYLES } from './constants'; -import { LinkComponent, LinkModal, withLinks, wrapLink } from './plugins/link'; -import { withDate, InlineDate } from './plugins/date'; -import { CustomElement } from '..'; -import isUrl from 'is-url'; -import { InlineRefLink } from './plugins/reflink'; -import { TextWithComments } from './element-leaf/TextWithComments'; export interface TextProps { /** read only */ @@ -739,6 +739,9 @@ const EditorElement = (props: any) => { switch (element.type) { case 'link': { + if (element.linkType === 'doubleLink') { + return ; + } return ( { } } + if (leaf.doubleLinkSearch) { + customChildren = ( + + + {customChildren} + + ); + } + customChildren = ( {customChildren} diff --git a/libs/components/common/src/lib/text/plugins/DoubleLink.tsx b/libs/components/common/src/lib/text/plugins/DoubleLink.tsx new file mode 100644 index 0000000000..8160731bf2 --- /dev/null +++ b/libs/components/common/src/lib/text/plugins/DoubleLink.tsx @@ -0,0 +1,29 @@ +import { PagesIcon } from '@toeverything/components/icons'; +import React, { useCallback } from 'react'; +import { useNavigate } from 'react-router-dom'; + +export const DoubleLinkComponent = ({ attributes, children, element }: any) => { + const navigate = useNavigate(); + + const handleClickLinkText = useCallback( + (event: React.MouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + const { workspaceId, blockId } = element; + navigate(`/${workspaceId}/${blockId}`); + }, + [element, navigate] + ); + + return ( + + + + {children} + + + ); +}; diff --git a/libs/components/common/src/lib/text/slate-utils.ts b/libs/components/common/src/lib/text/slate-utils.ts index ec7e219810..9b1fff664c 100644 --- a/libs/components/common/src/lib/text/slate-utils.ts +++ b/libs/components/common/src/lib/text/slate-utils.ts @@ -11,7 +11,6 @@ import { Transforms, } from 'slate'; import { ReactEditor } from 'slate-react'; - import { fontBgColorPalette, fontColorPalette, @@ -21,6 +20,7 @@ import { import { getCommentsIdsOnTextNode, getEditorMarkForCommentId, + getRandomString, MARKDOWN_STYLE_MAP, MatchRes, } from './utils'; @@ -246,7 +246,11 @@ Editor.insertText = function ( const { path, offset } = at; if (text.length > 0) { const marks = Editor.marks(editor); - if (text === '\u0020' && Object.keys(marks).length) { + if ( + text === '\u0020' && + Object.keys(marks).length && + !(marks as any).doubleLinkSearch + ) { // If the input is a space and the mark has content, remove the mark const newPath = [path[0], path[1] + 1]; const newPoint = { @@ -1084,34 +1088,130 @@ class SlateUtils { }); } - public insertReference(reference: string) { - try { - // Transforms.setSelection(this.editor, this.getEndSelection()); - const { anchor, focus } = this.getEndSelection(); - Transforms.insertNodes( + public setDoubleLinkSearchSlash(point: Point) { + const str = Editor.string(this.editor, { + anchor: this.getStart(), + focus: point, + }); + if (str.endsWith('[[')) { + Transforms.select(this.editor, { + anchor: point, + focus: Object.assign({}, point, { + offset: point.offset - 2, + }), + }); + Editor.addMark(this.editor, 'doubleLinkSearch', true); + Transforms.select(this.editor, { + anchor: this.editor.selection.anchor, + focus: this.editor.selection.anchor, + }); + } + } + + public getDoubleLinkSearchSlashText() { + const nodes = Editor.nodes(this.editor, { + at: [], + //@ts-ignore + match: node => !!node.doubleLinkSearch, + }); + const searchNode = nodes.next().value; + if (searchNode && (searchNode[0] as { text?: string }).text) { + return (searchNode[0] as { text?: string }).text; + } + return ''; + } + + public setSelectDoubleLinkSearchSlash() { + const nodes = Editor.nodes(this.editor, { + at: [], + //@ts-ignore + match: node => !!node.doubleLinkSearch, + }); + const searchNode = nodes.next().value; + if (searchNode) { + const text = (searchNode[0] as { text?: string })?.text || ''; + const path = searchNode[1]; + const anchor = Editor.before( this.editor, { - type: 'reflink', - reference, - children: [], + path, + offset: 1, }, - { at: focus || anchor } + { + unit: 'offset', + } ); - - // requestAnimationFrame(() => { - // console.log(this.editor.selection, this.editor.insertNode); - // this.editor.insertNode({ - // type: 'reflink', - // reference, - // children: [{ text: '' }] - // }); - // // Transforms.select(); - // }); - } catch (e) { - console.log(e); + const focus = Editor.after( + this.editor, + { + path, + offset: text.length - 1, + }, + { + unit: 'offset', + } + ); + Transforms.select(this.editor, { anchor, focus }); } } + public removeDoubleLinkSearchSlash(isRemoveSlash?: boolean) { + if (isRemoveSlash) { + const nodes = Editor.nodes(this.editor, { + at: [], + //@ts-ignore + match: node => !!node.doubleLinkSearch, + }); + const searchNode = nodes.next().value; + if (searchNode) { + const text = (searchNode[0] as { text?: string })?.text || ''; + if (text.startsWith('[[')) { + const path = searchNode[1]; + Transforms.delete(this.editor, { + at: { + path, + offset: 0, + }, + distance: text.length, + unit: 'character', + }); + } + } + } + Transforms.setNodes( + this.editor, + { doubleLinkSearch: null } as Partial, + { + at: [], + match: node => { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + //@ts-ignore + return !!node.doubleLinkSearch; + }, + } + ); + this.editor.removeMark('doubleLinkSearch'); + } + + public insertDoubleLink( + workspaceId: string, + linkBlockId: string, + children: any[] + ) { + const link = { + type: 'link', + linkType: 'doubleLink', + workspaceId: workspaceId, + blockId: linkBlockId, + children: children, + id: getRandomString('link'), + }; + Transforms.insertNodes(this.editor, link); + requestAnimationFrame(() => { + ReactEditor.focus(this.editor); + }); + } + /** todo improve if selection is collapsed */ public getCommentsIdsBySelection() { const commentedTextNodes = Editor.nodes(this.editor, { diff --git a/libs/components/editor-core/src/editor/block/async-block.ts b/libs/components/editor-core/src/editor/block/async-block.ts index df2f6d972c..9eb156852e 100644 --- a/libs/components/editor-core/src/editor/block/async-block.ts +++ b/libs/components/editor-core/src/editor/block/async-block.ts @@ -1,19 +1,19 @@ /* eslint-disable max-lines */ -import EventEmitter from 'eventemitter3'; import { - ReturnEditorBlock, - UpdateEditorBlock, DefaultColumnsValue, Protocol, + ReturnEditorBlock, + UpdateEditorBlock, } from '@toeverything/datasource/db-service'; import { - isDev, createNoopWithMessage, - lowerFirst, + isDev, last, + lowerFirst, } from '@toeverything/utils'; -import { BlockProvider } from './block-provider'; +import EventEmitter from 'eventemitter3'; import { BaseView, BaseView as BlockView } from './../views/base-view'; +import { BlockProvider } from './block-provider'; type EventType = 'update'; export interface EventData { @@ -154,6 +154,7 @@ export class AsyncBlock { } this.initialized = true; this.raw_data = await this.filterPageInvalidChildren(this.raw_data); + this.raw_data = await this.updateDoubleLinkBlock(this.raw_data); const { workspace, id } = this.raw_data; this.unobserve = await this.services.observe( { workspace, id }, @@ -161,6 +162,7 @@ export class AsyncBlock { const oldData = this.raw_data; this.raw_data = blockData; this.raw_data = await this.filterPageInvalidChildren(blockData); + this.raw_data = await this.updateDoubleLinkBlock(this.raw_data); this.emit('update', { block: this, oldData }); } ); @@ -495,4 +497,35 @@ export class AsyncBlock { getBoundingClientRect() { return this.dom?.getBoundingClientRect(); } + + async updateDoubleLinkBlock(rawData: ReturnEditorBlock) { + const values = rawData.properties?.text?.value || []; + for (let i = 0; i < values.length; i++) { + const item = values[i] as any; + if (item.linkType === 'doubleLink') { + const linkBlock = await this.services.load({ + workspace: item.workspaceId, + id: item.blockId, + }); + + if (linkBlock) { + let children = linkBlock.getProperties().text?.value || []; + if (children.length === 1 && !children[0].text) { + children = [{ text: 'Untitled' }]; + } + if ( + children.map(v => v.text).join('') !== + (item.children || []).map((v: any) => v.text).join('') + ) { + const newItem = { + ...item, + children: children, + }; + values.splice(i, 1, newItem); + } + } + } + } + return rawData; + } } 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 7bd0284ffc..012633b813 100644 --- a/libs/components/editor-core/src/editor/block/block-helper.ts +++ b/libs/components/editor-core/src/editor/block/block-helper.ts @@ -19,6 +19,11 @@ type TextUtilsFunctions = | 'setSearchSlash' | 'removeSearchSlash' | 'getSearchSlashText' + | 'setDoubleLinkSearchSlash' + | 'getDoubleLinkSearchSlashText' + | 'setSelectDoubleLinkSearchSlash' + | 'removeDoubleLinkSearchSlash' + | 'insertDoubleLink' | 'selectionToSlateRange' | 'transformPoint' | 'toggleTextFormatBySelection' @@ -32,7 +37,6 @@ type TextUtilsFunctions = | 'getCommentsIdsBySelection' | 'getCurrentSelection' | 'removeSelection' - | 'insertReference' | 'isCollapsed' | 'blur' | 'setSelection' @@ -149,27 +153,60 @@ export class BlockHelper { } } - public insertReference( - reference: string, + public setDoubleLinkSearchSlash(blockId: string, point: Point) { + const textUtils = this._blockTextUtilsMap[blockId]; + if (textUtils) { + textUtils.setDoubleLinkSearchSlash(point); + } else { + console.warn('Could find the block text utils'); + } + } + + public getDoubleLinkSearchSlashText(blockId: string) { + const textUtils = this._blockTextUtilsMap[blockId]; + if (textUtils) { + return textUtils.getDoubleLinkSearchSlashText(); + } + console.warn('Could find the block text utils'); + return ''; + } + public setSelectDoubleLinkSearchSlash(blockId: string) { + const textUtils = this._blockTextUtilsMap[blockId]; + if (textUtils) { + return textUtils.setSelectDoubleLinkSearchSlash(); + } + console.warn('Could find the block text utils'); + return ''; + } + + public removeDoubleLinkSearchSlash( blockId: string, - selection: Selection, - offset: number + isRemoveSlash?: boolean ) { - const text_utils = this._blockTextUtilsMap[blockId]; - if (text_utils) { - const offsetSelection = window.getSelection(); - offsetSelection.setBaseAndExtent( - selection.anchorNode, - selection.anchorOffset, - selection.focusNode, - selection.focusOffset + offset - ); + const textUtils = this._blockTextUtilsMap[blockId]; + if (textUtils) { + textUtils.removeDoubleLinkSearchSlash(isRemoveSlash); + } else { + console.warn('Could find the block text utils'); + } + } - text_utils.removeSelection(offsetSelection); - text_utils.insertReference(reference); - - // range. - // text_utils.toggleTextFormatBySelection(format, range); + public async insertDoubleLink( + workspaceId: string, + linkBlockId: string, + blockId: string + ) { + const textUtils = this._blockTextUtilsMap[blockId]; + if (textUtils) { + const linkBlock = await this._editor.getBlock({ + workspace: workspaceId, + id: linkBlockId, + }); + let children = linkBlock.getProperties().text?.value || []; + if (children.length === 1 && !children[0].text) { + children = [{ text: 'Untitled' }]; + } + textUtils.insertDoubleLink(workspaceId, linkBlockId, children); } console.warn('Could find the block text utils'); } diff --git a/libs/components/editor-core/src/editor/editor.ts b/libs/components/editor-core/src/editor/editor.ts index e85db2ba65..e7f6458a5f 100644 --- a/libs/components/editor-core/src/editor/editor.ts +++ b/libs/components/editor-core/src/editor/editor.ts @@ -1,17 +1,15 @@ /* eslint-disable max-lines */ -import HotKeys from 'hotkeys-js'; - import type { PatchNode } from '@toeverything/components/ui'; +import { Commands } from '@toeverything/datasource/commands'; import type { BlockFlavors, ReturnEditorBlock, UpdateEditorBlock, } from '@toeverything/datasource/db-service'; import { services } from '@toeverything/datasource/db-service'; - -import { Commands } from '@toeverything/datasource/commands'; import { domToRect, last, Point, sleep } from '@toeverything/utils'; import assert from 'assert'; +import HotKeys from 'hotkeys-js'; import type { WorkspaceAndBlockId } from './block'; import { AsyncBlock } from './block'; import { BlockHelper } from './block/block-helper'; @@ -282,7 +280,7 @@ export class Editor implements Virgo { return await blockView.onCreate(block); } - private async getBlock({ + public async getBlock({ workspace, id, }: WorkspaceAndBlockId): Promise { diff --git a/libs/components/editor-plugins/src/index.ts b/libs/components/editor-plugins/src/index.ts index be7bb9bd97..73cfece84f 100644 --- a/libs/components/editor-plugins/src/index.ts +++ b/libs/components/editor-plugins/src/index.ts @@ -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, diff --git a/libs/components/editor-plugins/src/menu/double-link-menu/Container.tsx b/libs/components/editor-plugins/src/menu/double-link-menu/Container.tsx new file mode 100644 index 0000000000..8714ab519f --- /dev/null +++ b/libs/components/editor-plugins/src/menu/double-link-menu/Container.tsx @@ -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; + items?: CommonListItem[]; +}; + +export const DoubleLinkMenuContainer = ({ + hooks, + isShow = false, + onSelected, + onClose, + types, + style, + items, +}: DoubleLinkMenuContainerProps) => { + const menuRef = useRef(null); + const [currentItem, setCurrentItem] = useState(); + const [needCheckIntoView, setNeedCheckIntoView] = useState(false); + + useEffect(() => { + if (needCheckIntoView) { + if (currentItem && menuRef.current) { + const itemEle = + menuRef.current.querySelector( + `.item-${currentItem}` + ); + const scrollEle = + menuRef.current.querySelector( + `.${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) => { + 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) => { + 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) => { + if (isShow && event.code === 'Enter' && currentItem) { + event.preventDefault(); + onSelected && onSelected(currentItem); + } + }, + [isShow, currentItem, onSelected] + ); + + const handleKeyDown = useCallback( + (event: React.KeyboardEvent) => { + 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 ? ( + + + onSelected?.(type)} + currentItem={currentItem} + setCurrentItem={setCurrentItem} + /> + + + ) : 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', +})); 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 new file mode 100644 index 0000000000..1f545809a3 --- /dev/null +++ b/libs/components/editor-plugins/src/menu/double-link-menu/DoubleLinkMenu.tsx @@ -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(); + const [searchText, setSearchText] = useState(''); + const [searchBlocks, setSearchBlocks] = useState([]); + const [items, setItems] = useState([]); + const [isNewPage, setIsNewPage] = useState(false); + const [anchorEl, setAnchorEl] = useState(null); + const ref = useRef(); + const ref1 = useRef(); + const [referenceMenuStyle, setReferenceMenuStyle] = + useState({ + 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 ; + }, + }); + } else { + items.push({ + renderCustom: () => { + return ; + }, + }); + } + 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) => { + 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) => handleSearch(event), + [handleSearch] + ); + + const handleKeyDown = useCallback( + (event: React.KeyboardEvent) => { + 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 ( +
+ hideMenu()}> + + {({ TransitionProps }) => ( + + + + + {}} + /> + + + + + + )} + + +
+ ); +}; + +const DoubleLinkMenuWrapper = styled('div')({ + zIndex: 1, +}); + +const SearchContainer = styled('div')({ + padding: '8px 8px', + input: { + height: '28px', + padding: '5px 10px', + with: '300px', + }, +}); diff --git a/libs/components/editor-plugins/src/menu/reference-menu/Plugin.tsx b/libs/components/editor-plugins/src/menu/double-link-menu/Plugin.tsx similarity index 79% rename from libs/components/editor-plugins/src/menu/reference-menu/Plugin.tsx rename to libs/components/editor-plugins/src/menu/double-link-menu/Plugin.tsx index 688adfd147..3933bf5f1c 100644 --- a/libs/components/editor-plugins/src/menu/reference-menu/Plugin.tsx +++ b/libs/components/editor-plugins/src/menu/double-link-menu/Plugin.tsx @@ -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( - + ); } diff --git a/libs/components/editor-plugins/src/menu/double-link-menu/index.ts b/libs/components/editor-plugins/src/menu/double-link-menu/index.ts new file mode 100644 index 0000000000..e287e74e03 --- /dev/null +++ b/libs/components/editor-plugins/src/menu/double-link-menu/index.ts @@ -0,0 +1 @@ +export { DoubleLinkMenuPlugin } from './Plugin'; diff --git a/libs/components/editor-plugins/src/menu/index.ts b/libs/components/editor-plugins/src/menu/index.ts index 50d4133651..190e3a5109 100644 --- a/libs/components/editor-plugins/src/menu/index.ts +++ b/libs/components/editor-plugins/src/menu/index.ts @@ -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'; diff --git a/libs/components/editor-plugins/src/menu/reference-menu/Container.tsx b/libs/components/editor-plugins/src/menu/reference-menu/Container.tsx deleted file mode 100644 index 42ca2d679d..0000000000 --- a/libs/components/editor-plugins/src/menu/reference-menu/Container.tsx +++ /dev/null @@ -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; -}; - -export const ReferenceMenuContainer = ({ - hooks, - isShow = false, - onSelected, - onClose, - types, - searchBlocks, - style, -}: ReferenceMenuContainerProps) => { - const menu_ref = useRef(null); - const [current_item, set_current_item] = useState(); - const [need_check_into_view, set_need_check_into_view] = - useState(false); - - useEffect(() => { - if (need_check_into_view) { - if (current_item && menu_ref.current) { - const item_ele = - menu_ref.current.querySelector( - `.item-${current_item}` - ); - const scroll_ele = - menu_ref.current.querySelector( - `.${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) => { - 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) => { - 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) => { - 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) => { - 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 ? ( - - - ({ block } as CommonListItem) - ) || [] - } - onSelected={type => onSelected?.(type)} - currentItem={current_item} - setCurrentItem={set_current_item} - /> - - - ) : 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', -})); diff --git a/libs/components/editor-plugins/src/menu/reference-menu/ReferenceMenu.tsx b/libs/components/editor-plugins/src/menu/reference-menu/ReferenceMenu.tsx deleted file mode 100644 index af1cd96cea..0000000000 --- a/libs/components/editor-plugins/src/menu/reference-menu/ReferenceMenu.tsx +++ /dev/null @@ -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(); - const [position, set_position] = useState(new Point(0, 0)); - - const [search_text, set_search_text] = useState(''); - const [search_blocks, set_search_blocks] = useState([]); - - 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) => { - 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) => handle_search(event), - [handle_search] - ); - - const handle_key_down = useCallback( - (event: React.KeyboardEvent) => { - 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 ( - - set_is_show(false)}> -
- -
-
-
- ); -}; - -const ReferenceMenuWrapper = styled('div')({ - position: 'absolute', - zIndex: 1, -}); diff --git a/libs/components/editor-plugins/src/menu/reference-menu/index.ts b/libs/components/editor-plugins/src/menu/reference-menu/index.ts deleted file mode 100644 index 0a3879e398..0000000000 --- a/libs/components/editor-plugins/src/menu/reference-menu/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { ReferenceMenuPlugin } from './Plugin'; diff --git a/libs/components/ui/src/button/ListButton.tsx b/libs/components/ui/src/button/ListButton.tsx index 1d6cead375..30e5ca9a31 100644 --- a/libs/components/ui/src/button/ListButton.tsx +++ b/libs/components/ui/src/button/ListButton.tsx @@ -1,6 +1,5 @@ import clsx from 'clsx'; import style9 from 'style9'; - import { SvgIconProps } from '../svg-icon'; import { BaseButton } from './base-button'; @@ -35,7 +34,7 @@ const styles = style9.create({ type ListButtonProps = { className?: string; - onClick: () => void; + onClick?: () => void; onMouseOver?: () => void; content?: string; children?: () => JSX.Element; diff --git a/libs/datasource/jwt/src/index.ts b/libs/datasource/jwt/src/index.ts index 50fec96d5e..664e0f21fa 100644 --- a/libs/datasource/jwt/src/index.ts +++ b/libs/datasource/jwt/src/index.ts @@ -1,7 +1,6 @@ /* eslint-disable max-lines */ import { DocumentSearchOptions } from 'flexsearch'; import LRUCache from 'lru-cache'; - import { AsyncDatabaseAdapter, BlockInstance, @@ -290,19 +289,22 @@ export class BlockClient< | string | Partial> ): Promise { - const promised_pages = await Promise.all( - this.search(part_of_title_or_content).flatMap(({ result }) => - result.map(async id => { - const page = this._pageMapping.get(id as string); - if (page) return page; - const block = await this.get(id as BlockTypeKeys); - return this.set_page(block); - }) - ) - ); - const pages = [ - ...new Set(promised_pages.filter((v): v is string => !!v)), - ]; + let pages = []; + if (part_of_title_or_content) { + const promisedPages = await Promise.all( + this.search(part_of_title_or_content).flatMap(({ result }) => + result.map(async id => { + const page = this._pageMapping.get(id as string); + if (page) return page; + const block = await this.get(id as BlockTypeKeys); + return this.set_page(block); + }) + ) + ); + pages = [...new Set(promisedPages.filter((v): v is string => !!v))]; + } else { + pages = await this.getBlockByFlavor('page'); + } return Promise.all( this._blockIndexer.getMetadata(pages).map(async page => ({ content: this.get_decoded_content(