Browse Source

feat: checkbox block (#2413)

Kilu.He 2 years ago
parent
commit
7db36e3f1e
23 changed files with 365 additions and 92 deletions
  1. 14 0
      frontend/appflowy_tauri/src/appflowy_app/components/document/Node/NodeChildren.tsx
  2. 4 0
      frontend/appflowy_tauri/src/appflowy_app/components/document/Node/index.tsx
  3. 3 3
      frontend/appflowy_tauri/src/appflowy_app/components/document/TextBlock/TextBlock.hooks.ts
  4. 6 9
      frontend/appflowy_tauri/src/appflowy_app/components/document/TextBlock/index.tsx
  5. 25 3
      frontend/appflowy_tauri/src/appflowy_app/components/document/TextBlock/useMarkDown.hooks.ts
  6. 38 0
      frontend/appflowy_tauri/src/appflowy_app/components/document/TodoListBlock/TodoListBlock.hooks.ts
  7. 42 0
      frontend/appflowy_tauri/src/appflowy_app/components/document/TodoListBlock/index.tsx
  8. 6 1
      frontend/appflowy_tauri/src/appflowy_app/constants/document/config.ts
  9. 7 1
      frontend/appflowy_tauri/src/appflowy_app/interfaces/document.ts
  10. 19 30
      frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/heading.ts
  11. 1 1
      frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/text/backspace.ts
  12. 18 25
      frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/text/index.ts
  13. 6 2
      frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/text/split.ts
  14. 27 1
      frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/text/update.ts
  15. 31 0
      frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/todo_list.ts
  16. 46 0
      frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/turn_to.ts
  17. 14 0
      frontend/appflowy_tauri/src/appflowy_app/utils/document/blocks/common.ts
  18. 4 0
      frontend/appflowy_tauri/src/appflowy_app/utils/document/blocks/heading.ts
  19. 21 0
      frontend/appflowy_tauri/src/appflowy_app/utils/document/blocks/todo_list.ts
  20. 18 6
      frontend/appflowy_tauri/src/appflowy_app/utils/document/slate/markdown.ts
  21. 13 10
      frontend/appflowy_tauri/src/appflowy_app/views/DocumentPage.hooks.ts
  22. 1 0
      frontend/appflowy_tauri/tailwind.config.cjs
  23. 1 0
      frontend/rust-lib/flowy-document2/src/manager.rs

+ 14 - 0
frontend/appflowy_tauri/src/appflowy_app/components/document/Node/NodeChildren.tsx

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

+ 4 - 0
frontend/appflowy_tauri/src/appflowy_app/components/document/Node/index.tsx

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

+ 3 - 3
frontend/appflowy_tauri/src/appflowy_app/components/document/TextBlock/TextBlock.hooks.ts

@@ -151,15 +151,15 @@ function useTextBlockKeyEvent(id: string, editor: Editor) {
       const keyEvents = [enterEvent, backSpaceEvent, tabEvent, upEvent, downEvent, leftEvent, rightEvent];
       const keyEvents = [enterEvent, backSpaceEvent, tabEvent, upEvent, downEvent, leftEvent, rightEvent];
 
 
       keyEvents.push(...markdownEvents);
       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);
         triggerHotkey(event, editor);
         return;
         return;
       }
       }
 
 
       event.stopPropagation();
       event.stopPropagation();
       event.preventDefault();
       event.preventDefault();
-      matchKey.handler(event, editor);
+      matchKeys.forEach((matchKey) => matchKey.handler(event, editor));
     },
     },
     [editor, enterEvent, backSpaceEvent, tabEvent, upEvent, downEvent, leftEvent, rightEvent, markdownEvents]
     [editor, enterEvent, backSpaceEvent, tabEvent, upEvent, downEvent, leftEvent, rightEvent, markdownEvents]
   );
   );

+ 6 - 9
frontend/appflowy_tauri/src/appflowy_app/components/document/TextBlock/index.tsx

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

+ 25 - 3
frontend/appflowy_tauri/src/appflowy_app/components/document/TextBlock/useMarkDown.hooks.ts

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

+ 38 - 0
frontend/appflowy_tauri/src/appflowy_app/components/document/TodoListBlock/TodoListBlock.hooks.ts

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

+ 42 - 0
frontend/appflowy_tauri/src/appflowy_app/components/document/TodoListBlock/index.tsx

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

+ 6 - 1
frontend/appflowy_tauri/src/appflowy_app/constants/document/config.ts

