From 7db36e3f1e976e063cfda117f1a76f2ba044f620 Mon Sep 17 00:00:00 2001 From: "Kilu.He" <108015703+qinluhe@users.noreply.github.com> Date: Wed, 3 May 2023 11:18:25 +0800 Subject: [PATCH] feat: checkbox block (#2413) --- .../components/document/Node/NodeChildren.tsx | 14 ++++++ .../components/document/Node/index.tsx | 4 ++ .../document/TextBlock/TextBlock.hooks.ts | 6 +-- .../components/document/TextBlock/index.tsx | 15 +++--- .../document/TextBlock/useMarkDown.hooks.ts | 28 +++++++++-- .../TodoListBlock/TodoListBlock.hooks.ts | 38 +++++++++++++++ .../document/TodoListBlock/index.tsx | 42 +++++++++++++++++ .../appflowy_app/constants/document/config.ts | 7 ++- .../src/appflowy_app/interfaces/document.ts | 8 +++- .../document/async-actions/blocks/heading.ts | 47 +++++++------------ .../async-actions/blocks/text/backspace.ts | 2 +- .../async-actions/blocks/text/index.ts | 43 +++++++---------- .../async-actions/blocks/text/split.ts | 8 +++- .../async-actions/blocks/text/update.ts | 28 ++++++++++- .../async-actions/blocks/todo_list.ts | 31 ++++++++++++ .../document/async-actions/turn_to.ts | 46 ++++++++++++++++++ .../utils/document/blocks/common.ts | 14 ++++++ .../utils/document/blocks/heading.ts | 4 ++ .../utils/document/blocks/todo_list.ts | 21 +++++++++ .../utils/document/slate/markdown.ts | 24 +++++++--- .../appflowy_app/views/DocumentPage.hooks.ts | 23 +++++---- frontend/appflowy_tauri/tailwind.config.cjs | 1 + .../rust-lib/flowy-document2/src/manager.rs | 1 + 23 files changed, 364 insertions(+), 91 deletions(-) create mode 100644 frontend/appflowy_tauri/src/appflowy_app/components/document/Node/NodeChildren.tsx create mode 100644 frontend/appflowy_tauri/src/appflowy_app/components/document/TodoListBlock/TodoListBlock.hooks.ts create mode 100644 frontend/appflowy_tauri/src/appflowy_app/components/document/TodoListBlock/index.tsx create mode 100644 frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/todo_list.ts create mode 100644 frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/turn_to.ts create mode 100644 frontend/appflowy_tauri/src/appflowy_app/utils/document/blocks/todo_list.ts diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/Node/NodeChildren.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/document/Node/NodeChildren.tsx new file mode 100644 index 0000000000..0a02f70b5d --- /dev/null +++ b/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 ? ( +
+ {childIds.map((item) => ( + + ))} +
+ ) : null; +} + +export default NodeChildren; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/Node/index.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/document/Node/index.tsx index cf13e853c3..4ecca39f8d 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/Node/index.tsx +++ b/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 { 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) { const { node, childIds, isSelected, ref } = useNode(id); @@ -18,6 +19,9 @@ function NodeComponent({ id, ...props }: { id: string } & React.HTMLAttributes; } + case BlockType.TodoListBlock: { + return ; + } default: return null; } diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/TextBlock/TextBlock.hooks.ts b/frontend/appflowy_tauri/src/appflowy_app/components/document/TextBlock/TextBlock.hooks.ts index 9fe898069e..763a3bc6f9 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/TextBlock/TextBlock.hooks.ts +++ b/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]; 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] ); diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/TextBlock/index.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/document/TextBlock/index.tsx index d88b73577d..201a7558a9 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/TextBlock/index.tsx +++ b/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 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) { const { editor, value, onChange, onKeyDownCapture, onDOMBeforeInput } = useTextBlock(node.id); + const className = props.className !== undefined ? ` ${props.className}` : ''; + return ( <> -
+
- {childIds && childIds.length > 0 ? ( -
- {childIds.map((item) => ( - - ))} -
- ) : null} + ); } diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/TextBlock/useMarkDown.hooks.ts b/frontend/appflowy_tauri/src/appflowy_app/components/document/TextBlock/useMarkDown.hooks.ts index c90875c70f..941f300bba 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/TextBlock/useMarkDown.hooks.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/TextBlock/useMarkDown.hooks.ts @@ -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, }; } diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/TodoListBlock/TodoListBlock.hooks.ts b/frontend/appflowy_tauri/src/appflowy_app/components/document/TodoListBlock/TodoListBlock.hooks.ts new file mode 100644 index 0000000000..f6b6a3bd3e --- /dev/null +++ b/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) { + 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) => { + // Accepts mod for the classic "cmd on Mac, ctrl on Windows" use case. + if (isHotkey('mod+enter', event)) { + toggleCheckbox(); + } + }, + [toggleCheckbox] + ); + + return { + toggleCheckbox, + handleShortcut, + }; +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/TodoListBlock/index.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/document/TodoListBlock/index.tsx new file mode 100644 index 0000000000..1ae9a33c00 --- /dev/null +++ b/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; + childIds?: string[]; +}) { + const { id, data } = node; + const { toggleCheckbox, handleShortcut } = useTodoListBlock(id, node.data); + + const checked = !!data.checked; + + return ( + <> +
+
+
+
{checked ? : }
+ +
+
+
+ +
+
+ + + ); +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/constants/document/config.ts b/frontend/appflowy_tauri/src/appflowy_app/constants/document/config.ts index 071e6492a5..3bfae1ddca 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/constants/document/config.ts +++ b/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 */ -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]; diff --git a/frontend/appflowy_tauri/src/appflowy_app/interfaces/document.ts b/frontend/appflowy_tauri/src/appflowy_app/interfaces/document.ts index 077ec411e2..7545482c08 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/interfaces/document.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/interfaces/document.ts @@ -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 extends BlockType.HeadingBlock ? HeadingBlockData : Type extends BlockType.PageBlock ? PageBlockData + : Type extends BlockType.TodoListBlock + ? TodoListBlockData : TextBlockData; export interface NestedBlock { diff --git a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/heading.ts b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/heading.ts index 8fec913504..b9bb6c3883 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/heading.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/heading.ts @@ -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, + }) + ); } ); diff --git a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/text/backspace.ts b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/text/backspace.ts index fbea40b6d3..da8053a923 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/text/backspace.ts +++ b/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 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; diff --git a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/text/index.ts b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/text/index.ts index fb38ecf363..040e9545ec 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/text/index.ts +++ b/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 { 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, + }) + ); } ); diff --git a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/text/split.ts b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/text/split.ts index b500b874ae..dd20d11c8b 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/text/split.ts +++ b/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 { 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(newNodeType, parent.id, { + ...defaultData, delta: insert, }); const retainNode = { diff --git a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/text/update.ts b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/text/update.ts index 6e9f0580ff..7c8d5f2910 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/text/update.ts +++ b/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 { 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>; + 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, + }, + }), + ]); +}); diff --git a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/todo_list.ts b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/blocks/todo_list.ts new file mode 100644 index 0000000000..6c6d53084b --- /dev/null +++ b/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, + }) + ); + } +); diff --git a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/turn_to.ts b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/document/async-actions/turn_to.ts new file mode 100644 index 0000000000..7120d767cc --- /dev/null +++ b/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 }, 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(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 })); + } +); diff --git a/frontend/appflowy_tauri/src/appflowy_app/utils/document/blocks/common.ts b/frontend/appflowy_tauri/src/appflowy_app/utils/document/blocks/common.ts index c3af7c1c37..3765525cb6 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/utils/document/blocks/common.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/utils/document/blocks/common.ts @@ -110,3 +110,17 @@ export function newBlock(type: BlockType, parentId: string, data: BlockDat data, }; } + +export function getDefaultBlockData(type: BlockType) { + switch (type) { + case BlockType.TodoListBlock: + return { + checked: false, + delta: [], + }; + default: + return { + delta: [], + }; + } +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/utils/document/blocks/heading.ts b/frontend/appflowy_tauri/src/appflowy_app/utils/document/blocks/heading.ts index 948e55f515..6677a1bb64 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/utils/document/blocks/heading.ts +++ b/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, 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; diff --git a/frontend/appflowy_tauri/src/appflowy_app/utils/document/blocks/todo_list.ts b/frontend/appflowy_tauri/src/appflowy_app/utils/document/blocks/todo_list.ts new file mode 100644 index 0000000000..3d48f8529e --- /dev/null +++ b/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, + }; +} diff --git a/frontend/appflowy_tauri/src/appflowy_app/utils/document/slate/markdown.ts b/frontend/appflowy_tauri/src/appflowy_app/utils/document/slate/markdown.ts index 721699ab5a..8cca17aca0 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/utils/document/slate/markdown.ts +++ b/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'; export function canHandleToHeadingBlock(event: React.KeyboardEvent, 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, editor: Editor) { + const flag = getMarkdownFlag(event, editor); + if (!flag) return false; + + const isCheckboxMarkdown = /^((-)?\[(x|\s)?\])$/.test(flag); + return isCheckboxMarkdown; +} + +function getMarkdownFlag(event: React.KeyboardEvent, 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(); } diff --git a/frontend/appflowy_tauri/src/appflowy_app/views/DocumentPage.hooks.ts b/frontend/appflowy_tauri/src/appflowy_app/views/DocumentPage.hooks.ts index 4b18191292..6bba20b6fe 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/views/DocumentPage.hooks.ts +++ b/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 { DocumentData } from '../interfaces/document'; import { DocumentController } from '$app/stores/effects/document/document_controller'; @@ -14,9 +14,9 @@ export const useDocument = () => { const [controller, setController] = useState(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 }; }; diff --git a/frontend/appflowy_tauri/tailwind.config.cjs b/frontend/appflowy_tauri/tailwind.config.cjs index b2ba17c424..cc80504561 100644 --- a/frontend/appflowy_tauri/tailwind.config.cjs +++ b/frontend/appflowy_tauri/tailwind.config.cjs @@ -38,6 +38,7 @@ module.exports = { 4: '#BDBDBD', 5: '#E0E0E0', 6: '#F2F2F2', + 7: '#FFFFFF', }, surface: { 1: '#F7F8FC', diff --git a/frontend/rust-lib/flowy-document2/src/manager.rs b/frontend/rust-lib/flowy-document2/src/manager.rs index 461e68e502..0b8fdc9df7 100644 --- a/frontend/rust-lib/flowy-document2/src/manager.rs +++ b/frontend/rust-lib/flowy-document2/src/manager.rs @@ -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::((events, is_remote).into()) .send();