diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/BlockMenu/BlockMenu.hooks.ts b/frontend/appflowy_tauri/src/appflowy_app/components/document/BlockMenu/BlockMenu.hooks.ts deleted file mode 100644 index 17195001aa..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/BlockMenu/BlockMenu.hooks.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { rectSelectionActions } from "@/appflowy_app/stores/reducers/document/slice"; -import { useAppDispatch } from '@/appflowy_app/stores/store'; -import { useRef, useState, useEffect } from 'react'; - -export function useBlockMenu(nodeId: string, open: boolean) { - const ref = useRef(null); - const dispatch = useAppDispatch(); - const [style, setStyle] = useState({ top: '0px', left: '0px' }); - - useEffect(() => { - if (!open) { - return; - } - // set selection when open - dispatch(rectSelectionActions.setSelectionById(nodeId)); - // get node rect - const rect = document.querySelector(`[data-block-id="${nodeId}"]`)?.getBoundingClientRect(); - if (!rect) return; - // set menu position - setStyle({ - top: rect.top + 'px', - left: rect.left + 'px', - }); - }, [open, nodeId, dispatch]); - - return { - ref, - style, - }; -} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/BlockMenu/MenuItem.hooks.ts b/frontend/appflowy_tauri/src/appflowy_app/components/document/BlockMenu/MenuItem.hooks.ts deleted file mode 100644 index f944c07f91..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/BlockMenu/MenuItem.hooks.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { DocumentControllerContext } from '$app/stores/effects/document/document_controller'; -import { useAppDispatch } from '@/appflowy_app/stores/store'; -import { useCallback, useContext } from 'react'; -import { insertAfterNodeThunk, deleteNodeThunk } from '$app/stores/reducers/document/async-actions'; - -export enum ActionType { - InsertAfter = 'insertAfter', - Remove = 'remove', -} -export function useActions(id: string, type: ActionType) { - const dispatch = useAppDispatch(); - const controller = useContext(DocumentControllerContext); - - const insertAfter = useCallback(async () => { - if (!controller) return; - await dispatch(insertAfterNodeThunk({ id, controller })); - }, [id, controller, dispatch]); - - const remove = useCallback(async () => { - if (!controller) return; - await dispatch(deleteNodeThunk({ id, controller })); - }, [id, dispatch]); - - if (type === ActionType.InsertAfter) { - return insertAfter; - } - if (type === ActionType.Remove) { - return remove; - } - return; -} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/BlockMenu/MenuItem.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/document/BlockMenu/MenuItem.tsx deleted file mode 100644 index 29db37b151..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/BlockMenu/MenuItem.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import React from 'react'; -import DeleteIcon from '@mui/icons-material/Delete'; -import AddIcon from '@mui/icons-material/Add'; -import Button from '@mui/material/Button'; -import { ActionType, useActions } from './MenuItem.hooks'; - -const icon: Record = { - [ActionType.InsertAfter]: , - [ActionType.Remove]: , -}; - -function MenuItem({ id, type, onClick }: { id: string; type: ActionType; onClick?: () => void }) { - const action = useActions(id, type); - return ( - - ); -} - -export default MenuItem; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/BlockMenu/index.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/document/BlockMenu/index.tsx deleted file mode 100644 index a0c00dd7d6..0000000000 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/BlockMenu/index.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import React from 'react'; -import { useBlockMenu } from './BlockMenu.hooks'; -import MenuItem from './MenuItem'; -import { ActionType } from '$app/components/document/BlockMenu/MenuItem.hooks'; - -function BlockMenu({ open, onClose, nodeId }: { open: boolean; onClose: () => void; nodeId: string }) { - const { ref, style } = useBlockMenu(nodeId, open); - - return open ? ( -
{ - // prevent scrolling of the document when menu is open - e.stopPropagation(); - }} - onMouseDown={(e) => { - // prevent menu from taking focus away from editor - e.preventDefault(); - e.stopPropagation(); - }} - onClick={(e) => { - e.stopPropagation(); - onClose(); - }} - > -
{ - // prevent menu close when clicking on menu - e.stopPropagation(); - }} - > - - -
-
- ) : null; -} - -export default React.memo(BlockMenu); diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSideToolbar/BlockMenu.hooks.ts b/frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSideToolbar/BlockMenu.hooks.ts new file mode 100644 index 0000000000..027435e40f --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSideToolbar/BlockMenu.hooks.ts @@ -0,0 +1,35 @@ +import { useAppDispatch } from '$app/stores/store'; +import { useCallback, useContext } from 'react'; +import { DocumentControllerContext } from '$app/stores/effects/document/document_controller'; +import { duplicateBelowNodeThunk } from '$app_reducers/document/async-actions/blocks/duplicate'; +import { deleteNodeThunk } from '$app_reducers/document/async-actions'; + +export function useBlockMenu(id: string) { + const dispatch = useAppDispatch(); + const controller = useContext(DocumentControllerContext); + + const handleDuplicate = useCallback(async () => { + if (!controller) return; + await dispatch( + duplicateBelowNodeThunk({ + id, + controller, + }) + ); + }, [controller, dispatch, id]); + + const handleDelete = useCallback(async () => { + if (!controller) return; + await dispatch( + deleteNodeThunk({ + id, + controller, + }) + ); + }, [controller, dispatch, id]); + + return { + handleDuplicate, + handleDelete, + }; +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSideToolbar/BlockMenu.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSideToolbar/BlockMenu.tsx new file mode 100644 index 0000000000..bf8c2b6b8a --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSideToolbar/BlockMenu.tsx @@ -0,0 +1,60 @@ +import React, { useCallback } from 'react'; +import { List } from '@mui/material'; +import { ContentCopy, Delete } from '@mui/icons-material'; +import MenuItem from './MenuItem'; +import { useBlockMenu } from '$app/components/document/BlockSideToolbar/BlockMenu.hooks'; +import BlockMenuTurnInto from '$app/components/document/BlockSideToolbar/BlockMenuTurnInto'; + +function BlockMenu({ id, onClose }: { id: string; onClose: () => void }) { + const { handleDelete, handleDuplicate } = useBlockMenu(id); + + const [turnIntoPup, setTurnIntoPup] = React.useState(false); + const handleClick = useCallback( + async ({ operate }: { operate: () => Promise }) => { + await operate(); + onClose(); + }, + [onClose] + ); + + return ( + { + e.preventDefault(); + e.stopPropagation(); + }} + > + } + onClick={() => + handleClick({ + operate: handleDelete, + }) + } + onHover={(isHovered) => { + if (isHovered) { + setTurnIntoPup(false); + } + }} + /> + } + onClick={() => + handleClick({ + operate: handleDuplicate, + }) + } + onHover={(isHovered) => { + if (isHovered) { + setTurnIntoPup(false); + } + }} + /> + setTurnIntoPup(true)} isHovered={turnIntoPup} onClose={onClose} id={id} /> + + ); +} + +export default BlockMenu; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSideToolbar/BlockMenuTurnInto.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSideToolbar/BlockMenuTurnInto.tsx new file mode 100644 index 0000000000..cb4077f2e2 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSideToolbar/BlockMenuTurnInto.tsx @@ -0,0 +1,62 @@ +import React, { useState } from 'react'; +import { ArrowRight, Transform } from '@mui/icons-material'; +import MenuItem from '$app/components/document/BlockSideToolbar/MenuItem'; +import TurnIntoPopover from '$app/components/document/_shared/TurnInto'; +function BlockMenuTurnInto({ + id, + onClose, + onHovered, + isHovered, +}: { + id: string; + onClose: () => void; + onHovered: () => void; + isHovered: boolean; +}) { + const [anchorEl, setAnchorEl] = useState(null); + + const open = isHovered && Boolean(anchorEl); + + return ( + <> + } + extra={} + onHover={(hovered, event) => { + if (hovered) { + onHovered(); + setAnchorEl(event.currentTarget); + return; + } + }} + /> + + + ); +} + +export default BlockMenuTurnInto; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSideToolbar/BlockSideToolbar.hooks.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSideToolbar/BlockSideToolbar.hooks.tsx index 72b805a7ba..5ecbb2407a 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSideToolbar/BlockSideToolbar.hooks.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSideToolbar/BlockSideToolbar.hooks.tsx @@ -1,7 +1,8 @@ -import { BlockType, HeadingBlockData, NestedBlock } from "@/appflowy_app/interfaces/document"; -import { useAppDispatch } from "@/appflowy_app/stores/store"; -import { useCallback, useEffect, useRef, useState } from 'react'; -import { getBlockByIdThunk } from "$app_reducers/document/async-actions"; +import { BlockType, HeadingBlockData, NestedBlock } from '@/appflowy_app/interfaces/document'; +import { useAppDispatch } from '@/appflowy_app/stores/store'; +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { getBlockByIdThunk } from '$app_reducers/document/async-actions'; +import { PopoverOrigin } from '@mui/material/Popover/Popover'; const headingBlockTopOffset: Record = { 1: 7, @@ -10,7 +11,6 @@ const headingBlockTopOffset: Record = { }; export function useBlockSideToolbar({ container }: { container: HTMLDivElement }) { const [nodeId, setHoverNodeId] = useState(null); - const [menuOpen, setMenuOpen] = useState(false); const ref = useRef(null); const dispatch = useAppDispatch(); const [style, setStyle] = useState({}); @@ -18,8 +18,8 @@ export function useBlockSideToolbar({ container }: { container: HTMLDivElement } useEffect(() => { const el = ref.current; if (!el || !nodeId) return; - void(async () => { - const{ payload: node } = await dispatch(getBlockByIdThunk(nodeId)) as { + void (async () => { + const { payload: node } = (await dispatch(getBlockByIdThunk(nodeId))) as { payload: NestedBlock; }; if (!node) { @@ -43,16 +43,8 @@ export function useBlockSideToolbar({ container }: { container: HTMLDivElement } }); } })(); - }, [dispatch, nodeId]); - const handleToggleMenu = useCallback((isOpen: boolean) => { - setMenuOpen(isOpen); - if (!isOpen) { - setHoverNodeId(''); - } - }, []); - const handleMouseMove = useCallback((e: MouseEvent) => { const { clientX, clientY } = e; const id = getNodeIdByPoint(clientX, clientY); @@ -69,9 +61,7 @@ export function useBlockSideToolbar({ container }: { container: HTMLDivElement } return { nodeId, ref, - handleToggleMenu, - menuOpen, - style + style, }; } @@ -102,3 +92,40 @@ function getNodeIdByPoint(x: number, y: number) { ).el.getAttribute('data-block-id') : null; } + +const origin: { + anchorOrigin: PopoverOrigin; + transformOrigin: PopoverOrigin; +} = { + anchorOrigin: { + vertical: 'bottom', + horizontal: 'right', + }, + transformOrigin: { + vertical: 'bottom', + horizontal: 'left', + }, +}; +export function usePopover() { + const [anchorEl, setAnchorEl] = React.useState(null); + + const onClose = useCallback(() => { + setAnchorEl(null); + }, []); + + const handleOpen = useCallback((e: React.MouseEvent) => { + e.preventDefault(); + setAnchorEl(e.currentTarget); + }, []); + + const open = Boolean(anchorEl); + + return { + anchorEl, + onClose, + open, + handleOpen, + disableAutoFocus: true, + ...origin, + }; +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSideToolbar/MenuItem.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSideToolbar/MenuItem.tsx new file mode 100644 index 0000000000..7d952647e5 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSideToolbar/MenuItem.tsx @@ -0,0 +1,36 @@ +import React from 'react'; +import { ListItem, ListItemButton, ListItemIcon, ListItemText } from '@mui/material'; + +function MenuItem({ + icon, + title, + onClick, + extra, + onHover, +}: { + title: string; + icon: React.ReactNode; + onClick?: () => void; + extra?: React.ReactNode; + onHover?: (isHovered: boolean, event: React.MouseEvent) => void; +}) { + return ( + + onHover?.(true, e)} + onMouseLeave={(e) => onHover?.(false, e)} + onClick={(e) => { + e.preventDefault(); + e.stopPropagation(); + onClick?.(); + }} + > + {icon} + + {extra} + + + ); +} + +export default MenuItem; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSideToolbar/ToolbarButton.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSideToolbar/ToolbarButton.tsx new file mode 100644 index 0000000000..2a3e1aa532 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSideToolbar/ToolbarButton.tsx @@ -0,0 +1,24 @@ +import React from 'react'; +const sx = { height: 24, width: 24 }; +import { IconButton } from '@mui/material'; +import Tooltip from '@mui/material/Tooltip'; + +const ToolbarButton = ({ + onClick, + children, + tooltip, +}: { + tooltip: string; + children: React.ReactNode; + onClick: React.MouseEventHandler; +}) => { + return ( + + + {children} + + + ); +}; + +export default ToolbarButton; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSideToolbar/index.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSideToolbar/index.tsx index 7142ec8baf..099a099c8b 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSideToolbar/index.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSideToolbar/index.tsx @@ -1,21 +1,30 @@ -import React from 'react'; -import { useBlockSideToolbar } from './BlockSideToolbar.hooks'; -import ExpandCircleDownSharpIcon from '@mui/icons-material/ExpandCircleDownSharp'; -import DragIndicatorRoundedIcon from '@mui/icons-material/DragIndicatorRounded'; +import React, { useCallback, useContext, useState } from 'react'; +import { useBlockSideToolbar, usePopover } from './BlockSideToolbar.hooks'; import Portal from '../BlockPortal'; -import { IconButton } from '@mui/material'; -import BlockMenu from '../BlockMenu'; -import { useAppSelector } from '$app/stores/store'; +import { useAppDispatch, useAppSelector } from '$app/stores/store'; +import Popover from '@mui/material/Popover'; +import DragIndicatorRoundedIcon from '@mui/icons-material/DragIndicatorRounded'; +import AddSharpIcon from '@mui/icons-material/AddSharp'; +import BlockMenu from './BlockMenu'; +import ToolbarButton from './ToolbarButton'; +import { rectSelectionActions } from '$app_reducers/document/slice'; +import { addBlockBelowClickThunk } from '$app_reducers/document/async-actions/menu'; +import { DocumentControllerContext } from '$app/stores/effects/document/document_controller'; -const sx = { height: 24, width: 24 }; - -export default function BlockSideToolbar(props: { container: HTMLDivElement }) { - const { nodeId, style, ref, menuOpen, handleToggleMenu } = useBlockSideToolbar(props); +export default function BlockSideToolbar({ container }: { container: HTMLDivElement }) { + const dispatch = useAppDispatch(); + const controller = useContext(DocumentControllerContext); + const { nodeId, style, ref } = useBlockSideToolbar({ container }); const isDragging = useAppSelector( (state) => state.documentRangeSelection.isDragging || state.documentRectSelection.isDragging ); + const { handleOpen, ...popoverProps } = usePopover(); + + // prevent popover from showing when anchorEl is not in DOM + const showPopover = popoverProps.anchorEl ? document.contains(popoverProps.anchorEl) : true; if (!nodeId || isDragging) return null; + return ( <> @@ -32,15 +41,41 @@ export default function BlockSideToolbar(props: { container: HTMLDivElement }) { e.stopPropagation(); }} > - handleToggleMenu(true)} sx={sx}> - - - + {/** Add Block below */} + ) => { + if (!nodeId || !controller) return; + dispatch( + addBlockBelowClickThunk({ + id: nodeId, + controller, + }) + ); + }} + > + + + + {/** Open menu or drag */} + ) => { + if (!nodeId) return; + dispatch(rectSelectionActions.setSelectionById(nodeId)); + handleOpen(e); + }} + > - + - handleToggleMenu(false)} nodeId={nodeId} /> + + {showPopover && ( + + + + )} ); } diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSlash/BlockSlashMenu.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSlash/BlockSlashMenu.tsx new file mode 100644 index 0000000000..20d43ec1d2 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSlash/BlockSlashMenu.tsx @@ -0,0 +1,142 @@ +import React, { useCallback, useContext, useMemo } from 'react'; +import MenuItem from '$app/components/document/BlockSideToolbar/MenuItem'; +import { + ArrowRight, + Check, + DataObject, + FormatListBulleted, + FormatListNumbered, + FormatQuote, + Lightbulb, + TextFields, + Title, +} from '@mui/icons-material'; +import { List } from '@mui/material'; +import { BlockData, BlockType } from '$app/interfaces/document'; +import { useAppDispatch } from '$app/stores/store'; +import { DocumentControllerContext } from '$app/stores/effects/document/document_controller'; +import { triggerSlashCommandActionThunk } from '$app_reducers/document/async-actions/menu'; + +function BlockSlashMenu({ id, onClose, searchText }: { id: string; onClose?: () => void; searchText?: string }) { + const dispatch = useAppDispatch(); + const controller = useContext(DocumentControllerContext); + const handleInsert = useCallback( + async (type: BlockType, data?: BlockData) => { + if (!controller) return; + await dispatch( + triggerSlashCommandActionThunk({ + controller, + id, + props: { + type, + data, + }, + }) + ); + onClose?.(); + }, + [controller, dispatch, id, onClose] + ); + + const optionColumns = useMemo( + () => [ + [ + { + type: BlockType.TextBlock, + title: 'Text', + icon: , + }, + { + type: BlockType.HeadingBlock, + title: 'Heading 1', + icon: , + props: { + level: 1, + }, + }, + { + type: BlockType.HeadingBlock, + title: 'Heading 2', + icon: <Title />, + props: { + level: 2, + }, + }, + { + type: BlockType.HeadingBlock, + title: 'Heading 3', + icon: <Title />, + props: { + level: 3, + }, + }, + { + type: BlockType.TodoListBlock, + title: 'To-do list', + icon: <Check />, + }, + { + type: BlockType.BulletedListBlock, + title: 'Bulleted list', + icon: <FormatListBulleted />, + }, + { + type: BlockType.NumberedListBlock, + title: 'Numbered list', + icon: <FormatListNumbered />, + }, + ], + [ + { + type: BlockType.ToggleListBlock, + title: 'Toggle list', + icon: <ArrowRight />, + }, + { + type: BlockType.CodeBlock, + title: 'Code', + icon: <DataObject />, + }, + { + type: BlockType.QuoteBlock, + title: 'Quote', + icon: <FormatQuote />, + }, + { + type: BlockType.CalloutBlock, + title: 'Callout', + icon: <Lightbulb />, + }, + ], + ], + [] + ); + return ( + <div + onMouseDown={(e) => { + e.preventDefault(); + e.stopPropagation(); + }} + className={'flex'} + > + {optionColumns.map((column, index) => ( + <List key={index} className={'flex-1'}> + {column.map((option) => { + return ( + <MenuItem + key={option.title} + title={option.title} + icon={option.icon} + onClick={() => { + handleInsert(option.type, option.props); + }} + /> + ); + })} + </List> + ))} + </div> + ); +} + +export default BlockSlashMenu; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSlash/index.hooks.ts b/frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSlash/index.hooks.ts new file mode 100644 index 0000000000..5361613e80 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSlash/index.hooks.ts @@ -0,0 +1,72 @@ +import { useAppDispatch, useAppSelector } from '$app/stores/store'; +import React, { useCallback, useEffect, useMemo } from 'react'; +import { slashCommandActions } from '$app_reducers/document/slice'; +import { useSubscribeNode } from '$app/components/document/_shared/SubscribeNode.hooks'; +import { TextDelta } from '$app/interfaces/document'; + +export function useBlockSlash() { + const dispatch = useAppDispatch(); + const { blockId, visible, slashText } = useSubscribeSlash(); + const [anchorEl, setAnchorEl] = React.useState<HTMLElement | null>(null); + useEffect(() => { + if (blockId && visible) { + const el = document.querySelector(`[data-block-id="${blockId}"]`) as HTMLElement; + if (el) { + setAnchorEl(el); + return; + } + } + setAnchorEl(null); + }, [blockId, visible]); + + useEffect(() => { + if (!slashText) { + dispatch(slashCommandActions.closeSlashCommand()); + } + }, [dispatch, slashText]); + + const searchText = useMemo(() => { + if (!slashText) return ''; + if (slashText[0] !== '/') return slashText; + return slashText.slice(1, slashText.length); + }, [slashText]); + const onClose = useCallback(() => { + dispatch(slashCommandActions.closeSlashCommand()); + }, [dispatch]); + + const open = Boolean(anchorEl); + + return { + open, + anchorEl, + onClose, + blockId, + searchText, + }; +} +export function useSubscribeSlash() { + const slashCommandState = useAppSelector((state) => state.documentSlashCommand); + + const visible = useMemo(() => slashCommandState.isSlashCommand, [slashCommandState.isSlashCommand]); + const blockId = useMemo(() => slashCommandState.blockId, [slashCommandState.blockId]); + const { node } = useSubscribeNode(blockId || ''); + const slashText = useMemo(() => { + if (!node) return ''; + const delta = node.data.delta || []; + return delta + .map((op: TextDelta) => { + if (typeof op.insert === 'string') { + return op.insert; + } else { + return ''; + } + }) + .join(''); + }, [node]); + + return { + visible, + blockId, + slashText, + }; +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSlash/index.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSlash/index.tsx new file mode 100644 index 0000000000..e59cc8ac10 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/BlockSlash/index.tsx @@ -0,0 +1,30 @@ +import React from 'react'; +import Popover from '@mui/material/Popover'; +import BlockSlashMenu from '$app/components/document/BlockSlash/BlockSlashMenu'; +import { useBlockSlash } from '$app/components/document/BlockSlash/index.hooks'; + +function BlockSlash() { + const { blockId, open, onClose, anchorEl, searchText } = useBlockSlash(); + if (!blockId) return null; + + return ( + <Popover + open={open} + anchorEl={anchorEl} + anchorOrigin={{ + vertical: 'bottom', + horizontal: 'left', + }} + transformOrigin={{ + vertical: 'top', + horizontal: 'left', + }} + disableAutoFocus + onClose={onClose} + > + <BlockSlashMenu id={blockId} onClose={onClose} searchText={searchText} /> + </Popover> + ); +} + +export default BlockSlash; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/Overlay/index.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/document/Overlay/index.tsx index d1a08c7071..5a5c6f60ea 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/Overlay/index.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/Overlay/index.tsx @@ -2,6 +2,7 @@ import React from 'react'; import BlockSideToolbar from '../BlockSideToolbar'; import BlockSelection from '../BlockSelection'; import TextActionMenu from '$app/components/document/TextActionMenu'; +import BlockSlash from '$app/components/document/BlockSlash'; export default function Overlay({ container }: { container: HTMLDivElement }) { return ( @@ -9,6 +10,7 @@ export default function Overlay({ container }: { container: HTMLDivElement }) { <BlockSideToolbar container={container} /> <TextActionMenu container={container} /> <BlockSelection container={container} /> + <BlockSlash /> </> ); } diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/TextBlock/events/Events.hooks.ts b/frontend/appflowy_tauri/src/appflowy_app/components/document/TextBlock/events/Events.hooks.ts index bf592938d6..135f7575c3 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/TextBlock/events/Events.hooks.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/TextBlock/events/Events.hooks.ts @@ -2,13 +2,16 @@ import { Editor } from 'slate'; import { useTurnIntoBlock } from './TurnIntoEvents.hooks'; import { useCallback, useContext, useMemo } from 'react'; import { keyBoardEventKeyMap } from '$app/constants/document/text_block'; -import { TextBlockKeyEventHandlerParams } from '$app/interfaces/document'; +import { BlockType, TextBlockKeyEventHandlerParams } from '$app/interfaces/document'; import isHotkey from 'is-hotkey'; import { indentNodeThunk, outdentNodeThunk, splitNodeThunk } from '$app_reducers/document/async-actions'; import { DocumentControllerContext } from '$app/stores/effects/document/document_controller'; import { useAppDispatch, useAppSelector } from '$app/stores/store'; import { useDefaultTextInputEvents } from '$app/components/document/_shared/Text/TextEvents.hooks'; import { ReactEditor } from 'slate-react'; +import { getBeforeRangeAt } from '$app/utils/document/blocks/text/delta'; +import { slashCommandActions } from '$app_reducers/document/slice'; +import { useSubscribeNode } from '$app/components/document/_shared/SubscribeNode.hooks'; export function useTextBlockKeyEvent(id: string, editor: ReactEditor) { const controller = useContext(DocumentControllerContext); @@ -20,6 +23,9 @@ export function useTextBlockKeyEvent(id: string, editor: ReactEditor) { return anchor.id === id && focus.id === id; }); + const { node } = useSubscribeNode(id); + const nodeType = node?.type; + const { turnIntoBlockEvents } = useTurnIntoBlock(id); // Here custom key events for TextBlock @@ -81,8 +87,27 @@ export function useTextBlockKeyEvent(id: string, editor: ReactEditor) { ); }, }, + { + // Here custom slash key event for TextBlock + triggerEventKey: keyBoardEventKeyMap.Slash, + canHandle: (...args: TextBlockKeyEventHandlerParams) => { + const [e, editor] = args; + if (!editor.selection) return false; + + return isHotkey('/', e) && Editor.string(editor, getBeforeRangeAt(editor, editor.selection)) === ''; + }, + handler: (...args: TextBlockKeyEventHandlerParams) => { + const [e, _] = args; + if (!controller) return; + dispatch( + slashCommandActions.openSlashCommand({ + blockId: id, + }) + ); + }, + }, ], - [defaultTextInputEvents, controller, dispatch, id] + [defaultTextInputEvents, controller, dispatch, id, nodeType] ); const onKeyDown = useCallback( diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/VirtualizedList/index.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/document/VirtualizedList/index.tsx index 9947f31bbd..814a137084 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/VirtualizedList/index.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/VirtualizedList/index.tsx @@ -43,12 +43,7 @@ export default function VirtualizedList({ {virtualItems.map((virtualRow) => { const id = childIds[virtualRow.index]; return ( - <div - className='float-left w-[100%]' - key={id} - data-index={virtualRow.index} - ref={virtualize.measureElement} - > + <div className='pt-0.5' key={id} data-index={virtualRow.index} ref={virtualize.measureElement}> {virtualRow.index === 0 ? <DocumentTitle id={node.id} /> : null} {renderNode(id)} </div> diff --git a/frontend/appflowy_tauri/src/appflowy_app/constants/document/text_block.ts b/frontend/appflowy_tauri/src/appflowy_app/constants/document/text_block.ts index b74920db1d..067b28aa03 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/constants/document/text_block.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/constants/document/text_block.ts @@ -9,4 +9,5 @@ export const keyBoardEventKeyMap = { Space: ' ', Reduce: '-', Backquote: '`', + Slash: '/', }; diff --git a/frontend/appflowy_tauri/src/appflowy_app/interfaces/document.ts b/frontend/appflowy_tauri/src/appflowy_app/interfaces/document.ts index f0a795d6ce..74332d6b92 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/interfaces/document.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/interfaces/document.ts @@ -131,6 +131,10 @@ export interface DocumentState { // map of block id to children block ids children: Record<string, string[]>; } +export interface SlashCommandState { + isSlashCommand: boolean; + blockId?: string; +} export interface RectSelectionState { selection: string[]; diff --git a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/text/delete.ts b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/delete.ts similarity index 100% rename from frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/text/delete.ts rename to frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/delete.ts diff --git a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/duplicate.ts b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/duplicate.ts new file mode 100644 index 0000000000..668f698095 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/duplicate.ts @@ -0,0 +1,22 @@ +import { DocumentState } from '$app/interfaces/document'; +import { DocumentController } from '$app/stores/effects/document/document_controller'; +import { createAsyncThunk } from '@reduxjs/toolkit'; +import { newBlock } from '$app/utils/document/blocks/common'; + +export const duplicateBelowNodeThunk = createAsyncThunk( + 'document/duplicateBelowNode', + async (payload: { id: string; controller: DocumentController }, thunkAPI) => { + const { id, controller } = payload; + const { getState } = thunkAPI; + const state = getState() as { document: DocumentState }; + const node = state.document.nodes[id]; + if (!node) return; + const parentId = node.parent; + if (!parentId) return; + // duplicate new node + const newNode = newBlock<any>(node.type, parentId, node.data); + await controller.applyActions([controller.getInsertAction(newNode, node.id)]); + + return newNode.id; + } +); diff --git a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/index.ts b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/index.ts index 1a9ac1460f..1c2c2ce75c 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/index.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/index.ts @@ -1 +1,4 @@ export * from './text'; +export * from './delete'; +export * from './duplicate'; +export * from './insert'; diff --git a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/text/insert.ts b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/insert.ts similarity index 100% rename from frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/text/insert.ts rename to frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/insert.ts diff --git a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/text/index.ts b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/text/index.ts index c44117ea62..56032b59d2 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/text/index.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/text/index.ts @@ -1,6 +1,4 @@ -export * from './delete'; export * from './indent'; -export * from './insert'; export * from './backspace'; export * from './outdent'; export * from './split'; diff --git a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/menu.ts b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/menu.ts new file mode 100644 index 0000000000..65bf5bc3d6 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/menu.ts @@ -0,0 +1,96 @@ +import { createAsyncThunk } from '@reduxjs/toolkit'; +import { BlockData, BlockType, DocumentState, TextDelta } from '$app/interfaces/document'; +import { insertAfterNodeThunk } from '$app_reducers/document/async-actions/blocks'; +import { DocumentController } from '$app/stores/effects/document/document_controller'; +import { slashCommandActions } from '$app_reducers/document/slice'; +import { setCursorBeforeThunk } from '$app_reducers/document/async-actions/cursor'; +import { turnToBlockThunk } from '$app_reducers/document/async-actions/turn_to'; +import { blockConfig } from '$app/constants/document/config'; + +/** + * add block below click + * 1. if current block is not empty, insert a new block after current block + * 2. if current block is empty, open slash command below current block + */ +export const addBlockBelowClickThunk = createAsyncThunk( + 'document/addBlockBelowClick', + async (payload: { id: string; controller: DocumentController }, thunkAPI) => { + const { id, controller } = payload; + const { dispatch, getState } = thunkAPI; + const state = (getState() as { document: DocumentState }).document; + const node = state.nodes[id]; + if (!node) return; + const delta = (node.data.delta as TextDelta[]) || []; + const text = delta.map((d) => d.insert).join(''); + + // if current block is not empty, insert a new block after current block + if (node.type !== BlockType.TextBlock || text !== '') { + const { payload: newBlockId } = await dispatch( + insertAfterNodeThunk({ id: id, type: BlockType.TextBlock, controller, data: { delta: [] } }) + ); + if (newBlockId) { + await dispatch(setCursorBeforeThunk({ id: newBlockId as string })); + dispatch(slashCommandActions.openSlashCommand({ blockId: newBlockId as string })); + } + return; + } + // if current block is empty, open slash command + await dispatch(setCursorBeforeThunk({ id })); + dispatch(slashCommandActions.openSlashCommand({ blockId: id })); + } +); + +/** + * slash command action be triggered + * 1. if current block is empty, operate on current block + * 2. if current block is not empty, insert a new block after current block and operate on new block + */ +export const triggerSlashCommandActionThunk = createAsyncThunk( + 'document/slashCommandAction', + async ( + payload: { + id: string; + controller: DocumentController; + props: { + data?: BlockData<any>; + type: BlockType; + }; + }, + thunkAPI + ) => { + const { id, controller, props } = payload; + const { dispatch, getState } = thunkAPI; + const state = (getState() as { document: DocumentState }).document; + const node = state.nodes[id]; + if (!node) return; + const delta = (node.data.delta as TextDelta[]) || []; + const text = delta.map((d) => d.insert).join(''); + const defaultData = blockConfig[props.type].defaultData; + if (node.type === BlockType.TextBlock && (text === '' || text === '/')) { + dispatch( + turnToBlockThunk({ + id, + controller, + type: props.type, + data: { + ...defaultData, + ...props.data, + }, + }) + ); + return; + } + const { payload: newBlockId } = await dispatch( + insertAfterNodeThunk({ + id, + controller, + type: props.type, + data: { + ...defaultData, + ...props.data, + }, + }) + ); + dispatch(setCursorBeforeThunk({ id: newBlockId as string })); + } +); diff --git a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/slice.ts b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/slice.ts index 5e307af906..da850f6185 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/slice.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/slice.ts @@ -4,11 +4,11 @@ import { PointState, RangeSelectionState, RectSelectionState, + SlashCommandState, } from '@/appflowy_app/interfaces/document'; import { BlockEventPayloadPB } from '@/services/backend'; import { createSlice, PayloadAction } from '@reduxjs/toolkit'; import { parseValue, matchChange } from '$app/utils/document/subscribe'; -import { getNodesInRange } from '$app/utils/document/blocks/common'; const initialState: DocumentState = { nodes: {}, @@ -25,6 +25,10 @@ const rangeSelectionInitialState: RangeSelectionState = { selection: [], }; +const slashCommandInitialState: SlashCommandState = { + isSlashCommand: false, +}; + export const documentSlice = createSlice({ name: 'document', initialState: initialState, @@ -126,12 +130,38 @@ export const rangeSelectionSlice = createSlice({ }, }); +export const slashCommandSlice = createSlice({ + name: 'documentSlashCommand', + initialState: slashCommandInitialState, + reducers: { + openSlashCommand: ( + state, + action: PayloadAction<{ + blockId: string; + }> + ) => { + const { blockId } = action.payload; + return { + ...state, + isSlashCommand: true, + blockId, + }; + }, + closeSlashCommand: (state, _: PayloadAction) => { + return slashCommandInitialState; + }, + }, +}); + export const documentReducers = { [documentSlice.name]: documentSlice.reducer, [rectSelectionSlice.name]: rectSelectionSlice.reducer, [rangeSelectionSlice.name]: rangeSelectionSlice.reducer, + [slashCommandSlice.name]: slashCommandSlice.reducer, }; export const documentActions = documentSlice.actions; export const rectSelectionActions = rectSelectionSlice.actions; export const rangeSelectionActions = rangeSelectionSlice.actions; + +export const slashCommandActions = slashCommandSlice.actions;