feat: checkbox block (#2413)

This commit is contained in:
Kilu.He 2023-05-03 11:18:25 +08:00 committed by GitHub
parent 93d787a9ae
commit 7db36e3f1e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
23 changed files with 364 additions and 91 deletions

View File

@ -0,0 +1,14 @@
import React from 'react';
import NodeComponent from '$app/components/document/Node/index';
function NodeChildren({ childIds }: { childIds?: string[] }) {
return childIds && childIds.length > 0 ? (
<div className='pl-[1.5em]'>
{childIds.map((item) => (
<NodeComponent key={item} id={item} />
))}
</div>
) : null;
}
export default NodeChildren;

View File

@ -6,6 +6,7 @@ import TextBlock from '../TextBlock';
import { NodeContext } from '../_shared/SubscribeNode.hooks';
import { BlockType } from '$app/interfaces/document';
import HeadingBlock from '$app/components/document/HeadingBlock';
import TodoListBlock from '$app/components/document/TodoListBlock';
function NodeComponent({ id, ...props }: { id: string } & React.HTMLAttributes<HTMLDivElement>) {
const { node, childIds, isSelected, ref } = useNode(id);
@ -18,6 +19,9 @@ function NodeComponent({ id, ...props }: { id: string } & React.HTMLAttributes<H
case BlockType.HeadingBlock: {
return <HeadingBlock node={node} />;
}
case BlockType.TodoListBlock: {
return <TodoListBlock node={node} childIds={childIds} />;
}
default:
return null;
}

View File

@ -151,15 +151,15 @@ function useTextBlockKeyEvent(id: string, editor: Editor) {
const keyEvents = [enterEvent, backSpaceEvent, tabEvent, upEvent, downEvent, leftEvent, rightEvent];
keyEvents.push(...markdownEvents);
const matchKey = keyEvents.find((keyEvent) => keyEvent.canHandle(event, editor));
if (!matchKey) {
const matchKeys = keyEvents.filter((keyEvent) => keyEvent.canHandle(event, editor));
if (matchKeys.length === 0) {
triggerHotkey(event, editor);
return;
}
event.stopPropagation();
event.preventDefault();
matchKey.handler(event, editor);
matchKeys.forEach((matchKey) => matchKey.handler(event, editor));
},
[editor, enterEvent, backSpaceEvent, tabEvent, upEvent, downEvent, leftEvent, rightEvent, markdownEvents]
);

View File

@ -4,7 +4,8 @@ import { useTextBlock } from './TextBlock.hooks';
import NodeComponent from '../Node';
import BlockHorizontalToolbar from '../BlockHorizontalToolbar';
import React from 'react';
import { BlockType, NestedBlock } from '$app/interfaces/document';
import { NestedBlock } from '$app/interfaces/document';
import NodeChildren from '$app/components/document/Node/NodeChildren';
function TextBlock({
node,
@ -17,9 +18,11 @@ function TextBlock({
placeholder?: string;
} & React.HTMLAttributes<HTMLDivElement>) {
const { editor, value, onChange, onKeyDownCapture, onDOMBeforeInput } = useTextBlock(node.id);
const className = props.className !== undefined ? ` ${props.className}` : '';
return (
<>
<div {...props} className={`py-[2px] ${props.className}`}>
<div {...props} className={`py-[2px]${className}`}>
<Slate editor={editor} onChange={onChange} value={value}>
<BlockHorizontalToolbar id={node.id} />
<Editable
@ -30,13 +33,7 @@ function TextBlock({
/>
</Slate>
</div>
{childIds && childIds.length > 0 ? (
<div className='pl-[1.5em]'>
{childIds.map((item) => (
<NodeComponent key={item} id={item} />
))}
</div>
) : null}
<NodeChildren childIds={childIds} />
</>
);
}

View File

@ -1,13 +1,14 @@
import { useCallback, useContext, useMemo } from 'react';
import { TextBlockKeyEventHandlerParams } from '$app/interfaces/document';
import { keyBoardEventKeyMap } from '$app/constants/document/text_block';
import { canHandleToHeadingBlock } from '$app/utils/document/slate/markdown';
import { canHandleToHeadingBlock, canHandleToCheckboxBlock } from '$app/utils/document/slate/markdown';
import { useAppDispatch } from '$app/stores/store';
import { DocumentControllerContext } from '$app/stores/effects/document/document_controller';
import { turnToHeadingBlockThunk } from '$app_reducers/document/async-actions/blocks/heading';
import { turnToTodoListBlockThunk } from '$app_reducers/document/async-actions/blocks/todo_list';
export function useMarkDown(id: string) {
const { toHeadingBlockAction } = useActions(id);
const { toHeadingBlockAction, toCheckboxBlockAction } = useActions(id);
const toHeadingBlockEvent = useMemo(() => {
return {
triggerEventKey: keyBoardEventKeyMap.Space,
@ -16,7 +17,18 @@ export function useMarkDown(id: string) {
};
}, [toHeadingBlockAction]);
const markdownEvents = useMemo(() => [toHeadingBlockEvent], [toHeadingBlockEvent]);
const toCheckboxBlockEvent = useMemo(() => {
return {
triggerEventKey: keyBoardEventKeyMap.Space,
canHandle: canHandleToCheckboxBlock,
handler: toCheckboxBlockAction,
};
}, [toCheckboxBlockAction]);
const markdownEvents = useMemo(
() => [toHeadingBlockEvent, toCheckboxBlockEvent],
[toHeadingBlockEvent, toCheckboxBlockEvent]
);
return {
markdownEvents,
@ -35,7 +47,17 @@ function useActions(id: string) {
[controller, dispatch, id]
);
const toCheckboxBlockAction = useCallback(
(...args: TextBlockKeyEventHandlerParams) => {
if (!controller) return;
const [_event, editor] = args;
dispatch(turnToTodoListBlockThunk({ id, controller, editor }));
},
[controller, dispatch, id]
);
return {
toHeadingBlockAction,
toCheckboxBlockAction,
};
}

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 useTodoListBlock(id: string, data: BlockData<BlockType.TodoListBlock>) {
const dispatch = useAppDispatch();
const controller = useContext(DocumentControllerContext);
const toggleCheckbox = useCallback(() => {
if (!controller) return;
void dispatch(
updateNodeDataThunk({
id,
controller,
data: {
checked: !data.checked,
},
})
);
}, [controller, dispatch, id, data.checked]);
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)) {
toggleCheckbox();
}
},
[toggleCheckbox]
);
return {
toggleCheckbox,
handleShortcut,
};
}

View File

@ -0,0 +1,42 @@
import { BlockType, NestedBlock } from '$app/interfaces/document';
import TextBlock from '$app/components/document/TextBlock';
import { useTodoListBlock } from '$app/components/document/TodoListBlock/TodoListBlock.hooks';
import { EditorCheckSvg } from '$app/components/_shared/svg/EditorCheckSvg';
import { EditorUncheckSvg } from '$app/components/_shared/svg/EditorUncheckSvg';
import React from 'react';
import NodeChildren from '$app/components/document/Node/NodeChildren';
export default function TodoListBlock({
node,
childIds,
}: {
node: NestedBlock<BlockType.TodoListBlock>;
childIds?: string[];
}) {
const { id, data } = node;
const { toggleCheckbox, handleShortcut } = useTodoListBlock(id, node.data);
const checked = !!data.checked;
return (
<>
<div className={'flex'} onKeyDownCapture={handleShortcut}>
<div className={'flex h-[calc(1.5em_+_2px)] w-[24px] select-none items-center justify-start'}>
<div className={'relative flex h-4 w-4 items-center justify-start transition'}>
<div>{checked ? <EditorCheckSvg /> : <EditorUncheckSvg />}</div>
<input
type={'checkbox'}
checked={checked}
onChange={toggleCheckbox}
className={'absolute h-[100%] w-[100%] cursor-pointer opacity-0'}
/>
</div>
</div>
<div className={'flex-1'}>
<TextBlock node={node} />
</div>
</div>
<NodeChildren childIds={childIds} />
</>
);
}

View File

@ -3,4 +3,9 @@ import { BlockType } from '$app/interfaces/document';
/**
* Block types that are allowed to have children
*/
export const allowedChildrenBlockTypes = [BlockType.TextBlock, BlockType.PageBlock];
export const allowedChildrenBlockTypes = [BlockType.TextBlock, BlockType.PageBlock, BlockType.TodoListBlock];
/**
* Block types that split node can extend to the next line
*/
export const splitableBlockTypes = [BlockType.TextBlock, BlockType.TodoListBlock];

View File

@ -3,8 +3,8 @@ import { Editor } from 'slate';
export enum BlockType {
PageBlock = 'page',
HeadingBlock = 'heading',
ListBlock = 'list',
TextBlock = 'text',
TodoListBlock = 'todo_list',
CodeBlock = 'code',
EmbedBlock = 'embed',
QuoteBlock = 'quote',
@ -18,6 +18,10 @@ export interface HeadingBlockData extends TextBlockData {
level: number;
}
export interface TodoListBlockData extends TextBlockData {
checked: boolean;
}
export interface TextBlockData {
delta: TextDelta[];
}
@ -28,6 +32,8 @@ export type BlockData<Type> = Type extends BlockType.HeadingBlock
? HeadingBlockData
: Type extends BlockType.PageBlock
? PageBlockData
: Type extends BlockType.TodoListBlock
? TodoListBlockData
: TextBlockData;
export interface NestedBlock<Type = any> {

View File

@ -1,42 +1,31 @@
import { createAsyncThunk } from '@reduxjs/toolkit';
import { Editor } from 'slate';
import { DocumentController } from '$app/stores/effects/document/document_controller';
import { DocumentState } from '$app/interfaces/document';
import { getHeadingDataFromEditor, newHeadingBlock } from '$app/utils/document/blocks/heading';
import { setCursorBeforeThunk } from '$app_reducers/document/async-actions/cursor';
import { BlockType } from '$app/interfaces/document';
import { turnToBlockThunk } from '$app_reducers/document/async-actions/turn_to';
import { getHeadingDataFromEditor } from '$app/utils/document/blocks/heading';
/**
* transform to heading block
* 1. insert heading block after current block
* 2. move all children to parent after heading block, because heading block can't have children
* 3. delete current block
*/
export const turnToHeadingBlockThunk = createAsyncThunk(
'document/turnToHeadingBlock',
async (payload: { id: string; editor: Editor; controller: DocumentController }, thunkAPI) => {
const { id, editor, controller } = payload;
const { dispatch, getState } = thunkAPI;
const state = (getState() as { document: DocumentState }).document;
const node = state.nodes[id];
if (!node.parent) return;
const parent = state.nodes[node.parent];
const children = state.children[node.children].map((id) => state.nodes[id]);
/**
* transform to heading block
* 1. insert heading block after current block
* 2. move all children to parent after heading block, because heading block can't have children
* 3. delete current block
*/
const { dispatch } = thunkAPI;
const data = getHeadingDataFromEditor(editor);
if (!data) return;
const headingBlock = newHeadingBlock(parent.id, data);
const insertHeadingAction = controller.getInsertAction(headingBlock, node.id);
const moveChildrenActions = controller.getMoveChildrenAction(children, parent.id, headingBlock.id);
const deleteAction = controller.getDeleteAction(node);
// submit actions
await controller.applyActions([insertHeadingAction, ...moveChildrenActions, deleteAction]);
// set cursor
await dispatch(setCursorBeforeThunk({ id: headingBlock.id }));
await dispatch(
turnToBlockThunk({
id,
controller,
type: BlockType.HeadingBlock,
data,
})
);
}
);

View File

@ -84,7 +84,7 @@ export const backspaceNodeThunk = createAsyncThunk(
const index = children.indexOf(id);
const prevNodeId = children[index - 1];
const nextNodeId = children[index + 1];
// transform to text block
// turn to text block
if (node.type !== BlockType.TextBlock) {
await dispatch(turnToTextBlockThunk({ id, controller }));
return;

View File

@ -1,39 +1,32 @@
import { createAsyncThunk } from '@reduxjs/toolkit';
import { DocumentController } from '$app/stores/effects/document/document_controller';
import { DocumentState } from '$app/interfaces/document';
import { newTextBlock } from '$app/utils/document/blocks/text';
import { setCursorBeforeThunk } from '$app_reducers/document/async-actions/cursor';
import { BlockType, DocumentState } from '$app/interfaces/document';
import { turnToBlockThunk } from '$app_reducers/document/async-actions/turn_to';
/**
* transform to text block
* 1. insert text block after current block
* 2. move children to text block
* 3. delete current block
*/
export const turnToTextBlockThunk = createAsyncThunk(
'document/turnToTextBlock',
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.parent) return;
const parent = state.nodes[node.parent];
const children = state.children[node.children].map((id) => state.nodes[id]);
/**
* transform to text block
* 1. insert text block after current block
* 2. move children to text block
* 3. delete current block
*/
const textBlock = newTextBlock(parent.id, {
const data = {
delta: node.data.delta,
});
const insertTextAction = controller.getInsertAction(textBlock, node.id);
const moveChildrenActions = controller.getMoveChildrenAction(children, textBlock.id, '');
const deleteAction = controller.getDeleteAction(node);
};
// submit actions
await controller.applyActions([insertTextAction, ...moveChildrenActions, deleteAction]);
// set cursor
await dispatch(setCursorBeforeThunk({ id: textBlock.id }));
await dispatch(
turnToBlockThunk({
id,
controller,
type: BlockType.TextBlock,
data,
})
);
}
);

View File

@ -3,7 +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 { newTextBlock } from '$app/utils/document/blocks/text';
import { getDefaultBlockData, newBlock } from '$app/utils/document/blocks/common';
import { splitableBlockTypes } from '$app/constants/document/config';
export const splitNodeThunk = createAsyncThunk(
'document/splitNode',
@ -20,7 +21,10 @@ export const splitNodeThunk = createAsyncThunk(
const prevId = children.length > 0 ? null : node.id;
const parent = children.length > 0 ? node : state.nodes[node.parent];
const newNode = newTextBlock(parent.id, {
const newNodeType = splitableBlockTypes.includes(node.type) ? node.type : BlockType.TextBlock;
const defaultData = getDefaultBlockData(newNodeType);
const newNode = newBlock<any>(newNodeType, parent.id, {
...defaultData,
delta: insert,
});
const retainNode = {

View File

@ -1,4 +1,4 @@
import { TextDelta, NestedBlock, DocumentState } from '$app/interfaces/document';
import { TextDelta, NestedBlock, DocumentState, BlockData } from '$app/interfaces/document';
import { DocumentController } from '$app/stores/effects/document/document_controller';
import { createAsyncThunk } from '@reduxjs/toolkit';
import { documentActions } from '$app_reducers/document/slice';
@ -35,3 +35,29 @@ const debounceApplyUpdate = debounce((controller: DocumentController, updateNode
}),
]);
}, 200);
export const updateNodeDataThunk = createAsyncThunk<
void,
{
id: string;
data: Partial<BlockData<any>>;
controller: DocumentController;
}
>('document/updateNodeDataExceptDelta', async (payload, thunkAPI) => {
const { id, data, controller } = payload;
const { dispatch, getState } = thunkAPI;
const state = (getState() as { document: DocumentState }).document;
dispatch(documentActions.updateNodeData({ id, data: { ...data } }));
const node = state.nodes[id];
await controller.applyActions([
controller.getUpdateAction({
...node,
data: {
...node.data,
...data,
},
}),
]);
});

View File

@ -0,0 +1,31 @@
import { createAsyncThunk } from '@reduxjs/toolkit';
import { DocumentController } from '$app/stores/effects/document/document_controller';
import { BlockType } from '$app/interfaces/document';
import { turnToBlockThunk } from '$app_reducers/document/async-actions/turn_to';
import { Editor } from 'slate';
import { getTodoListDataFromEditor } from '$app/utils/document/blocks/todo_list';
/**
* transform to todolist block
* 1. insert todolist block after current block
* 2. move children to todolist block
* 3. delete current block
*/
export const turnToTodoListBlockThunk = createAsyncThunk(
'document/turnToTodoListBlock',
async (payload: { id: string; editor: Editor; controller: DocumentController }, thunkAPI) => {
const { id, controller, editor } = payload;
const { dispatch } = thunkAPI;
const data = getTodoListDataFromEditor(editor);
if (!data) return;
await dispatch(
turnToBlockThunk({
id,
controller,
type: BlockType.TodoListBlock,
data,
})
);
}
);

View File

@ -0,0 +1,46 @@
import { createAsyncThunk } from '@reduxjs/toolkit';
import { DocumentController } from '$app/stores/effects/document/document_controller';
import { BlockData, BlockType, DocumentState, NestedBlock } from '$app/interfaces/document';
import { setCursorBeforeThunk } from '$app_reducers/document/async-actions/cursor';
import { allowedChildrenBlockTypes } from '$app/constants/document/config';
import { newBlock } from '$app/utils/document/blocks/common';
export const turnToBlockThunk = createAsyncThunk(
'document/turnToBlock',
async (payload: { id: string; controller: DocumentController; type: BlockType; data: BlockData<any> }, thunkAPI) => {
const { id, controller, type, data } = payload;
const { dispatch, getState } = thunkAPI;
const state = (getState() as { document: DocumentState }).document;
const node = state.nodes[id];
if (!node.parent) return;
const parent = state.nodes[node.parent];
const children = state.children[node.children].map((id) => state.nodes[id]);
/**
* transform to block
* 1. insert block after current block
* 2. move all children
* 3. delete current block
*/
const block = newBlock<any>(type, parent.id, data);
// insert new block after current block
const insertHeadingAction = controller.getInsertAction(block, node.id);
// if new block is not allowed to have children, move children to parent
const newParent = allowedChildrenBlockTypes.includes(block.type) ? block : parent;
// if move children to parent, set prev to current block, otherwise the prev is empty
const newPrev = newParent.id === parent.id ? block.id : '';
const moveChildrenActions = controller.getMoveChildrenAction(children, newParent.id, newPrev);
// delete current block
const deleteAction = controller.getDeleteAction(node);
// submit actions
await controller.applyActions([insertHeadingAction, ...moveChildrenActions, deleteAction]);
// set cursor in new block
await dispatch(setCursorBeforeThunk({ id: block.id }));
}
);

View File

@ -110,3 +110,17 @@ 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

@ -7,6 +7,10 @@ export function newHeadingBlock(parentId: string, data: HeadingBlockData): Neste
return newBlock<BlockType.HeadingBlock>(BlockType.HeadingBlock, parentId, data);
}
/**
* get heading data from editor, only support markdown
* @param editor
*/
export function getHeadingDataFromEditor(editor: Editor): HeadingBlockData | undefined {
const selection = editor.selection;
if (!selection) return;

View File

@ -0,0 +1,21 @@
import { Editor } from 'slate';
import { TodoListBlockData } from '$app/interfaces/document';
import { getAfterRangeAt, getBeforeRangeAt } from '$app/utils/document/slate/text';
import { getDeltaFromSlateNodes } from '$app/utils/document/blocks/common';
/**
* get todo_list data from editor, only support markdown
* @param editor
*/
export function getTodoListDataFromEditor(editor: Editor): TodoListBlockData | undefined {
const selection = editor.selection;
if (!selection) return;
const hashTags = Editor.string(editor, getBeforeRangeAt(editor, selection));
const checked = hashTags.match(/x/g)?.length;
const slateNodes = Editor.fragment(editor, getAfterRangeAt(editor, selection));
const delta = getDeltaFromSlateNodes(slateNodes);
return {
delta,
checked: !!checked,
};
}

View File

@ -3,16 +3,28 @@ import { getBeforeRangeAt } from '$app/utils/document/slate/text';
import { Editor } from 'slate';
export function canHandleToHeadingBlock(event: React.KeyboardEvent<HTMLDivElement>, editor: Editor): boolean {
const flag = getMarkdownFlag(event, editor);
if (!flag) return false;
const isHeadingMarkdown = /^(#{1,3})$/.test(flag);
return isHeadingMarkdown;
}
export function canHandleToCheckboxBlock(event: React.KeyboardEvent<HTMLDivElement>, editor: Editor) {
const flag = getMarkdownFlag(event, editor);
if (!flag) return false;
const isCheckboxMarkdown = /^((-)?\[(x|\s)?\])$/.test(flag);
return isCheckboxMarkdown;
}
function getMarkdownFlag(event: React.KeyboardEvent<HTMLDivElement>, editor: Editor) {
const isSpaceKey = event.key === keyBoardEventKeyMap.Space;
const selection = editor.selection;
if (!isSpaceKey || !selection) {
return false;
return null;
}
const beforeSpaceContent = Editor.string(editor, getBeforeRangeAt(editor, selection));
const isHeadingMarkdown = /^(#{1,3})$/.test(beforeSpaceContent.trim());
return isHeadingMarkdown;
return Editor.string(editor, getBeforeRangeAt(editor, selection)).trim();
}

View File

@ -1,4 +1,4 @@
import { useEffect, useState } from 'react';
import { useCallback, useEffect, useState } from 'react';
import { useParams } from 'react-router-dom';
import { DocumentData } from '../interfaces/document';
import { DocumentController } from '$app/stores/effects/document/document_controller';
@ -14,9 +14,9 @@ export const useDocument = () => {
const [controller, setController] = useState<DocumentController | null>(null);
const dispatch = useAppDispatch();
const onDocumentChange = (props: { isRemote: boolean; data: BlockEventPayloadPB }) => {
const onDocumentChange = useCallback((props: { isRemote: boolean; data: BlockEventPayloadPB }) => {
dispatch(documentActions.onDataChange(props));
};
}, []);
useEffect(() => {
let documentController: DocumentController | null = null;
@ -34,14 +34,17 @@ export const useDocument = () => {
Log.error(e);
}
})();
return () => {
void (async () => {
if (documentController) {
await documentController.dispose();
}
Log.debug('close document', params.id);
})();
const closeDocument = () => {
if (documentController) {
void documentController.dispose();
}
Log.debug('close document', params.id);
};
// dispose controller before unload
window.addEventListener('beforeunload', closeDocument);
return closeDocument;
}, [params.id]);
return { documentId, documentData, controller };
};

View File

@ -38,6 +38,7 @@ module.exports = {
4: '#BDBDBD',
5: '#E0E0E0',
6: '#F2F2F2',
7: '#FFFFFF',
},
surface: {
1: '#F7F8FC',

View File

@ -57,6 +57,7 @@ impl DocumentManager {
document
.lock()
.open(move |events, is_remote| {
tracing::debug!("data_change: {:?}, from remote: {}", &events, is_remote);
send_notification(&clone_doc_id, DocumentNotification::DidReceiveUpdate)
.payload::<DocEventPB>((events, is_remote).into())
.send();