feat: Support heading block (#2376)

* feat: support transform heading block according to markdown

* fix: folder scroll
This commit is contained in:
Kilu.He 2023-05-01 15:40:56 +08:00 committed by GitHub
parent 55cb7acc7f
commit f5b23e5fc1
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
48 changed files with 393 additions and 173 deletions

View File

@ -1,8 +1,8 @@
import { toggleFormat, isFormatActive } from '$app/utils/slate/format';
import { toggleFormat, isFormatActive } from '$app/utils/document/slate/format';
import IconButton from '@mui/material/IconButton';
import Tooltip from '@mui/material/Tooltip';
import { command } from '$app/constants/toolbar';
import { command } from '$app/constants/document/toolbar';
import FormatIcon from './FormatIcon';
import { BaseEditor } from 'slate';

View File

@ -1,6 +1,6 @@
import React from 'react';
import { FormatBold, FormatUnderlined, FormatItalic, CodeOutlined, StrikethroughSOutlined } from '@mui/icons-material';
import { iconSize } from '$app/constants/toolbar';
import { iconSize } from '$app/constants/document/toolbar';
export default function FormatIcon({ icon }: { icon: string }) {
switch (icon) {

View File

@ -1,6 +1,6 @@
import { useEffect, useRef } from 'react';
import { useFocused, useSlate } from 'slate-react';
import { calcToolbarPosition } from '$app/utils/slate/toolbar';
import { calcToolbarPosition } from '$app/utils/document/slate/toolbar';
export function useHoveringToolbar(id: string) {
const editor = useSlate();
const inFocus = useFocused();

View File

@ -1,8 +1,8 @@
import FormatButton from './FormatButton';
import Portal from '../../BlockPortal';
import Portal from '../BlockPortal';
import { useHoveringToolbar } from './index.hooks';
const HoveringToolbar = ({ id }: { id: string }) => {
const BlockHorizontalToolbar = ({ id }: { id: string }) => {
const { inFocus, ref, editor } = useHoveringToolbar(id);
if (!inFocus) return null;
@ -27,4 +27,4 @@ const HoveringToolbar = ({ id }: { id: string }) => {
);
};
export default HoveringToolbar;
export default BlockHorizontalToolbar;

View File

@ -1,7 +1,7 @@
import { DocumentControllerContext } from '$app/stores/effects/document/document_controller';
import { useAppDispatch } from '@/appflowy_app/stores/store';
import { useCallback, useContext } from 'react';
import { insertAfterNodeThunk, deleteNodeThunk } from '@/appflowy_app/stores/reducers/document/async_actions';
import { insertAfterNodeThunk, deleteNodeThunk } from '$app/stores/reducers/document/async-actions';
export enum ActionType {
InsertAfter = 'insertAfter',

View File

@ -3,7 +3,7 @@ import { useAppSelector } from '@/appflowy_app/stores/store';
import { debounce } from '@/appflowy_app/utils/tool';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
export function useBlockSideTools({ container }: { container: HTMLDivElement }) {
export function useBlockSideToolbar({ container }: { container: HTMLDivElement }) {
const [nodeId, setHoverNodeId] = useState<string>('');
const [menuOpen, setMenuOpen] = useState(false);
const ref = useRef<HTMLDivElement | null>(null);

View File

@ -1,5 +1,5 @@
import React from 'react';
import { useBlockSideTools } from './BlockSideTools.hooks';
import { useBlockSideToolbar } from './BlockSideToolbar.hooks';
import ExpandCircleDownSharpIcon from '@mui/icons-material/ExpandCircleDownSharp';
import DragIndicatorRoundedIcon from '@mui/icons-material/DragIndicatorRounded';
import Portal from '../BlockPortal';
@ -8,8 +8,8 @@ import BlockMenu from '../BlockMenu';
const sx = { height: 24, width: 24 };
export default function BlockSideTools(props: { container: HTMLDivElement }) {
const { nodeId, ref, menuOpen, handleToggleMenu } = useBlockSideTools(props);
export default function BlockSideToolbar(props: { container: HTMLDivElement }) {
const { nodeId, ref, menuOpen, handleToggleMenu } = useBlockSideToolbar(props);
if (!nodeId) return null;
return (

View File

@ -1,22 +1,16 @@
import TextBlock from '../TextBlock';
import { HeadingBlockData, Node } from '@/appflowy_app/interfaces/document';
import { BlockType, NestedBlock } from '@/appflowy_app/interfaces/document';
const fontSize: Record<string, string> = {
1: 'mt-8 text-3xl',
2: 'mt-6 text-2xl',
3: 'mt-4 text-xl',
1: 'mt-5 text-3xl',
2: 'mt-4 text-2xl',
3: 'text-xl',
};
export default function HeadingBlock({
node,
}: {
node: Node & {
data: HeadingBlockData;
};
}) {
export default function HeadingBlock({ node }: { node: NestedBlock<BlockType.HeadingBlock> }) {
return (
<div className={`${fontSize[node.data.level]} font-semibold `}>
{/*<TextBlock node={node} childIds={[]} delta={delta} />*/}
<TextBlock node={node} childIds={[]} />
</div>
);
}

View File

@ -4,18 +4,22 @@ import { withErrorBoundary } from 'react-error-boundary';
import { ErrorBoundaryFallbackComponent } from '../_shared/ErrorBoundaryFallbackComponent';
import TextBlock from '../TextBlock';
import { NodeContext } from '../_shared/SubscribeNode.hooks';
import { Node } from '$app/interfaces/document';
import { BlockType } from '$app/interfaces/document';
import HeadingBlock from '$app/components/document/HeadingBlock';
function NodeComponent({ id, ...props }: { id: string } & React.HTMLAttributes<HTMLDivElement>) {
const { node, childIds, isSelected, ref } = useNode(id);
const renderBlock = useCallback(() => {
switch (node.type) {
case 'text': {
case BlockType.TextBlock: {
return <TextBlock node={node} childIds={childIds} />;
}
case BlockType.HeadingBlock: {
return <HeadingBlock node={node} />;
}
default:
break;
return null;
}
}, [node, childIds]);

View File

@ -1,12 +1,12 @@
import React, { useState } from 'react';
import BlockSideTools from '../BlockSideTools';
import BlockSideToolbar from '../BlockSideToolbar';
import BlockSelection from '../BlockSelection';
export default function Overlay({ container }: { container: HTMLDivElement }) {
const [isDragging, setDragging] = useState(false);
return (
<>
{isDragging ? null : <BlockSideTools container={container} />}
{isDragging ? null : <BlockSideToolbar container={container} />}
<BlockSelection onDragging={setDragging} container={container} />
</>
);

View File

@ -18,9 +18,17 @@ function Root({ documentData }: { documentData: DocumentData }) {
return <Skeleton />;
}
return (
<div id='appflowy-block-doc' className='h-[100%] overflow-hidden'>
<div
id='appflowy-block-doc'
className='h-[100%] overflow-hidden'
onKeyDown={(e) => {
// prevent backspace from going back
if (e.key === 'Backspace') {
e.stopPropagation();
}
}}
>
<VirtualizedList node={node} childIds={childIds} renderNode={renderNode} />
</div>
);

View File

@ -1,6 +1,6 @@
import { useCallback, useContext, useMemo } from 'react';
import { Editor } from 'slate';
import { TextDelta, TextSelection } from '$app/interfaces/document';
import { TextBlockKeyEventHandlerParams, TextDelta, TextSelection } from '$app/interfaces/document';
import { useTextInput } from '../_shared/TextInput.hooks';
import { useAppDispatch } from '@/appflowy_app/stores/store';
import { DocumentControllerContext } from '@/appflowy_app/stores/effects/document/document_controller';
@ -8,22 +8,24 @@ import {
backspaceNodeThunk,
indentNodeThunk,
splitNodeThunk,
} from '@/appflowy_app/stores/reducers/document/async_actions';
setCursorNextLineThunk,
setCursorPreLineThunk,
} from '@/appflowy_app/stores/reducers/document/async-actions';
import { documentActions } from '@/appflowy_app/stores/reducers/document/slice';
import {
triggerHotkey,
canHandleEnterKey,
canHandleBackspaceKey,
canHandleTabKey,
onHandleEnterKey,
keyBoardEventKeyMap,
canHandleUpKey,
canHandleDownKey,
canHandleEnterKey,
canHandleLeftKey,
canHandleRightKey,
} from '@/appflowy_app/utils/slate/hotkey';
import { updateNodeDeltaThunk } from '$app/stores/reducers/document/async_actions/update';
import { setCursorPreLineThunk, setCursorNextLineThunk } from '$app/stores/reducers/document/async_actions/set_cursor';
canHandleTabKey,
canHandleUpKey,
onHandleEnterKey,
triggerHotkey,
} from '$app/utils/document/slate/hotkey';
import { updateNodeDeltaThunk } from '$app_reducers/document/async-actions/blocks/text/update';
import { useMarkDown } from './useMarkDown.hooks';
import { keyBoardEventKeyMap } from '$app/constants/document/text_block';
export function useTextBlock(id: string) {
const { editor, onChange, value } = useTextInput(id);
@ -54,25 +56,15 @@ export function useTextBlock(id: string) {
};
}
type TextBlockKeyEventHandlerParams = [React.KeyboardEvent<HTMLDivElement>, Editor];
function useTextBlockKeyEvent(id: string, editor: Editor) {
const { indentAction, backSpaceAction, splitAction, wrapAction, focusPreLineAction, focusNextLineAction } =
useActions(id);
const dispatch = useAppDispatch();
const keepSelection = useCallback(() => {
// This is a hack to make sure the selection is updated after next render
// It will save the selection to the store, and the selection will be restored
if (!editor.selection || !editor.selection.anchor || !editor.selection.focus) return;
const { anchor, focus } = editor.selection;
const selection = { anchor, focus } as TextSelection;
dispatch(documentActions.setTextSelection({ blockId: id, selection }));
}, [editor]);
const { markdownEvents } = useMarkDown(id);
const enterEvent = useMemo(() => {
return {
key: keyBoardEventKeyMap.Enter,
triggerEventKey: keyBoardEventKeyMap.Enter,
canHandle: canHandleEnterKey,
handler: (...args: TextBlockKeyEventHandlerParams) => {
onHandleEnterKey(...args, {
@ -85,29 +77,27 @@ function useTextBlockKeyEvent(id: string, editor: Editor) {
const tabEvent = useMemo(() => {
return {
key: keyBoardEventKeyMap.Tab,
triggerEventKey: keyBoardEventKeyMap.Tab,
canHandle: canHandleTabKey,
handler: (..._args: TextBlockKeyEventHandlerParams) => {
keepSelection();
void indentAction();
},
};
}, [keepSelection, indentAction]);
}, [indentAction]);
const backSpaceEvent = useMemo(() => {
return {
key: keyBoardEventKeyMap.Backspace,
triggerEventKey: keyBoardEventKeyMap.Backspace,
canHandle: canHandleBackspaceKey,
handler: (..._args: TextBlockKeyEventHandlerParams) => {
keepSelection();
void backSpaceAction();
},
};
}, [keepSelection, backSpaceAction]);
}, [backSpaceAction]);
const upEvent = useMemo(() => {
return {
key: keyBoardEventKeyMap.Up,
triggerEventKey: keyBoardEventKeyMap.Up,
canHandle: canHandleUpKey,
handler: (...args: TextBlockKeyEventHandlerParams) => {
void focusPreLineAction({
@ -119,7 +109,7 @@ function useTextBlockKeyEvent(id: string, editor: Editor) {
const downEvent = useMemo(() => {
return {
key: keyBoardEventKeyMap.Down,
triggerEventKey: keyBoardEventKeyMap.Down,
canHandle: canHandleDownKey,
handler: (...args: TextBlockKeyEventHandlerParams) => {
void focusNextLineAction({
@ -131,7 +121,7 @@ function useTextBlockKeyEvent(id: string, editor: Editor) {
const leftEvent = useMemo(() => {
return {
key: keyBoardEventKeyMap.Left,
triggerEventKey: keyBoardEventKeyMap.Left,
canHandle: canHandleLeftKey,
handler: (...args: TextBlockKeyEventHandlerParams) => {
void focusPreLineAction({
@ -144,7 +134,7 @@ function useTextBlockKeyEvent(id: string, editor: Editor) {
const rightEvent = useMemo(() => {
return {
key: keyBoardEventKeyMap.Right,
triggerEventKey: keyBoardEventKeyMap.Right,
canHandle: canHandleRightKey,
handler: (...args: TextBlockKeyEventHandlerParams) => {
void focusNextLineAction({
@ -159,6 +149,8 @@ function useTextBlockKeyEvent(id: string, editor: Editor) {
(event: React.KeyboardEvent<HTMLDivElement>) => {
// This is list of key events that can be handled by TextBlock
const keyEvents = [enterEvent, backSpaceEvent, tabEvent, upEvent, downEvent, leftEvent, rightEvent];
keyEvents.push(...markdownEvents);
const matchKey = keyEvents.find((keyEvent) => keyEvent.canHandle(event, editor));
if (!matchKey) {
triggerHotkey(event, editor);
@ -169,7 +161,7 @@ function useTextBlockKeyEvent(id: string, editor: Editor) {
event.preventDefault();
matchKey.handler(event, editor);
},
[editor, enterEvent, backSpaceEvent, tabEvent, upEvent, downEvent, leftEvent, rightEvent]
[editor, enterEvent, backSpaceEvent, tabEvent, upEvent, downEvent, leftEvent, rightEvent, markdownEvents]
);
return {

View File

@ -2,9 +2,9 @@ import { Slate, Editable } from 'slate-react';
import Leaf from './Leaf';
import { useTextBlock } from './TextBlock.hooks';
import NodeComponent from '../Node';
import HoveringToolbar from '../_shared/HoveringToolbar';
import React, { useEffect } from 'react';
import { Node } from '$app/interfaces/document';
import BlockHorizontalToolbar from '../BlockHorizontalToolbar';
import React from 'react';
import { BlockType, NestedBlock } from '$app/interfaces/document';
function TextBlock({
node,
@ -12,7 +12,7 @@ function TextBlock({
placeholder,
...props
}: {
node: Node;
node: NestedBlock;
childIds?: string[];
placeholder?: string;
} & React.HTMLAttributes<HTMLDivElement>) {
@ -21,7 +21,7 @@ function TextBlock({
<>
<div {...props} className={`py-[2px] ${props.className}`}>
<Slate editor={editor} onChange={onChange} value={value}>
<HoveringToolbar id={node.id} />
<BlockHorizontalToolbar id={node.id} />
<Editable
onKeyDownCapture={onKeyDownCapture}
onDOMBeforeInput={onDOMBeforeInput}

View File

@ -0,0 +1,41 @@
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 { useAppDispatch } from '$app/stores/store';
import { DocumentControllerContext } from '$app/stores/effects/document/document_controller';
import { turnToHeadingBlockThunk } from '$app_reducers/document/async-actions/blocks/heading';
export function useMarkDown(id: string) {
const { toHeadingBlockAction } = useActions(id);
const toHeadingBlockEvent = useMemo(() => {
return {
triggerEventKey: keyBoardEventKeyMap.Space,
canHandle: canHandleToHeadingBlock,
handler: toHeadingBlockAction,
};
}, [toHeadingBlockAction]);
const markdownEvents = useMemo(() => [toHeadingBlockEvent], [toHeadingBlockEvent]);
return {
markdownEvents,
};
}
function useActions(id: string) {
const controller = useContext(DocumentControllerContext);
const dispatch = useAppDispatch();
const toHeadingBlockAction = useCallback(
(...args: TextBlockKeyEventHandlerParams) => {
if (!controller) return;
const [_event, editor] = args;
dispatch(turnToHeadingBlockThunk({ id, editor, controller }));
},
[controller, dispatch, id]
);
return {
toHeadingBlockAction,
};
}

View File

@ -1,17 +1,16 @@
import { createEditor, Descendant, Transforms } from 'slate';
import { withReact, ReactEditor } from 'slate-react';
import * as Y from 'yjs';
import { withYjs, YjsEditor, slateNodesToInsertDelta } from '@slate-yjs/core';
import { useCallback, useContext, useMemo, useRef, useEffect, useState } from 'react';
import { DocumentControllerContext } from '$app/stores/effects/document/document_controller';
import { TextDelta, TextSelection } from '$app/interfaces/document';
import { NodeContext } from './SubscribeNode.hooks';
import { useAppDispatch, useAppSelector } from '@/appflowy_app/stores/store';
import { createEditor, Descendant, Transforms } from 'slate';
import { withReact, ReactEditor } from 'slate-react';
import * as Y from 'yjs';
import { withYjs, YjsEditor, slateNodesToInsertDelta } from '@slate-yjs/core';
import { updateNodeDeltaThunk } from '@/appflowy_app/stores/reducers/document/async_actions/update';
import { updateNodeDeltaThunk } from '$app_reducers/document/async-actions/blocks/text/update';
import { deltaToSlateValue, getDeltaFromSlateNodes } from '$app/utils/document/blocks/common';
import { documentActions } from '@/appflowy_app/stores/reducers/document/slice';
import { deltaToSlateValue, getDeltaFromSlateNodes } from '@/appflowy_app/utils/block';
export function useTextInput(id: string) {
const dispatch = useAppDispatch();
@ -34,16 +33,35 @@ export function useTextInput(id: string) {
const [value, setValue] = useState<Descendant[]>([]);
const onChange = useCallback((e: Descendant[]) => {
setValue(e);
}, []);
const storeSelection = useCallback(() => {
// This is a hack to make sure the selection is updated after next render
// It will save the selection to the store, and the selection will be restored
if (!ReactEditor.isFocused(editor) || !editor.selection || !editor.selection.anchor || !editor.selection.focus)
return;
const { anchor, focus } = editor.selection;
const selection = { anchor, focus } as TextSelection;
dispatch(documentActions.setTextSelection({ blockId: id, selection }));
}, [editor]);
const currentSelection = useAppSelector((state) => state.document.textSelections[id]);
useEffect(() => {
const restoreSelection = useCallback(() => {
if (editor.selection && JSON.stringify(currentSelection) === JSON.stringify(editor.selection)) return;
setSelection(editor, currentSelection);
}, [editor, currentSelection]);
const onChange = useCallback(
(e: Descendant[]) => {
setValue(e);
storeSelection();
},
[storeSelection]
);
useEffect(() => {
restoreSelection();
}, [restoreSelection]);
if (editor.selection && ReactEditor.isFocused(editor)) {
const domSelection = window.getSelection();
// this is a hack to fix the issue where the selection is not in the dom
@ -98,7 +116,6 @@ function useBindYjs(id: string, delta: TextDelta[]) {
};
yText.observe(textEventHandler);
return () => {
yText.unobserve(textEventHandler);
};
@ -148,8 +165,10 @@ function useController(id: string) {
function setSelection(editor: ReactEditor, currentSelection: TextSelection) {
// If the current selection is empty, blur the editor and deselect the selection
if (!currentSelection || !currentSelection.anchor || !currentSelection.focus) {
ReactEditor.blur(editor);
ReactEditor.deselect(editor);
if (ReactEditor.isFocused(editor)) {
ReactEditor.blur(editor);
ReactEditor.deselect(editor);
}
return;
}

View File

@ -105,14 +105,20 @@ export const NavigationPanel = ({
<div className={'flex flex-col'}>
<AppLogo iconToShow={'hide'} onHideMenuClick={onHideMenuClick}></AppLogo>
<WorkspaceUser></WorkspaceUser>
<div className={'relative flex flex-col'} style={{ height: 'calc(100vh - 300px)' }}>
<div className={'flex flex-col overflow-auto px-2'} ref={el}>
<div className={'relative flex flex-1 flex-col'}>
<div
className={'flex flex-col overflow-auto px-2'}
style={{
maxHeight: 'calc(100vh - 350px)',
}}
ref={el}
>
<WorkspaceApps folders={folders} pages={pages} onPageClick={onPageClick} />
</div>
</div>
</div>
<div className={'flex flex-col'}>
<div className={'flex max-h-[215px] flex-col'}>
<div className={'border-b border-shade-6 px-2 pb-4'}>
{/*<PluginsButton></PluginsButton>*/}

View File

@ -0,0 +1,6 @@
import { BlockType } from '$app/interfaces/document';
/**
* Block types that are allowed to have children
*/
export const allowedChildrenBlockTypes = [BlockType.TextBlock, BlockType.PageBlock];

View File

@ -0,0 +1,10 @@
export const keyBoardEventKeyMap = {
Enter: 'Enter',
Backspace: 'Backspace',
Tab: 'Tab',
Up: 'ArrowUp',
Down: 'ArrowDown',
Left: 'ArrowLeft',
Right: 'ArrowRight',
Space: ' ',
};

View File

@ -1,3 +1,5 @@
import { Editor } from 'slate';
export enum BlockType {
PageBlock = 'page',
HeadingBlock = 'heading',
@ -12,9 +14,8 @@ export enum BlockType {
ColumnBlock = 'column',
}
export interface HeadingBlockData {
export interface HeadingBlockData extends TextBlockData {
level: number;
delta: TextDelta[];
}
export interface TextBlockData {
@ -23,12 +24,16 @@ export interface TextBlockData {
export type PageBlockData = TextBlockData;
export type BlockData = TextBlockData | HeadingBlockData | PageBlockData;
export type BlockData<Type> = Type extends BlockType.HeadingBlock
? HeadingBlockData
: Type extends BlockType.PageBlock
? PageBlockData
: TextBlockData;
export interface NestedBlock {
export interface NestedBlock<Type = any> {
id: string;
type: BlockType;
data: BlockData | Record<string, any>;
data: BlockData<Type>;
parent: string | null;
children: string;
}
@ -98,3 +103,5 @@ export interface BlockPBValue {
children: string;
data: string;
}
export type TextBlockKeyEventHandlerParams = [React.KeyboardEvent<HTMLDivElement>, Editor];

View File

@ -12,9 +12,9 @@ import {
} from '@/services/backend';
import { DocumentObserver } from './document_observer';
import * as Y from 'yjs';
import { blockPB2Node } from '@/appflowy_app/utils/block';
import { BLOCK_MAP_NAME, CHILDREN_MAP_NAME, META_NAME } from '@/appflowy_app/constants/block';
import { BLOCK_MAP_NAME, CHILDREN_MAP_NAME, META_NAME } from '$app/constants/document/block';
import { get } from '@/appflowy_app/utils/tool';
import { blockPB2Node } from '$app/utils/document/blocks/common';
export const DocumentControllerContext = createContext<DocumentController | null>(null);
@ -46,7 +46,9 @@ export class DocumentController {
if (document.ok) {
const nodes: DocumentData['nodes'] = {};
get<Map<string, BlockPB>>(document.val, [BLOCK_MAP_NAME]).forEach((block) => {
nodes[block.id] = blockPB2Node(block);
Object.assign(nodes, {
[block.id]: blockPB2Node(block),
});
});
const children: Record<string, string[]> = {};
get<Map<string, ChildrenPB>>(document.val, [META_NAME, CHILDREN_MAP_NAME]).forEach((child, key) => {
@ -97,6 +99,12 @@ export class DocumentController {
};
};
getMoveChildrenAction = (children: Node[], parentId: string, prevId: string | null) => {
return children.reverse().map((child) => {
return this.getMoveAction(child, parentId, prevId);
});
};
getDeleteAction = (node: Node) => {
return {
action: BlockActionTypePB.Delete,

View File

@ -0,0 +1,42 @@
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';
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 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 }));
}
);

View File

@ -1,10 +1,11 @@
import { BlockType, DocumentState } from '@/appflowy_app/interfaces/document';
import { BlockType, DocumentState } from '$app/interfaces/document';
import { DocumentController } from '$app/stores/effects/document/document_controller';
import { createAsyncThunk } from '@reduxjs/toolkit';
import { documentActions } from '../slice';
import { documentActions } from '$app_reducers/document/slice';
import { outdentNodeThunk } from './outdent';
import { setCursorAfterThunk } from './set_cursor';
import { getPrevLineId } from '$app/utils/block';
import { setCursorAfterThunk } from '../../cursor';
import { turnToTextBlockThunk } from '$app_reducers/document/async-actions/blocks/text/index';
import { getPrevLineId } from '$app/utils/document/blocks/common';
const composeNodeThunk = createAsyncThunk(
'document/composeNode',
@ -32,11 +33,8 @@ const composeNodeThunk = createAsyncThunk(
const updateAction = controller.getUpdateAction(newNode);
// move children
const children = state.children[node.children];
// the reverse can ensure that every child will be inserted in first place and don't need to update prevId
const moveActions = children.reverse().map((childId) => {
return controller.getMoveAction(state.nodes[childId], newNode.id, '');
});
const children = state.children[node.children].map((id) => state.nodes[id]);
const moveActions = controller.getMoveChildrenAction(children, newNode.id, '');
// delete node
const deleteAction = controller.getDeleteAction(node);
@ -88,7 +86,8 @@ export const backspaceNodeThunk = createAsyncThunk(
const nextNodeId = children[index + 1];
// transform to text block
if (node.type !== BlockType.TextBlock) {
// todo: transform to text block
await dispatch(turnToTextBlockThunk({ id, controller }));
return;
}
// compose to previous line when it has next sibling or no ancestor
if (nextNodeId || !ancestorId) {

View File

@ -7,7 +7,7 @@ export const deleteNodeThunk = createAsyncThunk(
'document/deleteNode',
async (payload: { id: string; controller: DocumentController }, thunkAPI) => {
const { id, controller } = payload;
const { dispatch, getState } = thunkAPI;
const { getState } = thunkAPI;
const state = getState() as { document: DocumentState };
const node = state.document.nodes[id];
if (!node) return;

View File

@ -1,6 +1,7 @@
import { BlockType, DocumentState } from '@/appflowy_app/interfaces/document';
import { BlockType, DocumentState } from '$app/interfaces/document';
import { DocumentController } from '$app/stores/effects/document/document_controller';
import { createAsyncThunk } from '@reduxjs/toolkit';
import { allowedChildrenBlockTypes } from '$app/constants/document/config';
export const indentNodeThunk = createAsyncThunk(
'document/indentNode',
@ -19,7 +20,7 @@ export const indentNodeThunk = createAsyncThunk(
const newParentId = children[index - 1];
const prevNode = state.nodes[newParentId];
// check if prev node is allowed to have children
if (prevNode.type !== BlockType.TextBlock) return;
if (!allowedChildrenBlockTypes.includes(prevNode.type)) return;
// check if prev node has children and get last child for new prev node
const prevNodeChildren = state.children[prevNode.children];
const newPrevId = prevNodeChildren[prevNodeChildren.length - 1];

View File

@ -0,0 +1,39 @@
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';
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, {
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 }));
}
);

View File

@ -1,7 +1,7 @@
import { BlockType, DocumentState, NestedBlock } from '@/appflowy_app/interfaces/document';
import { DocumentState } from '$app/interfaces/document';
import { DocumentController } from '$app/stores/effects/document/document_controller';
import { createAsyncThunk } from '@reduxjs/toolkit';
import { generateId } from '@/appflowy_app/utils/block';
import { newTextBlock } from '$app/utils/document/blocks/text';
export const insertAfterNodeThunk = createAsyncThunk(
'document/insertAfterNode',
@ -14,15 +14,9 @@ export const insertAfterNodeThunk = createAsyncThunk(
const parentId = node.parent;
if (!parentId) return;
// create new node
const newNode: NestedBlock = {
id: generateId(),
parent: parentId,
type: BlockType.TextBlock,
data: {
delta: [],
},
children: generateId(),
};
const newNode = newTextBlock(parentId, {
delta: [],
});
await controller.applyActions([controller.getInsertAction(newNode, node.id)]);
}
);

View File

@ -1,9 +1,9 @@
import { BlockType, DocumentState, TextDelta } from '@/appflowy_app/interfaces/document';
import { DocumentController } from '@/appflowy_app/stores/effects/document/document_controller';
import { BlockType, DocumentState, TextDelta } from '$app/interfaces/document';
import { DocumentController } from '$app/stores/effects/document/document_controller';
import { createAsyncThunk } from '@reduxjs/toolkit';
import { generateId } from '@/appflowy_app/utils/block';
import { documentActions } from '../slice';
import { setCursorBeforeThunk } from './set_cursor';
import { documentActions } from '$app_reducers/document/slice';
import { setCursorBeforeThunk } from '../../cursor';
import { newTextBlock } from '$app/utils/document/blocks/text';
export const splitNodeThunk = createAsyncThunk(
'document/splitNode',
@ -19,15 +19,10 @@ export const splitNodeThunk = createAsyncThunk(
const children = state.children[node.children];
const prevId = children.length > 0 ? null : node.id;
const parent = children.length > 0 ? node : state.nodes[node.parent];
const newNode = {
id: generateId(),
parent: parent.id,
type: BlockType.TextBlock,
data: {
delta: insert,
},
children: generateId(),
};
const newNode = newTextBlock(parent.id, {
delta: insert,
});
const retainNode = {
...node,
data: {

View File

@ -1,7 +1,7 @@
import { TextDelta, NestedBlock, DocumentState } from '@/appflowy_app/interfaces/document';
import { DocumentController } from '@/appflowy_app/stores/effects/document/document_controller';
import { TextDelta, NestedBlock, DocumentState } from '$app/interfaces/document';
import { DocumentController } from '$app/stores/effects/document/document_controller';
import { createAsyncThunk } from '@reduxjs/toolkit';
import { documentActions } from '../slice';
import { documentActions } from '$app_reducers/document/slice';
import { debounce } from '$app/utils/tool';
export const updateNodeDeltaThunk = createAsyncThunk(
'document/updateNodeDelta',

View File

@ -1,7 +1,6 @@
import { createAsyncThunk } from '@reduxjs/toolkit';
import { documentActions } from '../slice';
import { DocumentState, TextSelection } from '$app/interfaces/document';
import { getNextLineId, getPrevLineId } from '$app/utils/block';
import { Editor } from 'slate';
import {
getBeforeRangeAt,
@ -10,7 +9,8 @@ import {
getNodeBeginSelection,
getNodeEndSelection,
getStartLineSelectionByOffset,
} from '$app/utils/slate/text';
} from '$app/utils/document/slate/text';
import { getNextLineId, getPrevLineId } from '$app/utils/document/blocks/common';
export const setCursorBeforeThunk = createAsyncThunk(
'document/setCursorBefore',

View File

@ -0,0 +1,7 @@
export * from './blocks/text/delete';
export * from './blocks/text/indent';
export * from './blocks/text/insert';
export * from './blocks/text/backspace';
export * from './blocks/text/outdent';
export * from './blocks/text/split';
export * from './cursor';

View File

@ -1,6 +0,0 @@
export * from './delete';
export * from './indent';
export * from './insert';
export * from './backspace';
export * from './outdent';
export * from './split';

View File

@ -2,7 +2,7 @@ import { DocumentState, Node, TextSelection } from '@/appflowy_app/interfaces/do
import { BlockEventPayloadPB } from '@/services/backend';
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
import { RegionGrid } from '@/appflowy_app/utils/region_grid';
import { parseValue, matchChange } from '@/appflowy_app/utils/block_change';
import { parseValue, matchChange } from '$app/utils/document/subscribe';
const regionGrid = new RegionGrid(50);
@ -92,6 +92,8 @@ export const documentSlice = createSlice({
) => {
const { blockId, selection } = action.payload;
const node = state.nodes[blockId];
const oldSelection = state.textSelections[blockId];
if (JSON.stringify(oldSelection) === JSON.stringify(selection)) return;
if (!node || !selection) {
delete state.textSelections[blockId];
} else {

View File

@ -17,7 +17,7 @@ import { databaseSlice } from './reducers/database/slice';
import { documentSlice } from './reducers/document/slice';
import { boardSlice } from './reducers/board/slice';
import { errorSlice } from './reducers/error/slice';
import { activePageIdSlice } from './reducers/activePageId/slice';
import { activePageIdSlice } from '$app_reducers/active-page-id/slice';
const listenerMiddlewareInstance = createListenerMiddleware({
onError: () => console.error,

View File

@ -1,11 +1,8 @@
import { BlockPB } from '@/services/backend/models/flowy-document2';
import { nanoid } from 'nanoid';
import { BlockData, BlockType, DocumentState, NestedBlock, TextDelta } from '$app/interfaces/document';
import { Descendant, Element, Text } from 'slate';
import { BlockType, DocumentState, NestedBlock, TextDelta } from '../interfaces/document';
import { Log } from './log';
export function generateId() {
return nanoid(10);
}
import { BlockPB } from '@/services/backend';
import { Log } from '$app/utils/log';
import { nanoid } from 'nanoid';
export function deltaToSlateValue(delta: TextDelta[]) {
const slateNode = {
@ -53,6 +50,10 @@ export function blockPB2Node(block: BlockPB) {
return node;
}
export function generateId() {
return nanoid(10);
}
export function getPrevLineId(state: DocumentState, id: string) {
const node = state.nodes[id];
if (!node.parent) return;
@ -99,3 +100,13 @@ export function getNextNodeId(state: DocumentState, id: string) {
const nextNodeId = children[index + 1];
return nextNodeId;
}
export function newBlock<Type>(type: BlockType, parentId: string, data: BlockData<Type>): NestedBlock<Type> {
return {
id: generateId(),
type,
parent: parentId,
children: generateId(),
data,
};
}

View File

@ -0,0 +1,22 @@
import { Editor } from 'slate';
import { getAfterRangeAt, getBeforeRangeAt } from '$app/utils/document/slate/text';
import { BlockType, HeadingBlockData, NestedBlock } from '$app/interfaces/document';
import { getDeltaFromSlateNodes, newBlock } from '$app/utils/document/blocks/common';
export function newHeadingBlock(parentId: string, data: HeadingBlockData): NestedBlock {
return newBlock<BlockType.HeadingBlock>(BlockType.HeadingBlock, parentId, data);
}
export function getHeadingDataFromEditor(editor: Editor): HeadingBlockData | undefined {
const selection = editor.selection;
if (!selection) return;
const hashTags = Editor.string(editor, getBeforeRangeAt(editor, selection));
const level = hashTags.match(/#/g)?.length;
if (!level) return;
const slateNodes = Editor.fragment(editor, getAfterRangeAt(editor, selection));
const delta = getDeltaFromSlateNodes(slateNodes);
return {
level,
delta,
};
}

View File

@ -0,0 +1,6 @@
import { BlockType, NestedBlock, TextBlockData } from '$app/interfaces/document';
import { newBlock } from '$app/utils/document/blocks/common';
export function newTextBlock(parentId: string, data: TextBlockData): NestedBlock {
return newBlock<BlockType.TextBlock>(BlockType.TextBlock, parentId, data);
}

View File

@ -1,8 +1,9 @@
import isHotkey from 'is-hotkey';
import { toggleFormat } from './format';
import { Editor, Range } from 'slate';
import { getBeforeRangeAt, getDelta, getAfterRangeAt, pointInEnd, pointInBegin, clonePoint } from './text';
import { clonePoint, getAfterRangeAt, getBeforeRangeAt, getDelta, pointInBegin, pointInEnd } from './text';
import { SelectionPoint, TextDelta, TextSelection } from '$app/interfaces/document';
import { keyBoardEventKeyMap } from '$app/constants/document/text_block';
const HOTKEYS: Record<string, string> = {
'mod+b': 'bold',
@ -13,16 +14,6 @@ const HOTKEYS: Record<string, string> = {
'mod+shift+S': 'strikethrough',
};
export const keyBoardEventKeyMap = {
Enter: 'Enter',
Backspace: 'Backspace',
Tab: 'Tab',
Up: 'ArrowUp',
Down: 'ArrowDown',
Left: 'ArrowLeft',
Right: 'ArrowRight',
};
export function triggerHotkey(event: React.KeyboardEvent<HTMLDivElement>, editor: Editor) {
for (const hotkey in HOTKEYS) {
if (isHotkey(hotkey, event)) {

View File

@ -0,0 +1,18 @@
import { keyBoardEventKeyMap } from '$app/constants/document/text_block';
import { getBeforeRangeAt } from '$app/utils/document/slate/text';
import { Editor } from 'slate';
export function canHandleToHeadingBlock(event: React.KeyboardEvent<HTMLDivElement>, editor: Editor): boolean {
const isSpaceKey = event.key === keyBoardEventKeyMap.Space;
const selection = editor.selection;
if (!isSpaceKey || !selection) {
return false;
}
const beforeSpaceContent = Editor.string(editor, getBeforeRangeAt(editor, selection));
const isHeadingMarkdown = /^(#{1,3})$/.test(beforeSpaceContent.trim());
return isHeadingMarkdown;
}

View File

@ -1,7 +1,7 @@
import { DeltaTypePB } from '@/services/backend/models/flowy-document2';
import { BlockType, NestedBlock, DocumentState, ChangeType, BlockPBValue } from '../interfaces/document';
import { Log } from './log';
import { BLOCK_MAP_NAME, CHILDREN_MAP_NAME, META_NAME } from '../constants/block';
import { BlockType, NestedBlock, DocumentState, ChangeType, BlockPBValue } from '../../interfaces/document';
import { Log } from '../log';
import { BLOCK_MAP_NAME, CHILDREN_MAP_NAME, META_NAME } from '../../constants/document/block';
// This is a list of all the possible changes that can happen to document data
const matchCases = [
@ -153,12 +153,14 @@ function onMatchChildrenDelete(state: DocumentState, id: string, _children: stri
* @param value
*/
export function blockChangeValue2Node(value: BlockPBValue): NestedBlock {
const block = {
const block: NestedBlock = {
id: value.id,
type: value.ty as BlockType,
parent: value.parent,
children: value.children,
data: {},
data: {
delta: [],
},
};
if ('data' in value && typeof value.data === 'string') {
try {

View File

@ -19,7 +19,8 @@
"baseUrl": "./",
"paths": {
"@/*": ["src/*"],
"$app/*": ["src/appflowy_app/*"]
"$app/*": ["src/appflowy_app/*"],
"$app_reducers/*": ["src/appflowy_app/stores/reducers/*"],
},
},
"include": ["src", "vite.config.ts", "../app_flowy/assets/translations"],

View File

@ -27,7 +27,8 @@ export default defineConfig({
resolve: {
alias: [
{ find: '@/', replacement: `${__dirname}/src/` },
{ find: '$app/', replacement: `${__dirname}/src/appflowy_app/` }
{ find: '$app/', replacement: `${__dirname}/src/appflowy_app/` },
{ find: '$app_reducers/', replacement: `${__dirname}/src/appflowy_app/stores/reducers/` },
],
},
});