mirror of
https://github.com/AppFlowy-IO/AppFlowy.git
synced 2024-08-30 18:12:39 +00:00
feat: support toggle list (#2456)
This commit is contained in:
parent
e2ced6524f
commit
12151d1f3b
@ -8,7 +8,7 @@ function BulletedListBlock({ node, childIds }: { node: NestedBlock<BlockType.Bul
|
||||
return (
|
||||
<>
|
||||
<div className={'flex'}>
|
||||
<div className={`relative flex h-[calc(1.5em_+_2px)] min-w-[24px] select-none items-center`}>
|
||||
<div className={`relative flex h-[calc(1.5em_+_2px)] min-w-[1.5em] select-none items-center px-1`}>
|
||||
<Circle sx={{ width: 8, height: 8 }} />
|
||||
</div>
|
||||
<div className={'flex-1'}>
|
||||
|
@ -5,12 +5,14 @@ import { ErrorBoundaryFallbackComponent } from '../_shared/ErrorBoundaryFallback
|
||||
import TextBlock from '../TextBlock';
|
||||
import { NodeContext } from '../_shared/SubscribeNode.hooks';
|
||||
import { BlockType } from '$app/interfaces/document';
|
||||
import { Alert } from '@mui/material';
|
||||
|
||||
import HeadingBlock from '$app/components/document/HeadingBlock';
|
||||
import TodoListBlock from '$app/components/document/TodoListBlock';
|
||||
import QuoteBlock from '$app/components/document/QuoteBlock';
|
||||
import BulletedListBlock from '$app/components/document/BulletedListBlock';
|
||||
import NumberedListBlock from '$app/components/document/NumberedListBlock';
|
||||
import { Alert } from '@mui/material';
|
||||
import ToggleListBlock from '$app/components/document/ToggleListBlock';
|
||||
|
||||
function NodeComponent({ id, ...props }: { id: string } & React.HTMLAttributes<HTMLDivElement>) {
|
||||
const { node, childIds, isSelected, ref } = useNode(id);
|
||||
@ -35,6 +37,9 @@ function NodeComponent({ id, ...props }: { id: string } & React.HTMLAttributes<H
|
||||
case BlockType.NumberedListBlock: {
|
||||
return <NumberedListBlock node={node} childIds={childIds} />;
|
||||
}
|
||||
case BlockType.ToggleListBlock: {
|
||||
return <ToggleListBlock node={node} childIds={childIds} />;
|
||||
}
|
||||
default:
|
||||
return (
|
||||
<Alert severity='info' className='mb-2'>
|
||||
@ -48,7 +53,7 @@ function NodeComponent({ id, ...props }: { id: string } & React.HTMLAttributes<H
|
||||
|
||||
return (
|
||||
<NodeContext.Provider value={node}>
|
||||
<div {...props} ref={ref} data-block-id={node.id} className={`relative px-1 ${props.className}`}>
|
||||
<div {...props} ref={ref} data-block-id={node.id} className={`relative ${props.className}`}>
|
||||
{renderBlock()}
|
||||
<div className='block-overlay' />
|
||||
{isSelected ? (
|
||||
|
@ -11,7 +11,7 @@ function NumberedListBlock({ node, childIds }: { node: NestedBlock<BlockType.Num
|
||||
<>
|
||||
<div className={'flex'}>
|
||||
<div
|
||||
className={`relative flex h-[calc(1.5em_+_4px)] min-w-[24px] select-none items-center whitespace-nowrap text-center`}
|
||||
className={`relative flex h-[calc(1.5em_+_4px)] min-w-[1.5em] select-none items-center whitespace-nowrap px-1 text-left`}
|
||||
>
|
||||
{index}.
|
||||
</div>
|
||||
|
@ -13,6 +13,7 @@ import {
|
||||
getTodoListDataFromEditor,
|
||||
getBulletedDataFromEditor,
|
||||
getNumberedListDataFromEditor,
|
||||
getToggleListDataFromEditor,
|
||||
} from '$app/utils/document/blocks';
|
||||
|
||||
const blockDataFactoryMap: Record<string, (editor: Editor) => BlockData<any> | undefined> = {
|
||||
@ -20,7 +21,8 @@ const blockDataFactoryMap: Record<string, (editor: Editor) => BlockData<any> | u
|
||||
[BlockType.TodoListBlock]: getTodoListDataFromEditor,
|
||||
[BlockType.QuoteBlock]: getQuoteDataFromEditor,
|
||||
[BlockType.BulletedListBlock]: getBulletedDataFromEditor,
|
||||
[BlockType.NumberedListBlock]: getNumberedListDataFromEditor
|
||||
[BlockType.NumberedListBlock]: getNumberedListDataFromEditor,
|
||||
[BlockType.ToggleListBlock]: getToggleListDataFromEditor,
|
||||
};
|
||||
|
||||
export function useTurnIntoBlock(id: string) {
|
||||
|
@ -21,7 +21,7 @@ function TextBlock({
|
||||
|
||||
return (
|
||||
<>
|
||||
<div {...props} className={`py-[2px]${className}`}>
|
||||
<div {...props} className={`px-1 py-[2px]${className}`}>
|
||||
<Slate editor={editor} onChange={onChange} value={value}>
|
||||
<BlockHorizontalToolbar id={node.id} />
|
||||
<Editable
|
||||
|
@ -21,7 +21,7 @@ export default function TodoListBlock({
|
||||
return (
|
||||
<>
|
||||
<div className={'flex'} onKeyDownCapture={handleShortcut}>
|
||||
<div className={'flex h-[calc(1.5em_+_2px)] w-[24px] select-none items-center justify-start'}>
|
||||
<div className={'flex h-[calc(1.5em_+_2px)] w-[1.5em] select-none items-center justify-start px-1'}>
|
||||
<div className={'relative flex h-4 w-4 items-center justify-start transition'}>
|
||||
<div>{checked ? <EditorCheckSvg /> : <EditorUncheckSvg />}</div>
|
||||
<input
|
||||
|
@ -0,0 +1,38 @@
|
||||
import { useAppDispatch } from '$app/stores/store';
|
||||
import { useCallback, useContext } from 'react';
|
||||
import { DocumentControllerContext } from '$app/stores/effects/document/document_controller';
|
||||
import { updateNodeDataThunk } from '$app_reducers/document/async-actions/blocks/text/update';
|
||||
import { BlockData, BlockType } from '$app/interfaces/document';
|
||||
import isHotkey from 'is-hotkey';
|
||||
|
||||
export function useToggleListBlock(id: string, data: BlockData<BlockType.ToggleListBlock>) {
|
||||
const dispatch = useAppDispatch();
|
||||
const controller = useContext(DocumentControllerContext);
|
||||
const toggleCollapsed = useCallback(() => {
|
||||
if (!controller) return;
|
||||
void dispatch(
|
||||
updateNodeDataThunk({
|
||||
id,
|
||||
controller,
|
||||
data: {
|
||||
collapsed: !data.collapsed,
|
||||
},
|
||||
})
|
||||
);
|
||||
}, [controller, dispatch, id, data.collapsed]);
|
||||
|
||||
const handleShortcut = useCallback(
|
||||
(event: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
// Accepts mod for the classic "cmd on Mac, ctrl on Windows" use case.
|
||||
if (isHotkey('mod+enter', event)) {
|
||||
toggleCollapsed();
|
||||
}
|
||||
},
|
||||
[toggleCollapsed]
|
||||
);
|
||||
|
||||
return {
|
||||
toggleCollapsed,
|
||||
handleShortcut,
|
||||
};
|
||||
}
|
@ -0,0 +1,41 @@
|
||||
import React from 'react';
|
||||
import { BlockType, NestedBlock } from '$app/interfaces/document';
|
||||
import TextBlock from '$app/components/document/TextBlock';
|
||||
import NodeChildren from '$app/components/document/Node/NodeChildren';
|
||||
import { useToggleListBlock } from '$app/components/document/ToggleListBlock/ToggleListBlock.hooks';
|
||||
import { IconButton } from '@mui/material';
|
||||
import { DropDownShowSvg } from '$app/components/_shared/svg/DropDownShowSvg';
|
||||
import Button from '@mui/material/Button';
|
||||
|
||||
function ToggleListBlock({ node, childIds }: { node: NestedBlock<BlockType.ToggleListBlock>; childIds?: string[] }) {
|
||||
const { toggleCollapsed, handleShortcut } = useToggleListBlock(node.id, node.data);
|
||||
const collapsed = node.data.collapsed;
|
||||
return (
|
||||
<>
|
||||
<div className={'flex'} onKeyDownCapture={handleShortcut}>
|
||||
<div className={`relative h-[calc(1.5em_+_2px)] w-[1.5em] select-none overflow-hidden px-1`}>
|
||||
<Button
|
||||
variant={'text'}
|
||||
color={'inherit'}
|
||||
size={'small'}
|
||||
onClick={toggleCollapsed}
|
||||
style={{
|
||||
minWidth: '20px',
|
||||
padding: 0,
|
||||
}}
|
||||
className={`transition-transform duration-500 ${collapsed && 'rotate-[-90deg]'}`}
|
||||
>
|
||||
<DropDownShowSvg />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className={'flex-1'}>
|
||||
<TextBlock node={node} />
|
||||
</div>
|
||||
</div>
|
||||
{!collapsed && <NodeChildren className='pl-[1.5em]' childIds={childIds} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default ToggleListBlock;
|
@ -1,5 +1,9 @@
|
||||
import { BlockType } from '$app/interfaces/document';
|
||||
import { BlockData, BlockType } from '$app/interfaces/document';
|
||||
|
||||
export enum SplitRelationship {
|
||||
NextSibling,
|
||||
FirstChild,
|
||||
}
|
||||
/**
|
||||
* If the block type is not in the config, it will be thrown an error in development env
|
||||
*/
|
||||
@ -10,23 +14,47 @@ export const blockConfig: Record<
|
||||
* Whether the block can have children
|
||||
*/
|
||||
canAddChild: boolean;
|
||||
/**
|
||||
* the type of the block that will be split from the current block
|
||||
*/
|
||||
splitType: BlockType;
|
||||
/**
|
||||
* The regexps that will be used to match the markdown flag
|
||||
*/
|
||||
markdownRegexps?: RegExp[];
|
||||
|
||||
/**
|
||||
* The default data of the block
|
||||
*/
|
||||
defaultData?: BlockData<any>;
|
||||
|
||||
/**
|
||||
* The props that will be passed to the text split function
|
||||
*/
|
||||
splitProps?: {
|
||||
/**
|
||||
* The relationship between the next line block and the current block
|
||||
*/
|
||||
nextLineRelationShip: SplitRelationship;
|
||||
/**
|
||||
* The type of the next line block
|
||||
*/
|
||||
nextLineBlockType: BlockType;
|
||||
};
|
||||
}
|
||||
> = {
|
||||
[BlockType.TextBlock]: {
|
||||
canAddChild: true,
|
||||
splitType: BlockType.TextBlock,
|
||||
defaultData: {
|
||||
delta: [],
|
||||
},
|
||||
splitProps: {
|
||||
nextLineRelationShip: SplitRelationship.NextSibling,
|
||||
nextLineBlockType: BlockType.TextBlock,
|
||||
},
|
||||
},
|
||||
[BlockType.HeadingBlock]: {
|
||||
canAddChild: false,
|
||||
splitType: BlockType.TextBlock,
|
||||
splitProps: {
|
||||
nextLineRelationShip: SplitRelationship.NextSibling,
|
||||
nextLineBlockType: BlockType.TextBlock,
|
||||
},
|
||||
/**
|
||||
* # or ## or ###
|
||||
*/
|
||||
@ -34,7 +62,14 @@ export const blockConfig: Record<
|
||||
},
|
||||
[BlockType.TodoListBlock]: {
|
||||
canAddChild: true,
|
||||
splitType: BlockType.TodoListBlock,
|
||||
defaultData: {
|
||||
delta: [],
|
||||
checked: false,
|
||||
},
|
||||
splitProps: {
|
||||
nextLineRelationShip: SplitRelationship.NextSibling,
|
||||
nextLineBlockType: BlockType.TodoListBlock,
|
||||
},
|
||||
/**
|
||||
* -[] or -[x] or -[ ] or [] or [x] or [ ]
|
||||
*/
|
||||
@ -42,7 +77,14 @@ export const blockConfig: Record<
|
||||
},
|
||||
[BlockType.BulletedListBlock]: {
|
||||
canAddChild: true,
|
||||
splitType: BlockType.BulletedListBlock,
|
||||
defaultData: {
|
||||
delta: [],
|
||||
format: 'default',
|
||||
},
|
||||
splitProps: {
|
||||
nextLineRelationShip: SplitRelationship.NextSibling,
|
||||
nextLineBlockType: BlockType.BulletedListBlock,
|
||||
},
|
||||
/**
|
||||
* - or + or *
|
||||
*/
|
||||
@ -50,7 +92,14 @@ export const blockConfig: Record<
|
||||
},
|
||||
[BlockType.NumberedListBlock]: {
|
||||
canAddChild: true,
|
||||
splitType: BlockType.NumberedListBlock,
|
||||
defaultData: {
|
||||
delta: [],
|
||||
format: 'default',
|
||||
},
|
||||
splitProps: {
|
||||
nextLineRelationShip: SplitRelationship.NextSibling,
|
||||
nextLineBlockType: BlockType.NumberedListBlock,
|
||||
},
|
||||
/**
|
||||
* 1. or 2. or 3.
|
||||
* a. or b. or c.
|
||||
@ -59,15 +108,36 @@ export const blockConfig: Record<
|
||||
},
|
||||
[BlockType.QuoteBlock]: {
|
||||
canAddChild: true,
|
||||
splitType: BlockType.TextBlock,
|
||||
defaultData: {
|
||||
delta: [],
|
||||
size: 'default',
|
||||
},
|
||||
splitProps: {
|
||||
nextLineRelationShip: SplitRelationship.NextSibling,
|
||||
nextLineBlockType: BlockType.TextBlock,
|
||||
},
|
||||
/**
|
||||
* " or “ or ”
|
||||
*/
|
||||
markdownRegexps: [/^("|“|”)$/],
|
||||
},
|
||||
[BlockType.ToggleListBlock]: {
|
||||
canAddChild: true,
|
||||
defaultData: {
|
||||
delta: [],
|
||||
collapsed: false,
|
||||
},
|
||||
splitProps: {
|
||||
nextLineRelationShip: SplitRelationship.FirstChild,
|
||||
nextLineBlockType: BlockType.TextBlock,
|
||||
},
|
||||
/**
|
||||
* >
|
||||
*/
|
||||
markdownRegexps: [/^(>)$/],
|
||||
},
|
||||
[BlockType.CodeBlock]: {
|
||||
canAddChild: false,
|
||||
splitType: BlockType.TextBlock,
|
||||
/**
|
||||
* ```
|
||||
*/
|
||||
|
@ -7,6 +7,7 @@ export enum BlockType {
|
||||
TodoListBlock = 'todo_list',
|
||||
BulletedListBlock = 'bulleted_list',
|
||||
NumberedListBlock = 'numbered_list',
|
||||
ToggleListBlock = 'toggle_list',
|
||||
CodeBlock = 'code',
|
||||
EmbedBlock = 'embed',
|
||||
QuoteBlock = 'quote',
|
||||
@ -33,6 +34,10 @@ export interface NumberedListBlockData extends TextBlockData {
|
||||
format: 'default' | 'numbers' | 'letters' | 'roman_numerals';
|
||||
}
|
||||
|
||||
export interface ToggleListBlockData extends TextBlockData {
|
||||
collapsed: boolean;
|
||||
}
|
||||
|
||||
export interface QuoteBlockData extends TextBlockData {
|
||||
size: 'default' | 'large';
|
||||
}
|
||||
@ -55,6 +60,8 @@ export type BlockData<Type> = Type extends BlockType.HeadingBlock
|
||||
? BulletListBlockData
|
||||
: Type extends BlockType.NumberedListBlock
|
||||
? NumberedListBlockData
|
||||
: Type extends BlockType.ToggleListBlock
|
||||
? ToggleListBlockData
|
||||
: TextBlockData;
|
||||
|
||||
export interface NestedBlock<Type = any> {
|
||||
|
@ -3,8 +3,8 @@ import { DocumentController } from '$app/stores/effects/document/document_contro
|
||||
import { createAsyncThunk } from '@reduxjs/toolkit';
|
||||
import { documentActions } from '$app_reducers/document/slice';
|
||||
import { setCursorBeforeThunk } from '../../cursor';
|
||||
import { getDefaultBlockData, newBlock } from '$app/utils/document/blocks/common';
|
||||
import { blockConfig } from '$app/constants/document/config';
|
||||
import { newBlock } from '$app/utils/document/blocks/common';
|
||||
import { blockConfig, SplitRelationship } from '$app/constants/document/config';
|
||||
|
||||
export const splitNodeThunk = createAsyncThunk(
|
||||
'document/splitNode',
|
||||
@ -18,13 +18,28 @@ export const splitNodeThunk = createAsyncThunk(
|
||||
const node = state.nodes[id];
|
||||
if (!node.parent) return;
|
||||
const children = state.children[node.children];
|
||||
const prevId = node.id;
|
||||
const parent = state.nodes[node.parent];
|
||||
|
||||
const config = blockConfig[node.type];
|
||||
const newNodeType = config.splitType;
|
||||
const defaultData = getDefaultBlockData(newNodeType);
|
||||
const newNode = newBlock<any>(newNodeType, parent.id, {
|
||||
const config = blockConfig[node.type].splitProps;
|
||||
// Here we are using the splitProps property of the blockConfig object to determine the type of the new node.
|
||||
// if the splitProps property is not defined for the block type, we throw an error.
|
||||
if (!config) {
|
||||
throw new Error(`Cannot split node of type ${node.type}`);
|
||||
}
|
||||
const newNodeType = config.nextLineBlockType;
|
||||
const relationShip = config.nextLineRelationShip;
|
||||
const defaultData = blockConfig[newNodeType].defaultData;
|
||||
// if the defaultData property is not defined for the new block type, we throw an error.
|
||||
if (!defaultData) {
|
||||
throw new Error(`Cannot split node of type ${node.type} to ${newNodeType}`);
|
||||
}
|
||||
|
||||
// if the next line is a sibling, parent is the same as the current node, and prev is the current node.
|
||||
// otherwise, parent is the current node, and prev is empty.
|
||||
const newParentId = relationShip === SplitRelationship.NextSibling ? parent.id : node.id;
|
||||
const newPrevId = relationShip === SplitRelationship.NextSibling ? node.id : '';
|
||||
|
||||
const newNode = newBlock<any>(newNodeType, newParentId, {
|
||||
...defaultData,
|
||||
delta: insert,
|
||||
});
|
||||
@ -35,14 +50,22 @@ export const splitNodeThunk = createAsyncThunk(
|
||||
delta: retain,
|
||||
},
|
||||
};
|
||||
const insertAction = controller.getInsertAction(newNode, prevId);
|
||||
const insertAction = controller.getInsertAction(newNode, newPrevId);
|
||||
const updateAction = controller.getUpdateAction(retainNode);
|
||||
const moveChildrenAction = controller.getMoveChildrenAction(
|
||||
|
||||
// if the next line is a sibling, we need to move the children of the current node to the new node.
|
||||
// otherwise, we don't need to do anything.
|
||||
const moveChildrenAction =
|
||||
relationShip === SplitRelationship.NextSibling
|
||||
? controller.getMoveChildrenAction(
|
||||
children.map((id) => state.nodes[id]),
|
||||
newNode.id,
|
||||
''
|
||||
);
|
||||
)
|
||||
: [];
|
||||
|
||||
await controller.applyActions([insertAction, ...moveChildrenAction, updateAction]);
|
||||
|
||||
// update local node data
|
||||
dispatch(documentActions.updateNodeData({ id: retainNode.id, data: { delta: retain } }));
|
||||
// set cursor
|
||||
|
@ -119,17 +119,3 @@ export function newBlock<Type>(type: BlockType, parentId: string, data: BlockDat
|
||||
data,
|
||||
};
|
||||
}
|
||||
|
||||
export function getDefaultBlockData(type: BlockType) {
|
||||
switch (type) {
|
||||
case BlockType.TodoListBlock:
|
||||
return {
|
||||
checked: false,
|
||||
delta: [],
|
||||
};
|
||||
default:
|
||||
return {
|
||||
delta: [],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
@ -4,6 +4,7 @@ import {
|
||||
HeadingBlockData,
|
||||
NumberedListBlockData,
|
||||
TodoListBlockData,
|
||||
ToggleListBlockData,
|
||||
} from '$app/interfaces/document';
|
||||
import { getBeforeRangeAt } from '$app/utils/document/slate/text';
|
||||
import { getDeltaAfterSelection } from '$app/utils/document/blocks/common';
|
||||
@ -81,3 +82,15 @@ export function getNumberedListDataFromEditor(editor: Editor): NumberedListBlock
|
||||
format: 'default',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* get toggle_list data from editor, only support markdown
|
||||
*/
|
||||
export function getToggleListDataFromEditor(editor: Editor): ToggleListBlockData | undefined {
|
||||
const delta = getDeltaAfterSelection(editor);
|
||||
if (!delta) return;
|
||||
return {
|
||||
delta,
|
||||
collapsed: false,
|
||||
};
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user