mirror of
https://github.com/AppFlowy-IO/AppFlowy.git
synced 2024-08-30 18:12:39 +00:00
feat: checkbox block (#2413)
This commit is contained in:
parent
93d787a9ae
commit
7db36e3f1e
@ -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;
|
@ -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;
|
||||||
}
|
}
|
||||||
|
@ -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));
|
const matchKeys = keyEvents.filter((keyEvent) => keyEvent.canHandle(event, editor));
|
||||||
if (!matchKey) {
|
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]
|
||||||
);
|
);
|
||||||
|
@ -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 ? (
|
<NodeChildren childIds={childIds} />
|
||||||
<div className='pl-[1.5em]'>
|
|
||||||
{childIds.map((item) => (
|
|
||||||
<NodeComponent key={item} id={item} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
@ -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,
|
||||||
|
};
|
||||||
|
}
|
@ -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} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
@ -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];
|
||||||
|
@ -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> {
|
||||||
|
@ -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 { BlockType } from '$app/interfaces/document';
|
||||||
import { getHeadingDataFromEditor, newHeadingBlock } from '$app/utils/document/blocks/heading';
|
import { turnToBlockThunk } from '$app_reducers/document/async-actions/turn_to';
|
||||||
import { setCursorBeforeThunk } from '$app_reducers/document/async-actions/cursor';
|
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 { dispatch } = 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 data = getHeadingDataFromEditor(editor);
|
const data = getHeadingDataFromEditor(editor);
|
||||||
if (!data) return;
|
if (!data) return;
|
||||||
const headingBlock = newHeadingBlock(parent.id, data);
|
await dispatch(
|
||||||
const insertHeadingAction = controller.getInsertAction(headingBlock, node.id);
|
turnToBlockThunk({
|
||||||
|
id,
|
||||||
const moveChildrenActions = controller.getMoveChildrenAction(children, parent.id, headingBlock.id);
|
controller,
|
||||||
|
type: BlockType.HeadingBlock,
|
||||||
const deleteAction = controller.getDeleteAction(node);
|
data,
|
||||||
|
})
|
||||||
// submit actions
|
);
|
||||||
await controller.applyActions([insertHeadingAction, ...moveChildrenActions, deleteAction]);
|
|
||||||
// set cursor
|
|
||||||
await dispatch(setCursorBeforeThunk({ id: headingBlock.id }));
|
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
@ -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;
|
||||||
|
@ -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 { BlockType, DocumentState } from '$app/interfaces/document';
|
||||||
import { newTextBlock } from '$app/utils/document/blocks/text';
|
import { turnToBlockThunk } from '$app_reducers/document/async-actions/turn_to';
|
||||||
import { setCursorBeforeThunk } from '$app_reducers/document/async-actions/cursor';
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 data = {
|
||||||
|
|
||||||
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, {
|
|
||||||
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 dispatch(
|
||||||
await controller.applyActions([insertTextAction, ...moveChildrenActions, deleteAction]);
|
turnToBlockThunk({
|
||||||
// set cursor
|
id,
|
||||||
await dispatch(setCursorBeforeThunk({ id: textBlock.id }));
|
controller,
|
||||||
|
type: BlockType.TextBlock,
|
||||||
|
data,
|
||||||
|
})
|
||||||
|
);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
@ -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 = {
|
||||||
|
@ -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,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
@ -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,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
@ -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 }));
|
||||||
|
}
|
||||||
|
);
|
@ -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: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@ -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;
|
||||||
|
@ -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,
|
||||||
|
};
|
||||||
|
}
|
@ -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));
|
return Editor.string(editor, getBeforeRangeAt(editor, selection)).trim();
|
||||||
|
|
||||||
const isHeadingMarkdown = /^(#{1,3})$/.test(beforeSpaceContent.trim());
|
|
||||||
|
|
||||||
return isHeadingMarkdown;
|
|
||||||
}
|
}
|
||||||
|
@ -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 () => {
|
const closeDocument = () => {
|
||||||
if (documentController) {
|
if (documentController) {
|
||||||
await documentController.dispose();
|
void documentController.dispose();
|
||||||
}
|
}
|
||||||
Log.debug('close document', params.id);
|
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 };
|
||||||
};
|
};
|
||||||
|
@ -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',
|
||||||
|
@ -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();
|
||||||
|
Loading…
Reference in New Issue
Block a user