feat: support toggle list (#2456)

This commit is contained in:
Kilu.He 2023-05-05 10:13:39 +08:00 committed by GitHub
parent e2ced6524f
commit 12151d1f3b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 231 additions and 46 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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,
/**
* ```
*/

View File

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

View File

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

View File

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

View File

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