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 { BlockType, HeadingBlockData, NestedBlock } from '@/appflowy_app/interfaces/document';
|
||||||
import { useAppDispatch } from "@/appflowy_app/stores/store";
|
import { useAppDispatch } from '@/appflowy_app/stores/store';
|
||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { getBlockByIdThunk } from "$app_reducers/document/async-actions";
|
import { getBlockByIdThunk } from '$app_reducers/document/async-actions';
|
||||||
|
import { PopoverOrigin } from '@mui/material/Popover/Popover';
|
||||||
|
|
||||||
const headingBlockTopOffset: Record<number, number> = {
|
const headingBlockTopOffset: Record<number, number> = {
|
||||||
1: 7,
|
1: 7,
|
||||||
@ -10,7 +11,6 @@ const headingBlockTopOffset: Record<number, number> = {
|
|||||||
};
|
};
|
||||||
export function useBlockSideToolbar({ container }: { container: HTMLDivElement }) {
|
export function useBlockSideToolbar({ container }: { container: HTMLDivElement }) {
|
||||||
const [nodeId, setHoverNodeId] = useState<string | null>(null);
|
const [nodeId, setHoverNodeId] = useState<string | null>(null);
|
||||||
const [menuOpen, setMenuOpen] = useState(false);
|
|
||||||
const ref = useRef<HTMLDivElement | null>(null);
|
const ref = useRef<HTMLDivElement | null>(null);
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const [style, setStyle] = useState<React.CSSProperties>({});
|
const [style, setStyle] = useState<React.CSSProperties>({});
|
||||||
@ -18,8 +18,8 @@ export function useBlockSideToolbar({ container }: { container: HTMLDivElement }
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const el = ref.current;
|
const el = ref.current;
|
||||||
if (!el || !nodeId) return;
|
if (!el || !nodeId) return;
|
||||||
void(async () => {
|
void (async () => {
|
||||||
const{ payload: node } = await dispatch(getBlockByIdThunk(nodeId)) as {
|
const { payload: node } = (await dispatch(getBlockByIdThunk(nodeId))) as {
|
||||||
payload: NestedBlock;
|
payload: NestedBlock;
|
||||||
};
|
};
|
||||||
if (!node) {
|
if (!node) {
|
||||||
@ -43,16 +43,8 @@ export function useBlockSideToolbar({ container }: { container: HTMLDivElement }
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
|
||||||
}, [dispatch, nodeId]);
|
}, [dispatch, nodeId]);
|
||||||
|
|
||||||
const handleToggleMenu = useCallback((isOpen: boolean) => {
|
|
||||||
setMenuOpen(isOpen);
|
|
||||||
if (!isOpen) {
|
|
||||||
setHoverNodeId('');
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleMouseMove = useCallback((e: MouseEvent) => {
|
const handleMouseMove = useCallback((e: MouseEvent) => {
|
||||||
const { clientX, clientY } = e;
|
const { clientX, clientY } = e;
|
||||||
const id = getNodeIdByPoint(clientX, clientY);
|
const id = getNodeIdByPoint(clientX, clientY);
|
||||||
@ -69,9 +61,7 @@ export function useBlockSideToolbar({ container }: { container: HTMLDivElement }
|
|||||||
return {
|
return {
|
||||||
nodeId,
|
nodeId,
|
||||||
ref,
|
ref,
|
||||||
handleToggleMenu,
|
style,
|
||||||
menuOpen,
|
|
||||||
style
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -102,3 +92,40 @@ function getNodeIdByPoint(x: number, y: number) {
|
|||||||
).el.getAttribute('data-block-id')
|
).el.getAttribute('data-block-id')
|
||||||
: null;
|
: 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 React, { useCallback, useContext, useState } from 'react';
|
||||||
import { useBlockSideToolbar } from './BlockSideToolbar.hooks';
|
import { useBlockSideToolbar, usePopover } from './BlockSideToolbar.hooks';
|
||||||
import ExpandCircleDownSharpIcon from '@mui/icons-material/ExpandCircleDownSharp';
|
|
||||||
import DragIndicatorRoundedIcon from '@mui/icons-material/DragIndicatorRounded';
|
|
||||||
import Portal from '../BlockPortal';
|
import Portal from '../BlockPortal';
|
||||||
import { IconButton } from '@mui/material';
|
import { useAppDispatch, useAppSelector } from '$app/stores/store';
|
||||||
import BlockMenu from '../BlockMenu';
|
import Popover from '@mui/material/Popover';
|
||||||
import { useAppSelector } from '$app/stores/store';
|
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({ container }: { container: HTMLDivElement }) {
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
export default function BlockSideToolbar(props: { container: HTMLDivElement }) {
|
const controller = useContext(DocumentControllerContext);
|
||||||
const { nodeId, style, ref, menuOpen, handleToggleMenu } = useBlockSideToolbar(props);
|
const { nodeId, style, ref } = useBlockSideToolbar({ container });
|
||||||
const isDragging = useAppSelector(
|
const isDragging = useAppSelector(
|
||||||
(state) => state.documentRangeSelection.isDragging || state.documentRectSelection.isDragging
|
(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;
|
if (!nodeId || isDragging) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Portal blockId={nodeId}>
|
<Portal blockId={nodeId}>
|
||||||
@ -32,15 +41,41 @@ export default function BlockSideToolbar(props: { container: HTMLDivElement }) {
|
|||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<IconButton onClick={() => handleToggleMenu(true)} sx={sx}>
|
{/** Add Block below */}
|
||||||
<ExpandCircleDownSharpIcon />
|
<ToolbarButton
|
||||||
</IconButton>
|
tooltip={'Add a new block below'}
|
||||||
<IconButton sx={sx}>
|
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 />
|
<DragIndicatorRoundedIcon />
|
||||||
</IconButton>
|
</ToolbarButton>
|
||||||
</div>
|
</div>
|
||||||
</Portal>
|
</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 BlockSideToolbar from '../BlockSideToolbar';
|
||||||
import BlockSelection from '../BlockSelection';
|
import BlockSelection from '../BlockSelection';
|
||||||
import TextActionMenu from '$app/components/document/TextActionMenu';
|
import TextActionMenu from '$app/components/document/TextActionMenu';
|
||||||
|
import BlockSlash from '$app/components/document/BlockSlash';
|
||||||
|
|
||||||
export default function Overlay({ container }: { container: HTMLDivElement }) {
|
export default function Overlay({ container }: { container: HTMLDivElement }) {
|
||||||
return (
|
return (
|
||||||
@ -9,6 +10,7 @@ export default function Overlay({ container }: { container: HTMLDivElement }) {
|
|||||||
<BlockSideToolbar container={container} />
|
<BlockSideToolbar container={container} />
|
||||||
<TextActionMenu container={container} />
|
<TextActionMenu container={container} />
|
||||||
<BlockSelection container={container} />
|
<BlockSelection container={container} />
|
||||||
|
<BlockSlash />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -2,13 +2,16 @@ import { Editor } from 'slate';
|
|||||||
import { useTurnIntoBlock } from './TurnIntoEvents.hooks';
|
import { useTurnIntoBlock } from './TurnIntoEvents.hooks';
|
||||||
import { useCallback, useContext, useMemo } from 'react';
|
import { useCallback, useContext, useMemo } from 'react';
|
||||||
import { keyBoardEventKeyMap } from '$app/constants/document/text_block';
|
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 isHotkey from 'is-hotkey';
|
||||||
import { indentNodeThunk, outdentNodeThunk, splitNodeThunk } from '$app_reducers/document/async-actions';
|
import { indentNodeThunk, outdentNodeThunk, splitNodeThunk } from '$app_reducers/document/async-actions';
|
||||||
import { DocumentControllerContext } from '$app/stores/effects/document/document_controller';
|
import { DocumentControllerContext } from '$app/stores/effects/document/document_controller';
|
||||||
import { useAppDispatch, useAppSelector } from '$app/stores/store';
|
import { useAppDispatch, useAppSelector } from '$app/stores/store';
|
||||||
import { useDefaultTextInputEvents } from '$app/components/document/_shared/Text/TextEvents.hooks';
|
import { useDefaultTextInputEvents } from '$app/components/document/_shared/Text/TextEvents.hooks';
|
||||||
import { ReactEditor } from 'slate-react';
|
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) {
|
export function useTextBlockKeyEvent(id: string, editor: ReactEditor) {
|
||||||
const controller = useContext(DocumentControllerContext);
|
const controller = useContext(DocumentControllerContext);
|
||||||
@ -20,6 +23,9 @@ export function useTextBlockKeyEvent(id: string, editor: ReactEditor) {
|
|||||||
return anchor.id === id && focus.id === id;
|
return anchor.id === id && focus.id === id;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const { node } = useSubscribeNode(id);
|
||||||
|
const nodeType = node?.type;
|
||||||
|
|
||||||
const { turnIntoBlockEvents } = useTurnIntoBlock(id);
|
const { turnIntoBlockEvents } = useTurnIntoBlock(id);
|
||||||
|
|
||||||
// Here custom key events for TextBlock
|
// 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(
|
const onKeyDown = useCallback(
|
||||||
|
@ -43,12 +43,7 @@ export default function VirtualizedList({
|
|||||||
{virtualItems.map((virtualRow) => {
|
{virtualItems.map((virtualRow) => {
|
||||||
const id = childIds[virtualRow.index];
|
const id = childIds[virtualRow.index];
|
||||||
return (
|
return (
|
||||||
<div
|
<div className='pt-0.5' key={id} data-index={virtualRow.index} ref={virtualize.measureElement}>
|
||||||
className='float-left w-[100%]'
|
|
||||||
key={id}
|
|
||||||
data-index={virtualRow.index}
|
|
||||||
ref={virtualize.measureElement}
|
|
||||||
>
|
|
||||||
{virtualRow.index === 0 ? <DocumentTitle id={node.id} /> : null}
|
{virtualRow.index === 0 ? <DocumentTitle id={node.id} /> : null}
|
||||||
{renderNode(id)}
|
{renderNode(id)}
|
||||||
</div>
|
</div>
|
||||||
|
@ -9,4 +9,5 @@ export const keyBoardEventKeyMap = {
|
|||||||
Space: ' ',
|
Space: ' ',
|
||||||
Reduce: '-',
|
Reduce: '-',
|
||||||
Backquote: '`',
|
Backquote: '`',
|
||||||
|
Slash: '/',
|
||||||
};
|
};
|
||||||
|
@ -131,6 +131,10 @@ export interface DocumentState {
|
|||||||
// map of block id to children block ids
|
// map of block id to children block ids
|
||||||
children: Record<string, string[]>;
|
children: Record<string, string[]>;
|
||||||
}
|
}
|
||||||
|
export interface SlashCommandState {
|
||||||
|
isSlashCommand: boolean;
|
||||||
|
blockId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface RectSelectionState {
|
export interface RectSelectionState {
|
||||||
selection: string[];
|
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 './text';
|
||||||
|
export * from './delete';
|
||||||
|
export * from './duplicate';
|
||||||
|
export * from './insert';
|
||||||
|
@ -1,6 +1,4 @@
|
|||||||
export * from './delete';
|
|
||||||
export * from './indent';
|
export * from './indent';
|
||||||
export * from './insert';
|
|
||||||
export * from './backspace';
|
export * from './backspace';
|
||||||
export * from './outdent';
|
export * from './outdent';
|
||||||
export * from './split';
|
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,
|
PointState,
|
||||||
RangeSelectionState,
|
RangeSelectionState,
|
||||||
RectSelectionState,
|
RectSelectionState,
|
||||||
|
SlashCommandState,
|
||||||
} from '@/appflowy_app/interfaces/document';
|
} from '@/appflowy_app/interfaces/document';
|
||||||
import { BlockEventPayloadPB } from '@/services/backend';
|
import { BlockEventPayloadPB } from '@/services/backend';
|
||||||
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
|
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
|
||||||
import { parseValue, matchChange } from '$app/utils/document/subscribe';
|
import { parseValue, matchChange } from '$app/utils/document/subscribe';
|
||||||
import { getNodesInRange } from '$app/utils/document/blocks/common';
|
|
||||||
|
|
||||||
const initialState: DocumentState = {
|
const initialState: DocumentState = {
|
||||||
nodes: {},
|
nodes: {},
|
||||||
@ -25,6 +25,10 @@ const rangeSelectionInitialState: RangeSelectionState = {
|
|||||||
selection: [],
|
selection: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const slashCommandInitialState: SlashCommandState = {
|
||||||
|
isSlashCommand: false,
|
||||||
|
};
|
||||||
|
|
||||||
export const documentSlice = createSlice({
|
export const documentSlice = createSlice({
|
||||||
name: 'document',
|
name: 'document',
|
||||||
initialState: initialState,
|
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 = {
|
export const documentReducers = {
|
||||||
[documentSlice.name]: documentSlice.reducer,
|
[documentSlice.name]: documentSlice.reducer,
|
||||||
[rectSelectionSlice.name]: rectSelectionSlice.reducer,
|
[rectSelectionSlice.name]: rectSelectionSlice.reducer,
|
||||||
[rangeSelectionSlice.name]: rangeSelectionSlice.reducer,
|
[rangeSelectionSlice.name]: rangeSelectionSlice.reducer,
|
||||||
|
[slashCommandSlice.name]: slashCommandSlice.reducer,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const documentActions = documentSlice.actions;
|
export const documentActions = documentSlice.actions;
|
||||||
export const rectSelectionActions = rectSelectionSlice.actions;
|
export const rectSelectionActions = rectSelectionSlice.actions;
|
||||||
export const rangeSelectionActions = rangeSelectionSlice.actions;
|
export const rangeSelectionActions = rangeSelectionSlice.actions;
|
||||||
|
|
||||||
|
export const slashCommandActions = slashCommandSlice.actions;
|
||||||
|
Loading…
x
Reference in New Issue
Block a user