mirror of
https://github.com/AppFlowy-IO/AppFlowy.git
synced 2024-08-30 18:12:39 +00:00
Support block toolbar (#2566)
* feat: support block toolbar in left side * fix: export delete and duplicate * feat: slash menu
This commit is contained in:
parent
ca7777e891
commit
b41b212b0d
@ -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<HTMLDivElement | null>(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,
|
||||
};
|
||||
}
|
@ -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;
|
||||
}
|
@ -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, React.ReactNode> = {
|
||||
[ActionType.InsertAfter]: <AddIcon />,
|
||||
[ActionType.Remove]: <DeleteIcon />,
|
||||
};
|
||||
|
||||
function MenuItem({ id, type, onClick }: { id: string; type: ActionType; onClick?: () => void }) {
|
||||
const action = useActions(id, type);
|
||||
return (
|
||||
<Button
|
||||
key={type}
|
||||
className='w-[100%]'
|
||||
variant={'text'}
|
||||
color={'inherit'}
|
||||
startIcon={icon[type]}
|
||||
onClick={() => {
|
||||
void action?.();
|
||||
onClick?.();
|
||||
}}
|
||||
style={{ justifyContent: 'flex-start' }}
|
||||
>
|
||||
{type}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
export default MenuItem;
|
@ -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 ? (
|
||||
<div
|
||||
ref={ref}
|
||||
className='appflowy-block-menu-overlay z-1 fixed inset-0 overflow-hidden'
|
||||
onScrollCapture={(e) => {
|
||||
// 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();
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className='z-99 absolute flex w-[200px] translate-x-[-100%] translate-y-[32px] transform flex-col items-start justify-items-start rounded bg-white p-4 shadow'
|
||||
style={style}
|
||||
onClick={(e) => {
|
||||
// prevent menu close when clicking on menu
|
||||
e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<MenuItem id={nodeId} type={ActionType.InsertAfter} />
|
||||
<MenuItem id={nodeId} type={ActionType.Remove} onClick={onClose} />
|
||||
</div>
|
||||
</div>
|
||||
) : null;
|
||||
}
|
||||
|
||||
export default React.memo(BlockMenu);
|
@ -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,
|
||||
};
|
||||
}
|
@ -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<boolean>(false);
|
||||
const handleClick = useCallback(
|
||||
async ({ operate }: { operate: () => Promise<void> }) => {
|
||||
await operate();
|
||||
onClose();
|
||||
},
|
||||
[onClose]
|
||||
);
|
||||
|
||||
return (
|
||||
<List
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<MenuItem
|
||||
title='Delete'
|
||||
icon={<Delete />}
|
||||
onClick={() =>
|
||||
handleClick({
|
||||
operate: handleDelete,
|
||||
})
|
||||
}
|
||||
onHover={(isHovered) => {
|
||||
if (isHovered) {
|
||||
setTurnIntoPup(false);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<MenuItem
|
||||
title='Duplicate'
|
||||
icon={<ContentCopy />}
|
||||
onClick={() =>
|
||||
handleClick({
|
||||
operate: handleDuplicate,
|
||||
})
|
||||
}
|
||||
onHover={(isHovered) => {
|
||||
if (isHovered) {
|
||||
setTurnIntoPup(false);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<BlockMenuTurnInto onHovered={() => setTurnIntoPup(true)} isHovered={turnIntoPup} onClose={onClose} id={id} />
|
||||
</List>
|
||||
);
|
||||
}
|
||||
|
||||
export default BlockMenu;
|
@ -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 | HTMLDivElement>(null);
|
||||
|
||||
const open = isHovered && Boolean(anchorEl);
|
||||
|
||||
return (
|
||||
<>
|
||||
<MenuItem
|
||||
title='Turn into'
|
||||
icon={<Transform />}
|
||||
extra={<ArrowRight />}
|
||||
onHover={(hovered, event) => {
|
||||
if (hovered) {
|
||||
onHovered();
|
||||
setAnchorEl(event.currentTarget);
|
||||
return;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<TurnIntoPopover
|
||||
id={id}
|
||||
open={open}
|
||||
disableRestoreFocus
|
||||
disableAutoFocus
|
||||
sx={{
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
PaperProps={{
|
||||
style: {
|
||||
pointerEvents: 'auto',
|
||||
},
|
||||
}}
|
||||
onClose={onClose}
|
||||
anchorEl={anchorEl}
|
||||
anchorOrigin={{
|
||||
vertical: 'center',
|
||||
horizontal: 'right',
|
||||
}}
|
||||
transformOrigin={{
|
||||
vertical: 'center',
|
||||
horizontal: 'left',
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default BlockMenuTurnInto;
|
@ -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<number, number> = {
|
||||
1: 7,
|
||||
@ -10,7 +11,6 @@ const headingBlockTopOffset: Record<number, number> = {
|
||||
};
|
||||
export function useBlockSideToolbar({ container }: { container: HTMLDivElement }) {
|
||||
const [nodeId, setHoverNodeId] = useState<string | null>(null);
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const ref = useRef<HTMLDivElement | null>(null);
|
||||
const dispatch = useAppDispatch();
|
||||
const [style, setStyle] = useState<React.CSSProperties>({});
|
||||
@ -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<HTMLButtonElement | null>(null);
|
||||
|
||||
const onClose = useCallback(() => {
|
||||
setAnchorEl(null);
|
||||
}, []);
|
||||
|
||||
const handleOpen = useCallback((e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
e.preventDefault();
|
||||
setAnchorEl(e.currentTarget);
|
||||
}, []);
|
||||
|
||||
const open = Boolean(anchorEl);
|
||||
|
||||
return {
|
||||
anchorEl,
|
||||
onClose,
|
||||
open,
|
||||
handleOpen,
|
||||
disableAutoFocus: true,
|
||||
...origin,
|
||||
};
|
||||
}
|
||||
|
@ -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<HTMLDivElement>) => void;
|
||||
}) {
|
||||
return (
|
||||
<ListItem disablePadding>
|
||||
<ListItemButton
|
||||
onMouseEnter={(e) => onHover?.(true, e)}
|
||||
onMouseLeave={(e) => onHover?.(false, e)}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onClick?.();
|
||||
}}
|
||||
>
|
||||
<ListItemIcon>{icon}</ListItemIcon>
|
||||
<ListItemText primary={title} />
|
||||
{extra}
|
||||
</ListItemButton>
|
||||
</ListItem>
|
||||
);
|
||||
}
|
||||
|
||||
export default MenuItem;
|
@ -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<HTMLButtonElement>;
|
||||
}) => {
|
||||
return (
|
||||
<Tooltip title={tooltip} placement={'top-start'}>
|
||||
<IconButton onClick={onClick} sx={sx}>
|
||||
{children}
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
||||
export default ToolbarButton;
|
@ -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 (
|
||||
<>
|
||||
<Portal blockId={nodeId}>
|
||||
@ -32,15 +41,41 @@ export default function BlockSideToolbar(props: { container: HTMLDivElement }) {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<IconButton onClick={() => handleToggleMenu(true)} sx={sx}>
|
||||
<ExpandCircleDownSharpIcon />
|
||||
</IconButton>
|
||||
<IconButton sx={sx}>
|
||||
{/** Add Block below */}
|
||||
<ToolbarButton
|
||||
tooltip={'Add a new block below'}
|
||||
onClick={(e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
if (!nodeId || !controller) return;
|
||||
dispatch(
|
||||
addBlockBelowClickThunk({
|
||||
id: nodeId,
|
||||
controller,
|
||||
})
|
||||
);
|
||||
}}
|
||||
>
|
||||
<AddSharpIcon />
|
||||
</ToolbarButton>
|
||||
|
||||
{/** Open menu or drag */}
|
||||
<ToolbarButton
|
||||
tooltip={'Click to open Menu'}
|
||||
onClick={(e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
if (!nodeId) return;
|
||||
dispatch(rectSelectionActions.setSelectionById(nodeId));
|
||||
handleOpen(e);
|
||||
}}
|
||||
>
|
||||
<DragIndicatorRoundedIcon />
|
||||
</IconButton>
|
||||
</ToolbarButton>
|
||||
</div>
|
||||
</Portal>
|
||||
<BlockMenu open={menuOpen} onClose={() => handleToggleMenu(false)} nodeId={nodeId} />
|
||||
|
||||
{showPopover && (
|
||||
<Popover {...popoverProps}>
|
||||
<BlockMenu id={nodeId} onClose={popoverProps.onClose} />
|
||||
</Popover>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
@ -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<any>) => {
|
||||
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: <TextFields />,
|
||||
},
|
||||
{
|
||||
type: BlockType.HeadingBlock,
|
||||
title: 'Heading 1',
|
||||
icon: <Title />,
|
||||
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;
|
@ -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,
|
||||
};
|
||||
}
|
@ -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;
|
@ -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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
@ -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(
|
||||
|
@ -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>
|
||||
|
@ -9,4 +9,5 @@ export const keyBoardEventKeyMap = {
|
||||
Space: ' ',
|
||||
Reduce: '-',
|
||||
Backquote: '`',
|
||||
Slash: '/',
|
||||
};
|
||||
|
@ -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[];
|
||||
|
@ -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;
|
||||
}
|
||||
);
|
@ -1 +1,4 @@
|
||||
export * from './text';
|
||||
export * from './delete';
|
||||
export * from './duplicate';
|
||||
export * from './insert';
|
||||
|
@ -1,6 +1,4 @@
|
||||
export * from './delete';
|
||||
export * from './indent';
|
||||
export * from './insert';
|
||||
export * from './backspace';
|
||||
export * from './outdent';
|
||||
export * from './split';
|
||||
|
@ -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 }));
|
||||
}
|
||||
);
|
@ -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;
|
||||
|
Loading…
Reference in New Issue
Block a user