Support block toolbar (#2566)

* feat: support block toolbar in left side

* fix: export delete and duplicate

* feat: slash menu
This commit is contained in:
Kilu.He 2023-05-22 09:33:37 +08:00 committed by GitHub
parent ca7777e891
commit b41b212b0d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
26 changed files with 745 additions and 181 deletions

View File

@ -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,
};
}

View File

@ -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;
}

View File

@ -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;

View File

@ -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);

View File

@ -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,
};
}

View File

@ -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;

View File

@ -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;

View File

@ -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,
};
}

View File

@ -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;

View File

@ -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;

View File

@ -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>
)}
</>
);
}

View File

@ -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;

View File

@ -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,
};
}

View File

@ -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;

View File

@ -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 />
</>
);
}

View File

@ -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(

View File

@ -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>

View File

@ -9,4 +9,5 @@ export const keyBoardEventKeyMap = {
Space: ' ',
Reduce: '-',
Backquote: '`',
Slash: '/',
};

View File

@ -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[];

View File

@ -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;
}
);

View File

@ -1 +1,4 @@
export * from './text';
export * from './delete';
export * from './duplicate';
export * from './insert';

View File

@ -1,6 +1,4 @@
export * from './delete';
export * from './indent';
export * from './insert';
export * from './backspace';
export * from './outdent';
export * from './split';

View File

@ -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 }));
}
);

View File

@ -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;