@@ -3,4 +3,9 @@ import { BlockType } from '$app/interfaces/document';
 /**
 /**
  * Block types that are allowed to have children
  * 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];

+ 7 - 1
frontend/appflowy_tauri/src/appflowy_app/interfaces/document.ts

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

+ 19 - 30
frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/heading.ts

@@ -1,42 +1,31 @@
 import { createAsyncThunk } from '@reduxjs/toolkit';
 import { createAsyncThunk } from '@reduxjs/toolkit';
 import { Editor } from 'slate';
 import { Editor } from 'slate';
 import { DocumentController } from '$app/stores/effects/document/document_controller';
 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(
 export const turnToHeadingBlockThunk = createAsyncThunk(
   'document/turnToHeadingBlock',
   'document/turnToHeadingBlock',
   async (payload: { id: string; editor: Editor; controller: DocumentController }, thunkAPI) => {
   async (payload: { id: string; editor: Editor; controller: DocumentController }, thunkAPI) => {
     const { id, editor, controller } = payload;
     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);
     const data = getHeadingDataFromEditor(editor);
     if (!data) return;
     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,
+      })
+    );
   }
   }
 );
 );

+ 1 - 1
frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/text/backspace.ts

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

+ 18 - 25
frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/text/index.ts

@@ -1,39 +1,32 @@
 import { createAsyncThunk } from '@reduxjs/toolkit';
 import { createAsyncThunk } from '@reduxjs/toolkit';
 import { DocumentController } from '$app/stores/effects/document/document_controller';
 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(
 export const turnToTextBlockThunk = createAsyncThunk(
   'document/turnToTextBlock',
   'document/turnToTextBlock',
   async (payload: { id: string; controller: DocumentController }, thunkAPI) => {
   async (payload: { id: string; controller: DocumentController }, thunkAPI) => {
     const { id, controller } = payload;
     const { id, controller } = payload;
     const { dispatch, getState } = thunkAPI;
     const { dispatch, getState } = thunkAPI;
     const state = (getState() as { document: DocumentState }).document;
     const state = (getState() as { document: DocumentState }).document;
-
     const node = state.nodes[id];
     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,
       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,
+      })
+    );
   }
   }
 );
 );

+ 6 - 2
frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/text/split.ts

@@ -3,7 +3,8 @@ import { DocumentController } from '$app/stores/effects/document/document_contro
 import { createAsyncThunk } from '@reduxjs/toolkit';
 import { createAsyncThunk } from '@reduxjs/toolkit';
 import { documentActions } from '$app_reducers/document/slice';
 import { documentActions } from '$app_reducers/document/slice';
 import { setCursorBeforeThunk } from '../../cursor';
 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(
 export const splitNodeThunk = createAsyncThunk(
   'document/splitNode',
   'document/splitNode',
@@ -20,7 +21,10 @@ export const splitNodeThunk = createAsyncThunk(
     const prevId = children.length > 0 ? null : node.id;
     const prevId = children.length > 0 ? null : node.id;
     const parent = children.length > 0 ? node : state.nodes[node.parent];
     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,
       delta: insert,
     });
     });
     const retainNode = {
     const retainNode = {

+ 27 - 1
frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/text/update.ts

@@ -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 { DocumentController } from '$app/stores/effects/document/document_controller';
 import { createAsyncThunk } from '@reduxjs/toolkit';
 import { createAsyncThunk } from '@reduxjs/toolkit';
 import { documentActions } from '$app_reducers/document/slice';
 import { documentActions } from '$app_reducers/document/slice';
@@ -35,3 +35,29 @@ const debounceApplyUpdate = debounce((controller: DocumentController, updateNode
     }),
     }),
   ]);
   ]);
 }, 200);
 }, 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,
+      },
+    }),
+  ]);
+});

+ 31 - 0
frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/todo_list.ts

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

+ 46 - 0
frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/turn_to.ts

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

+ 14 - 0
frontend/appflowy_tauri/src/appflowy_app/utils/document/blocks/common.ts

@@ -110,3 +110,17 @@ export function newBlock<Type>(type: BlockType, parentId: string, data: BlockDat
     data,
     data,
   };
   };
 }
 }
+
+export function getDefaultBlockData(type: BlockType) {
+  switch (type) {
+    case BlockType.TodoListBlock:
+      return {
+        checked: false,
+        delta: [],
+      };
+    default:
+      return {
+        delta: [],
+      };
+  }
+}

+ 4 - 0
frontend/appflowy_tauri/src/appflowy_app/utils/document/blocks/heading.ts

@@ -7,6 +7,10 @@ export function newHeadingBlock(parentId: string, data: HeadingBlockData): Neste
   return newBlock<BlockType.HeadingBlock>(BlockType.HeadingBlock, parentId, data);
   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 {
 export function getHeadingDataFromEditor(editor: Editor): HeadingBlockData | undefined {
   const selection = editor.selection;
   const selection = editor.selection;
   if (!selection) return;
   if (!selection) return;

+ 21 - 0
frontend/appflowy_tauri/src/appflowy_app/utils/document/blocks/todo_list.ts

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

+ 18 - 6
frontend/appflowy_tauri/src/appflowy_app/utils/document/slate/markdown.ts

@@ -3,16 +3,28 @@ import { getBeforeRangeAt } from '$app/utils/document/slate/text';
 import { Editor } from 'slate';
 import { Editor } from 'slate';
 
 
 export function canHandleToHeadingBlock(event: React.KeyboardEvent<HTMLDivElement>, editor: Editor): boolean {
 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 isSpaceKey = event.key === keyBoardEventKeyMap.Space;
   const selection = editor.selection;
   const selection = editor.selection;
 
 
   if (!isSpaceKey || !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();
 }
 }

+ 13 - 10
frontend/appflowy_tauri/src/appflowy_app/views/DocumentPage.hooks.ts

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

+ 1 - 0
frontend/appflowy_tauri/tailwind.config.cjs

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

+ 1 - 0
frontend/rust-lib/flowy-document2/src/manager.rs

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